SDD §8 panels (PV/oil/meter-detail) + fix reference-data-in-Docker
Complete the SDD §8 dashboard views that were deferred at the M5 boundary,
and fix a shipping bug that left the Docker demo empty.
Bug: "Load reference data" created meters/tank/tariffs but imported zero
readings in Docker. Root cause: sampledata/ was excluded by .dockerignore and
never copied into the build stage, so the App csproj's linked Content glob
resolved to nothing at publish time; ReferenceDataImporter then silently
skipped the missing CSVs after already writing its marker meter, leaving the
DB permanently "loaded" but empty.
- .dockerignore: stop excluding sampledata/
- Dockerfile: COPY sampledata/ into the build stage
- ReferenceDataImporter: fail-fast (validate CSVs exist before the marker
meter) and throw instead of silently skipping a missing file
- Program.cs + MeterVaultOptions: opt-in MeterVault__SeedReferenceData
(compose METERVAULT_SEED=true) for a one-command populated demo
New SDD §8 panels (read models in Infrastructure/Dashboard, Blazor pages):
- §8.4 Solar/PV (/solar): generation from GenerationCounter meters;
self-consumption / autarky % / self-consumption % / savings derived from
meters tagged total_load & grid_import via Meter.Meta role config
(MeterRoles/MeterMeta) — nothing hardcoded by name.
- §8.5 Oil/consumable (/consumables): tank level (cm→L calibrated), fill
gauge, deliveries log, burner runtime, effective L/h (fixed/empirical),
forecast-to-empty, tariff cost, monthly series.
- §8.6 Meter detail (/meters/{id}): raw readings, normalized consumption,
source status, tariff timeline, events, measured-vs-estimated markers.
- Reusable SeriesChart component; nav links; Meters list rows link to detail.
Tests: MeterMetaTests (Core, +10); DashboardRenderTests extended to assert the
three panel services compute real figures and the new routes render (108 total,
all green). Live-verified in Docker: seed imports 302 readings / 347 consumption
rows; panels render (generation 16,481 kWh, oil 3,967 L) cross-checking the DB.
Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
@page "/meters/{Id:int}"
|
||||
@rendermode InteractiveServer
|
||||
@inject MeterDetailService Details
|
||||
@inject NavigationManager Nav
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Meter</PageTitle>
|
||||
|
||||
@if (_detail is null)
|
||||
{
|
||||
@if (_notFound)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning">Meter #@Id not found. <MudLink Href="/meters">Back to meters</MudLink></MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex align-center mb-4" style="gap:.75rem">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/meters" Size="Size.Small" />
|
||||
<MudText Typo="Typo.h4">@_detail.Name</MudText>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Primary">@_detail.EnergyType</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@_detail.Mode</MudChip>
|
||||
@if (!_detail.IsActive)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">retired</MudChip>
|
||||
}
|
||||
</div>
|
||||
|
||||
<MudGrid Class="mb-2">
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Consumption</MudText>
|
||||
<MudText Typo="Typo.h6">@Format.Number(_detail.TotalConsumption, 0) @_detail.Unit</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
@if (_detail.TotalGeneration != 0)
|
||||
{
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Generation</MudText>
|
||||
<MudText Typo="Typo.h6">@Format.Number(_detail.TotalGeneration, 0) @_detail.Unit</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Readings</MudText>
|
||||
<MudText Typo="Typo.h6">@_detail.ReadingCount</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
@(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Register span</MudText>
|
||||
<MudText Typo="Typo.h6">
|
||||
@(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") →
|
||||
@(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—")
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">baseline @Format.Number(_detail.InitialBaseline, 0)</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2">
|
||||
<MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")">
|
||||
@if (_detail.RecentReadings.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No raw readings.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentReadings.Count (raw, immutable audit truth).</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
||||
<thead><tr><th>Time</th><th style="text-align:right">Value</th><th>Quality</th><th>Flags</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _detail.RecentReadings)
|
||||
{
|
||||
<tr>
|
||||
<td>@r.Time.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td style="text-align:right">@Format.Number(r.Value, 2) @_detail.Unit</td>
|
||||
<td>@QualityChip(r.Quality)</td>
|
||||
<td>@(r.Flags == MeterVault.Core.Domain.ReadingFlags.None ? "" : r.Flags.ToString())</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Consumption ({_detail.ConsumptionCount})")">
|
||||
@if (_detail.RecentConsumption.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No normalized consumption yet.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentConsumption.Count normalized deltas.</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
||||
<thead><tr><th>Time</th><th style="text-align:right">Amount</th><th>Kind</th><th>Quality</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _detail.RecentConsumption)
|
||||
{
|
||||
<tr>
|
||||
<td>@c.Time.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td style="text-align:right">@Format.Number(c.Amount, 2) @_detail.Unit</td>
|
||||
<td>@c.Kind</td>
|
||||
<td>@QualityChip(c.Quality)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Events ({_detail.Events.Count})")">
|
||||
@if (_detail.Events.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No events (swaps, deliveries, corrections).</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Time</th><th>Type</th><th style="text-align:right">Amount</th><th style="text-align:right">Prev→New</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var e in _detail.Events)
|
||||
{
|
||||
<tr>
|
||||
<td>@e.Time.ToString("yyyy-MM-dd")</td>
|
||||
<td>@e.Type</td>
|
||||
<td style="text-align:right">@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—")</td>
|
||||
<td style="text-align:right">@(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—")</td>
|
||||
<td>@e.Notes</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Tariffs ({_detail.Tariffs.Count})")">
|
||||
@if (_detail.Tariffs.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No applicable tariffs.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Scope</th><th>Component</th><th style="text-align:right">Value</th><th>Unit</th><th>From</th><th>To</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var t in _detail.Tariffs)
|
||||
{
|
||||
<tr>
|
||||
<td>@t.Scope @(t.ScopeId is { } id ? $"#{id}" : "")</td>
|
||||
<td>@t.Component</td>
|
||||
<td style="text-align:right">@Format.Number(t.Value, 4)</td>
|
||||
<td>@t.Unit</td>
|
||||
<td>@t.ValidFrom.ToString("yyyy-MM-dd")</td>
|
||||
<td>@(t.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Sources ({_detail.Sources.Count})")">
|
||||
@if (_detail.Sources.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No ingest sources bound to this meter.</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>
|
||||
<tbody>
|
||||
@foreach (var s in _detail.Sources)
|
||||
{
|
||||
<tr>
|
||||
<td>@s.Type</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>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private MeterDetailView? _detail;
|
||||
private bool _notFound;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_detail = null;
|
||||
_notFound = false;
|
||||
_detail = await Details.GetAsync(Id);
|
||||
_notFound = _detail is null;
|
||||
}
|
||||
|
||||
private static RenderFragment QualityChip(ReadingQuality quality) =>@<MudChip T="string" Size="Size.Small" Variant="Variant.Text"
|
||||
Color="@(quality == ReadingQuality.Measured ? Color.Success : quality == ReadingQuality.Estimated ? Color.Warning : Color.Default)">@quality</MudChip>;
|
||||
}
|
||||
Reference in New Issue
Block a user