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:
@@ -1,7 +1,12 @@
|
||||
@page "/meters/{Id:int}"
|
||||
@rendermode InteractiveServer
|
||||
@inject MeterDetailService Details
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager Nav
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Meter</PageTitle>
|
||||
@@ -169,24 +174,34 @@ else
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Sources ({_detail.Sources.Count})")">
|
||||
@if (_detail.Sources.Count == 0)
|
||||
<MudTabPanel Text="@($"Sources ({_sources.Count})")">
|
||||
<div class="d-flex justify-end mb-2">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenSource(null))">
|
||||
Add source
|
||||
</MudButton>
|
||||
</div>
|
||||
@if (_sources.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No ingest sources bound to this meter.</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No ingest sources bound to this meter. Add one to pull from MQTT/Tasmota or Home Assistant.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Type</th><th>Enabled</th><th>Last seen</th><th style="text-align:right">Last value</th><th>Status</th></tr></thead>
|
||||
<thead><tr><th>Type</th><th>Target</th><th>Enabled</th><th>Last seen</th><th style="text-align:right">Last value</th><th>Status</th><th style="text-align:right">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var s in _detail.Sources)
|
||||
@foreach (var s in _sources)
|
||||
{
|
||||
<tr>
|
||||
<td>@s.Type</td>
|
||||
<td>@s.SourceType</td>
|
||||
<td>@SourceTarget(s)</td>
|
||||
<td>@(s.IsEnabled ? "yes" : "no")</td>
|
||||
<td>@(s.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</td>
|
||||
<td style="text-align:right">@(s.LastValue is { } v ? Format.Number(v, 2) : "—")</td>
|
||||
<td>@(s.LastStatus ?? "—")</td>
|
||||
<td style="text-align:right">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenSource(s))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteSourceAsync(s))" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -194,6 +209,57 @@ else
|
||||
}
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
|
||||
<MudDialog @bind-Visible="_sourceOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? "New source" : "Edit source")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudSelect T="SourceType" @bind-Value="_sourceEdit.SourceType" Label="Source type" Class="mb-2">
|
||||
@foreach (var type in Enum.GetValues<SourceType>())
|
||||
{
|
||||
<MudSelectItem T="SourceType" Value="type">@type</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (_sourceEdit.SourceType is SourceType.HomeAssistant or SourceType.Mqtt or SourceType.Tasmota)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_sourceEdit.EndpointId" Label="Connector" Clearable="true" Class="mb-2">
|
||||
@foreach (var e in _endpoints)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name (@e.Type)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
@if (_sourceEdit.SourceType == SourceType.HomeAssistant)
|
||||
{
|
||||
<MudTextField @bind-Value="_sourceEdit.EntityId" Label="Entity id (e.g. sensor.house_power)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.Attribute" Label="Attribute (optional; blank = state)" Class="mb-2" />
|
||||
<MudNumericField T="int?" @bind-Value="_sourceEdit.PollSeconds" Label="Poll interval (seconds)" Class="mb-2" />
|
||||
}
|
||||
else if (_sourceEdit.SourceType is SourceType.Mqtt or SourceType.Tasmota)
|
||||
{
|
||||
<MudTextField @bind-Value="_sourceEdit.Topic" Label="MQTT topic (e.g. tele/plug1/SENSOR)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.Path" Label="Value path (e.g. ENERGY.Total; blank = bare scalar)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.TimePath" Label="Time path (optional, e.g. Time)" Class="mb-2" />
|
||||
}
|
||||
<MudSelect T="SourceValueKind" @bind-Value="_sourceEdit.ValueKind" Label="Value kind" Class="mb-2">
|
||||
@foreach (var kind in Enum.GetValues<SourceValueKind>())
|
||||
{
|
||||
<MudSelectItem T="SourceValueKind" Value="kind">@kind</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<div class="d-flex" style="gap:1rem">
|
||||
<MudNumericField T="double" @bind-Value="_sourceEdit.Scale" Label="Scale" Class="mb-2" />
|
||||
<MudNumericField T="double" @bind-Value="_sourceEdit.Offset" Label="Offset" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_sourceEdit.Priority" Label="Priority" Class="mb-2" />
|
||||
</div>
|
||||
<MudSwitch T="bool" @bind-Value="_sourceEdit.IsEnabled" Label="Enabled" Color="Color.Primary" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _sourceOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveSourceAsync">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -202,6 +268,11 @@ else
|
||||
|
||||
private MeterDetailView? _detail;
|
||||
private bool _notFound;
|
||||
private List<MeterSource> _sources = [];
|
||||
private List<IngestionEndpoint> _endpoints = [];
|
||||
private bool _sourceOpen;
|
||||
private SourceEdit _sourceEdit = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
@@ -209,6 +280,137 @@ else
|
||||
_notFound = false;
|
||||
_detail = await Details.GetAsync(Id);
|
||||
_notFound = _detail is null;
|
||||
if (_detail is not null)
|
||||
{
|
||||
await LoadSourcesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadSourcesAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_sources = await db.MeterSources.AsNoTracking().Where(s => s.MeterId == Id).OrderBy(s => s.Priority).ToListAsync();
|
||||
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
|
||||
}
|
||||
|
||||
private static string SourceTarget(MeterSource s)
|
||||
{
|
||||
var config = SourceConfig.Parse(s.Config);
|
||||
return s.SourceType == SourceType.HomeAssistant
|
||||
? config.EntityId ?? "—"
|
||||
: config.Topic ?? "—";
|
||||
}
|
||||
|
||||
private void OpenSource(MeterSource? source)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
_sourceEdit = new SourceEdit();
|
||||
}
|
||||
else
|
||||
{
|
||||
var config = SourceConfig.Parse(source.Config);
|
||||
_sourceEdit = new SourceEdit
|
||||
{
|
||||
Id = source.Id,
|
||||
SourceType = source.SourceType,
|
||||
EndpointId = source.EndpointId,
|
||||
ValueKind = source.ValueKind,
|
||||
Scale = source.Scale,
|
||||
Offset = source.Offset,
|
||||
Priority = source.Priority,
|
||||
IsEnabled = source.IsEnabled,
|
||||
EntityId = config.EntityId,
|
||||
Attribute = config.Attribute,
|
||||
PollSeconds = config.PollSeconds,
|
||||
Topic = config.Topic,
|
||||
Path = config.Path,
|
||||
TimePath = config.TimePath,
|
||||
};
|
||||
}
|
||||
_sourceOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveSourceAsync()
|
||||
{
|
||||
var config = new SourceConfig
|
||||
{
|
||||
EntityId = Trim(_sourceEdit.EntityId),
|
||||
Attribute = Trim(_sourceEdit.Attribute),
|
||||
PollSeconds = _sourceEdit.PollSeconds,
|
||||
Topic = Trim(_sourceEdit.Topic),
|
||||
Path = Trim(_sourceEdit.Path),
|
||||
TimePath = Trim(_sourceEdit.TimePath),
|
||||
};
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(config,
|
||||
new System.Text.Json.JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (_sourceEdit.Id == 0)
|
||||
{
|
||||
db.MeterSources.Add(new MeterSource
|
||||
{
|
||||
MeterId = Id,
|
||||
SourceType = _sourceEdit.SourceType,
|
||||
EndpointId = _sourceEdit.EndpointId,
|
||||
Config = configJson,
|
||||
ValueKind = _sourceEdit.ValueKind,
|
||||
Scale = _sourceEdit.Scale,
|
||||
Offset = _sourceEdit.Offset,
|
||||
Priority = _sourceEdit.Priority,
|
||||
IsEnabled = _sourceEdit.IsEnabled,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var existing = await db.MeterSources.FirstAsync(s => s.Id == _sourceEdit.Id);
|
||||
existing.SourceType = _sourceEdit.SourceType;
|
||||
existing.EndpointId = _sourceEdit.EndpointId;
|
||||
existing.Config = configJson;
|
||||
existing.ValueKind = _sourceEdit.ValueKind;
|
||||
existing.Scale = _sourceEdit.Scale;
|
||||
existing.Offset = _sourceEdit.Offset;
|
||||
existing.Priority = _sourceEdit.Priority;
|
||||
existing.IsEnabled = _sourceEdit.IsEnabled;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_sourceOpen = false;
|
||||
Snackbar.Add("Source saved.", Severity.Success);
|
||||
await LoadSourcesAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteSourceAsync(MeterSource source)
|
||||
{
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete source", $"Delete this {source.SourceType} source?"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
await db.MeterSources.Where(s => s.Id == source.Id).ExecuteDeleteAsync();
|
||||
Snackbar.Add("Source deleted.", Severity.Success);
|
||||
await LoadSourcesAsync();
|
||||
}
|
||||
|
||||
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private sealed class SourceEdit
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public SourceType SourceType { get; set; } = SourceType.HomeAssistant;
|
||||
public int? EndpointId { get; set; }
|
||||
public SourceValueKind ValueKind { get; set; } = SourceValueKind.Register;
|
||||
public double Scale { get; set; } = 1;
|
||||
public double Offset { get; set; }
|
||||
public int Priority { get; set; }
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
public string? EntityId { get; set; }
|
||||
public string? Attribute { get; set; }
|
||||
public int? PollSeconds { get; set; } = 60;
|
||||
public string? Topic { get; set; }
|
||||
public string? Path { get; set; }
|
||||
public string? TimePath { get; set; }
|
||||
}
|
||||
|
||||
private static RenderFragment QualityChip(ReadingQuality quality) =>@<MudChip T="string" Size="Size.Small" Variant="Variant.Text"
|
||||
|
||||
Reference in New Issue
Block a user