Admin write-CRUD, Home Assistant connector config, wiring audit
ci / build-test (push) Successful in 1m19s
ci / build-test (push) Successful in 1m19s
Three requested phases.
1) Admin section (SDD §8.7) — MudBlazor inline-dialog CRUD, consistent pattern,
delete guards, snackbar feedback, shared Confirm helper:
- Energy types: create/edit/delete (blocks delete when meters reference it).
- Meters: create/edit/delete; recomputes consumption when mode/baseline
changes (NormalizationService over a fresh factory context, in a tx);
delete cascades data (consumption+readings are Restrict → removed first).
- A meter's ingest sources: manage on the meter-detail Sources tab
(add/edit/delete MQTT/Tasmota/HA sources with typed config).
- Tariffs: full CRUD (scope/component/value/validity).
- Cost categories: CRUD + member management (meter or energy-type members).
- Connectors: ingestion_endpoint CRUD (MQTT broker + Home Assistant);
secrets referenced by env-var name only, never stored.
- Settings: read-only effective-config view (settings are env-driven and
reproducible, so an editable form would change nothing — kept honest).
PV role is now editable on meters (MeterMeta.SetRole can clear a role).
2) Read Home Assistant — extracted a shared public HaEndpointConfig (was a
private record in the worker), added HaConnectionTester (powers the connector
"Test connection": checks base URL + env-resolved token, optionally reads one
entity). Configuring an HA connector + an HA source on a meter drives the
existing REST-poll worker end to end. (WebSocket push stays a future
optimization; REST poll already reads HA.)
3) Wiring/placeholder audit — swept every OnClick/Href: all handlers are real,
all internal links resolve to real routes, no TODO/stub/placeholder code.
Fixed one genuine gap: MainLayout had no drawer toggle, so the nav was
unreachable on narrow screens — added a hamburger button.
Tests: +6 (MeterMeta.SetRole role-removal; HaConnectionTester fail-closed
guard branches with a throwing HttpClientFactory proving no network on bad
config); render test now covers all admin routes. 69 Core + 45 Integration =
114 green. Live-verified in Docker: all admin pages 200, drawer toggle present,
Settings shows real effective config.
Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
@page "/admin/connectors"
|
||||
@rendermode InteractiveServer
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject MeterVault.Infrastructure.Ingestion.HaConnectionTester HaTester
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Connectors</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Connectors</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add connector
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
||||
Secrets are never stored here. Credentials/tokens are referenced by the <b>name of an environment variable</b>
|
||||
(or Docker secret) resolved at runtime.
|
||||
</MudAlert>
|
||||
|
||||
@if (_endpoints is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="_endpoints" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh>Enabled</MudTh>
|
||||
<MudTh>Last status</MudTh>
|
||||
<MudTh>Last seen</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Type">@context.Type</MudTd>
|
||||
<MudTd DataLabel="Enabled">@(context.IsEnabled ? "yes" : "no")</MudTd>
|
||||
<MudTd DataLabel="Last status">@(context.LastStatus ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Last seen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
@if (_endpoints.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Normal" Class="mt-4">No connectors yet. Add an MQTT broker or a Home Assistant connection.</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New connector" : $"Edit {_working.Name}")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudSelect T="EndpointType" @bind-Value="_working.Type" Label="Type" Class="mb-2">
|
||||
@foreach (var type in Enum.GetValues<EndpointType>())
|
||||
{
|
||||
<MudSelectItem T="EndpointType" Value="type">@type</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
||||
|
||||
@if (_working.Type == EndpointType.HomeAssistant)
|
||||
{
|
||||
<MudTextField @bind-Value="_working.BaseUrl" Label="Base URL (e.g. http://homeassistant.local:8123)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.TokenEnv" Label="Token env-var name (e.g. HA_TOKEN)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.TestEntityId" Label="Test entity id (optional, e.g. sensor.house_power)" Class="mb-2" />
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestHaAsync" Disabled="_testing" Class="mb-2">
|
||||
Test connection
|
||||
</MudButton>
|
||||
@if (_testing)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mb-2" />
|
||||
}
|
||||
@if (_testResult is not null)
|
||||
{
|
||||
<MudAlert Severity="@(_testResult.Ok ? Severity.Success : Severity.Error)" Dense="true" Class="mb-2">@_testResult.Message</MudAlert>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTextField @bind-Value="_working.Host" Label="Host" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_working.Port" Label="Port" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.Tls" Label="TLS" Color="Color.Primary" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.UsernameEnv" Label="Username env-var name (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.PasswordEnv" Label="Password env-var name (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.ExtraTopics" Label="Extra topics (comma-separated, optional)" Class="mb-2" />
|
||||
}
|
||||
<MudSwitch T="bool" @bind-Value="_working.IsEnabled" Label="Enabled" Color="Color.Primary" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
private List<IngestionEndpoint>? _endpoints;
|
||||
private bool _editOpen;
|
||||
private bool _testing;
|
||||
private HaTestResult? _testResult;
|
||||
private EditModel _working = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
|
||||
}
|
||||
|
||||
private void OpenEdit(IngestionEndpoint? endpoint)
|
||||
{
|
||||
_testResult = null;
|
||||
if (endpoint is null)
|
||||
{
|
||||
_working = new EditModel();
|
||||
}
|
||||
else if (endpoint.Type == EndpointType.HomeAssistant)
|
||||
{
|
||||
var ha = HaEndpointConfig.Parse(endpoint.Config);
|
||||
_working = new EditModel
|
||||
{
|
||||
Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled,
|
||||
BaseUrl = ha.BaseUrl, TokenEnv = ha.TokenEnv,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
var mqtt = EndpointConfig.Parse(endpoint.Config);
|
||||
_working = new EditModel
|
||||
{
|
||||
Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled,
|
||||
Host = mqtt.Host, Port = mqtt.Port, Tls = mqtt.Tls,
|
||||
UsernameEnv = mqtt.UsernameEnv, PasswordEnv = mqtt.PasswordEnv,
|
||||
ExtraTopics = string.Join(", ", mqtt.ExtraTopics),
|
||||
};
|
||||
}
|
||||
_editOpen = true;
|
||||
}
|
||||
|
||||
private async Task TestHaAsync()
|
||||
{
|
||||
_testing = true;
|
||||
_testResult = null;
|
||||
try
|
||||
{
|
||||
_testResult = await HaTester.TestAsync(_working.BaseUrl, _working.TokenEnv, _working.TestEntityId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Name))
|
||||
{
|
||||
Snackbar.Add("Name is required.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var config = _working.Type == EndpointType.HomeAssistant
|
||||
? new HaEndpointConfig { BaseUrl = Trim(_working.BaseUrl), TokenEnv = Trim(_working.TokenEnv) }.ToJson()
|
||||
: new EndpointConfig
|
||||
{
|
||||
Host = string.IsNullOrWhiteSpace(_working.Host) ? "localhost" : _working.Host.Trim(),
|
||||
Port = _working.Port,
|
||||
Tls = _working.Tls,
|
||||
UsernameEnv = Trim(_working.UsernameEnv),
|
||||
PasswordEnv = Trim(_working.PasswordEnv),
|
||||
ExtraTopics = SplitTopics(_working.ExtraTopics),
|
||||
}.ToJson();
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (_working.Id == 0)
|
||||
{
|
||||
db.IngestionEndpoints.Add(new IngestionEndpoint
|
||||
{
|
||||
Type = _working.Type, Name = _working.Name.Trim(), Config = config, IsEnabled = _working.IsEnabled,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var existing = await db.IngestionEndpoints.FirstAsync(e => e.Id == _working.Id);
|
||||
existing.Type = _working.Type;
|
||||
existing.Name = _working.Name.Trim();
|
||||
existing.Config = config;
|
||||
existing.IsEnabled = _working.IsEnabled;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(IngestionEndpoint endpoint)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var sourceCount = await db.MeterSources.CountAsync(s => s.EndpointId == endpoint.Id);
|
||||
var note = sourceCount > 0 ? $" {sourceCount} source(s) reference it and will be unlinked." : "";
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete connector", $"Delete '{endpoint.Name}'?{note}"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await db.IngestionEndpoints.Where(e => e.Id == endpoint.Id).ExecuteDeleteAsync();
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static IReadOnlyList<string> SplitTopics(string? csv) =>
|
||||
string.IsNullOrWhiteSpace(csv) ? [] : [.. csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
|
||||
|
||||
private sealed class EditModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public EndpointType Type { get; set; } = EndpointType.HomeAssistant;
|
||||
public string Name { get; set; } = "";
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
// Home Assistant
|
||||
public string? BaseUrl { get; set; }
|
||||
public string? TokenEnv { get; set; }
|
||||
public string? TestEntityId { get; set; }
|
||||
|
||||
// MQTT broker
|
||||
public string? Host { get; set; } = "localhost";
|
||||
public int Port { get; set; } = 1883;
|
||||
public bool Tls { get; set; }
|
||||
public string? UsernameEnv { get; set; }
|
||||
public string? PasswordEnv { get; set; }
|
||||
public string? ExtraTopics { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user