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,11 +1,20 @@
|
||||
@page "/meters"
|
||||
@rendermode InteractiveServer
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject MeterVault.Core.Normalization.INormalizationEngine Engine
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Meters</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Meters</MudText>
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Meters</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add meter
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@if (_meters is null)
|
||||
{
|
||||
@@ -21,6 +30,8 @@ else
|
||||
<MudTh>Unit</MudTh>
|
||||
<MudTh>Sources</MudTh>
|
||||
<MudTh>Last seen</MudTh>
|
||||
<MudTh>Active</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name"><MudLink Href="@($"/meters/{context.Id}")">@context.Name</MudLink></MudTd>
|
||||
@@ -30,31 +41,83 @@ else
|
||||
<MudTd DataLabel="Sources">@context.Sources.Count</MudTd>
|
||||
<MudTd DataLabel="Last seen">
|
||||
@{
|
||||
var lastSeen = context.Sources
|
||||
.Where(s => s.LastSeenAt != null)
|
||||
.Select(s => s.LastSeenAt)
|
||||
.DefaultIfEmpty(null)
|
||||
.Max();
|
||||
var lastSeen = context.Sources.Where(s => s.LastSeenAt != null).Select(s => s.LastSeenAt).DefaultIfEmpty(null).Max();
|
||||
}
|
||||
@(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—")
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Active">@(context.IsActive ? "yes" : "no")</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 (_meters.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||
No meters yet. Go to <MudLink Href="/import">Import</MudLink> to load the reference data.
|
||||
No meters yet. Add one, or go to <MudLink Href="/import">Import</MudLink> to load the reference data.
|
||||
</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New meter" : $"Edit {_working.Name}")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
||||
<MudSelect T="short" @bind-Value="_working.EnergyTypeId" Label="Energy type" Class="mb-2">
|
||||
@foreach (var t in _energyTypes)
|
||||
{
|
||||
<MudSelectItem T="short" Value="t.Id">@t.DisplayName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="Measurement mode" Class="mb-2">
|
||||
@foreach (var mode in Enum.GetValues<MeterMode>())
|
||||
{
|
||||
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField @bind-Value="_working.Unit" Label="Unit" Required="true" Class="mb-2" />
|
||||
<MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="Initial register baseline" Class="mb-2" />
|
||||
<MudSelect T="string" @bind-Value="_working.Role" Label="PV role (optional)" Class="mb-2">
|
||||
<MudSelectItem T="string" Value="@("")">— none —</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@MeterRoles.TotalLoad">total_load</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@MeterRoles.GridImport">grid_import</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@MeterRoles.GridExport">grid_export</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudTextField @bind-Value="_working.Location" Label="Location (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.SerialNumber" Label="Serial number (optional)" Class="mb-2" />
|
||||
<div class="d-flex" style="gap:1rem">
|
||||
<MudTextField @bind-Value="_working.Manufacturer" Label="Manufacturer (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Model" Label="Model (optional)" Class="mb-2" />
|
||||
</div>
|
||||
<MudSwitch T="bool" @bind-Value="_working.IsActive" Label="Active" Color="Color.Primary" />
|
||||
@if (_working.Id != 0 && _working.RecomputeNeeded)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">Mode/baseline changed — consumption will be recomputed on save.</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
private List<Meter>? _meters;
|
||||
private List<EnergyType> _energyTypes = [];
|
||||
private bool _editOpen;
|
||||
private EditModel _working = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
|
||||
_meters = await db.Meters
|
||||
.AsNoTracking()
|
||||
.Include(m => m.EnergyType)
|
||||
@@ -62,4 +125,140 @@ else
|
||||
.OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private void OpenEdit(Meter? meter)
|
||||
{
|
||||
if (meter is null)
|
||||
{
|
||||
_working = new EditModel { EnergyTypeId = _energyTypes.FirstOrDefault()?.Id ?? 0 };
|
||||
}
|
||||
else
|
||||
{
|
||||
_working = new EditModel
|
||||
{
|
||||
Id = meter.Id,
|
||||
Name = meter.Name,
|
||||
EnergyTypeId = meter.EnergyTypeId,
|
||||
Mode = meter.Mode,
|
||||
OriginalMode = meter.Mode,
|
||||
Unit = meter.Unit,
|
||||
InitialBaseline = meter.InitialBaseline,
|
||||
OriginalBaseline = meter.InitialBaseline,
|
||||
Role = MeterMeta.Role(meter.Meta) ?? "",
|
||||
Location = meter.Location,
|
||||
SerialNumber = meter.SerialNumber,
|
||||
Manufacturer = meter.Manufacturer,
|
||||
Model = meter.Model,
|
||||
IsActive = meter.IsActive,
|
||||
};
|
||||
}
|
||||
_editOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Name) || string.IsNullOrWhiteSpace(_working.Unit) || _working.EnergyTypeId == 0)
|
||||
{
|
||||
Snackbar.Add("Name, energy type and unit are required.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (_working.Id == 0)
|
||||
{
|
||||
db.Meters.Add(new Meter
|
||||
{
|
||||
Name = _working.Name.Trim(),
|
||||
EnergyTypeId = _working.EnergyTypeId,
|
||||
Mode = _working.Mode,
|
||||
Unit = _working.Unit.Trim(),
|
||||
InitialBaseline = _working.InitialBaseline,
|
||||
Meta = MeterMeta.SetRole("{}", _working.Role),
|
||||
Location = Trim(_working.Location),
|
||||
SerialNumber = Trim(_working.SerialNumber),
|
||||
Manufacturer = Trim(_working.Manufacturer),
|
||||
Model = Trim(_working.Model),
|
||||
IsActive = _working.IsActive,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
var existing = await db.Meters.FirstAsync(m => m.Id == _working.Id);
|
||||
existing.Name = _working.Name.Trim();
|
||||
existing.EnergyTypeId = _working.EnergyTypeId;
|
||||
existing.Mode = _working.Mode;
|
||||
existing.Unit = _working.Unit.Trim();
|
||||
existing.InitialBaseline = _working.InitialBaseline;
|
||||
existing.Meta = MeterMeta.SetRole(existing.Meta, _working.Role);
|
||||
existing.Location = Trim(_working.Location);
|
||||
existing.SerialNumber = Trim(_working.SerialNumber);
|
||||
existing.Manufacturer = Trim(_working.Manufacturer);
|
||||
existing.Model = Trim(_working.Model);
|
||||
existing.IsActive = _working.IsActive;
|
||||
existing.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (_working.RecomputeNeeded)
|
||||
{
|
||||
var normalization = new MeterVault.Infrastructure.Normalization.NormalizationService(db, Engine);
|
||||
await normalization.RecomputeMeterAsync(existing.Id, null);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
}
|
||||
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Meter meter)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var readings = await db.Readings.CountAsync(r => r.MeterId == meter.Id);
|
||||
var consumption = await db.Consumption.CountAsync(c => c.MeterId == meter.Id);
|
||||
var detail = readings + consumption > 0
|
||||
? $" This will also delete {readings} reading(s) and {consumption} consumption row(s)."
|
||||
: "";
|
||||
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete meter", $"Delete '{meter.Name}'?{detail} This cannot be undone."))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
// reading/consumption FKs are Restrict — remove them first; events/sources/tank/members cascade.
|
||||
await db.Consumption.Where(c => c.MeterId == meter.Id).ExecuteDeleteAsync();
|
||||
await db.Readings.Where(r => r.MeterId == meter.Id).ExecuteDeleteAsync();
|
||||
await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private sealed class EditModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public short EnergyTypeId { get; set; }
|
||||
public MeterMode Mode { get; set; } = MeterMode.CumulativeCounter;
|
||||
public MeterMode OriginalMode { get; set; } = MeterMode.CumulativeCounter;
|
||||
public string Unit { get; set; } = "";
|
||||
public double InitialBaseline { get; set; }
|
||||
public double OriginalBaseline { get; set; }
|
||||
public string Role { get; set; } = "";
|
||||
public string? Location { get; set; }
|
||||
public string? SerialNumber { get; set; }
|
||||
public string? Manufacturer { get; set; }
|
||||
public string? Model { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public bool RecomputeNeeded => Mode != OriginalMode || Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user