The last open item on the M7 list. Number and currency formatting was already locale-aware, but every string in the UI was an English literal, so a German instance read half in each language -- German data, English chrome. This translates all of it and adds the machinery to keep it translated. Strings live in Localization/Strings.resx (English, neutral) and Strings.de.resx. The neutral file generates a strongly-typed accessor at build time, aliased as S in _Imports.razor, so components reference compiled properties -- @S.Common_Save, not a string key. That choice is the point: across 4,500 lines of markup, a key lookup that silently falls back to its own name is a defect you find in production, while a renamed property is a build error. Generation runs in MSBuild rather than the IDE designer, so dotnet build alone reproduces it anywhere. Resource fallback is the hazard here. Ask for a key the German satellite lacks and ResourceManager quietly serves the English one -- correct at runtime, disastrous at release time, because a half-translated build looks perfectly healthy. StringResourceTests reads each satellite with tryParents: false, which is the only way to see what one actually contains, and fails on a missing or blank translation, a placeholder that changed arity, an orphan, or a key nothing references. Three things needed more than substitution: - Domain enums reached the screen as bare identifiers. They stay bare in the model -- they are persisted as text and appear in the REST API, so their names are part of the data contract -- and DisplayNames is now the single place that decides how each value is spoken. Every arm ends in a fallback returning the identifier, so a value added later cannot throw mid-render; EnumDisplayNameTests is what stops that safety net quietly becoming the shipping behaviour. - Infrastructure was writing display text: FlowService's "Other (X)", MeterPeriodView's "Generation"/"Consumption", the HA connection-test verdicts, the updater's snackbar, the CSV importer's row warnings. Each now returns an outcome value and the UI supplies the words, which is where the reader's language is known. Diagnostics that are not ours -- an HTTP status, systemd's stderr, an exception message -- are passed through untranslated, and every English summary is kept alongside the outcome so log lines never move with the UI language. The UpdateRunner change is additive only; no gate was touched. - Importer warnings carry their arguments rather than a finished sentence, so the numbers inside them pick up the reader's grouping. A register that reads 2.940,19 everywhere else must not read 2940.19 only inside a warning. Switching language is a redirect through /culture/set followed by a full reload, not an interactive state change: a Blazor Server circuit is fixed to the culture of the request that opened it. That makes the endpoint a redirector taking its target from the query string, so anything but a local path is refused rather than followed. Preference order is the cookie, then Accept-Language, then MeterVault__Locale -- an instance can be pinned to one language and a reader can still switch. Locale keeps its documented default of "en". Format now follows CurrentCulture instead of a hardcoded de-DE, so an instance with nothing configured and a browser asking for English will show English number formatting where it previously showed German; set MeterVault__Locale=de to pin the old behaviour. The importer's de-DE parsing is untouched and stays that way -- that dialect is a property of the spreadsheets, not of whoever is looking at the dashboard. Anything that comes from the database -- meter names, energy-type display names, category names -- is user data and is never translated. Claude-Session: https://claude.ai/code/session_0112ezeWqaZ85kTj5bYu9JHx
This commit is contained in:
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
MeterVault is a self-hosted, local-first energy & utility metering platform: it ingests meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading timestamped and immutable, normalizes it into consumption, and turns it into cost dashboards. Energy types (electricity, water, heating oil, gas, …) and meters are **user-defined, never hardcoded**.
|
||||
|
||||
**Status: implemented (M0–M7) + SDD §8 panels.** The full solution is built and green — five projects, ~108 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). The dedicated **PV/Solar** (`/solar`), **Oil/consumable** (`/consumables`) and **meter-detail** (`/meters/{id}`) views (SDD §8.4–§8.6) are implemented as read models in `Infrastructure/Dashboard` (`SolarService`, `ConsumableService`, `MeterDetailService`) — PV meters are found by `Mode == GenerationCounter` and grid/load meters by a `role` tag in `Meter.Meta` (`MeterRoles`/`MeterMeta`), so nothing is hardcoded by name. **Admin write-CRUD** (SDD §8.7) is implemented as MudBlazor inline-dialog pages: energy types, meters (+ recompute on mode/baseline change), a meter's ingest sources (meter-detail Sources tab), tariffs, cost categories + members, and connectors (`ingestion_endpoint`, secrets by env-var reference only). **Manual readings** are entered from the meter-detail Readings tab ("Add reading"): a touch-first dialog prefilled with the meter's last register value and the current local time, with an on-screen keypad for phone entry at the meter, a live parsed-value + delta-since-last readout, and the decrease guard surfaced before saving. It goes through `IngestionService.IngestByMeterAsync(quality: Manual)`, so it is stamped `ReadingQuality.Manual` and renormalizes inline like any other ingest — the layout of that dialog deliberately reserves fixed space for its verdict line, because anything that reflows moves the keys out from under the user's thumb mid-entry. `/admin/settings` is a read-only effective-config view (settings are env-driven and reproducible, not DB-stored). **Home Assistant reading** is configured here: an HA connector (`BaseUrl` + `TokenEnv`) + an HA source (entity id) drives `HomeAssistantWorker`'s REST poll, or — with the connector's **WebSocket push** toggle (`HaEndpointConfig.UseWebSocket`) — `HomeAssistantWebSocketWorker` holds a persistent `state_changed` subscription and ingests in real time (the poll worker skips WS endpoints, so each is served once; `HaWebSocketProtocol` is the pure, unit-tested handshake/parse logic). `HaConnectionTester` powers the connector "Test connection" button. **Meter topology & flow**: `MeterLink` (a directed `from→to` edge; a downstream meter is a *subsection* of an upstream one, multi-parent allowed) drives a per-energy-type page `/energy/{id}` with a hand-rolled SVG **Sankey** (`SankeyChart.razor`, since ApexCharts has no Sankey type) computed by `FlowService` (link value = downstream consumption, split proportionally across multiple parents; unaccounted remainder → an "Other" node). Upstream meters are wired cycle-safely in the meter editor; the nav lists a link per energy type. **CSV mapping wizard** (`/import/wizard`): upload an arbitrary CSV, map columns → meters/roles, dry-run preview, then commit as a revertible `import_batch` (the `/import` page lists batches with one-click revert). The `instant_rate` mode is normalized (`InstantRateNormalizer`: rate integrated over time, trapezoidal). Remaining refinement: full de-DE UI-string localization (data parsing is already de-DE) — noted at its commit. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo.
|
||||
**Status: implemented (M0–M7) + SDD §8 panels.** The full solution is built and green — five projects, ~108 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). The dedicated **PV/Solar** (`/solar`), **Oil/consumable** (`/consumables`) and **meter-detail** (`/meters/{id}`) views (SDD §8.4–§8.6) are implemented as read models in `Infrastructure/Dashboard` (`SolarService`, `ConsumableService`, `MeterDetailService`) — PV meters are found by `Mode == GenerationCounter` and grid/load meters by a `role` tag in `Meter.Meta` (`MeterRoles`/`MeterMeta`), so nothing is hardcoded by name. **Admin write-CRUD** (SDD §8.7) is implemented as MudBlazor inline-dialog pages: energy types, meters (+ recompute on mode/baseline change), a meter's ingest sources (meter-detail Sources tab), tariffs, cost categories + members, and connectors (`ingestion_endpoint`, secrets by env-var reference only). **Manual readings** are entered from the meter-detail Readings tab ("Add reading"): a touch-first dialog prefilled with the meter's last register value and the current local time, with an on-screen keypad for phone entry at the meter, a live parsed-value + delta-since-last readout, and the decrease guard surfaced before saving. It goes through `IngestionService.IngestByMeterAsync(quality: Manual)`, so it is stamped `ReadingQuality.Manual` and renormalizes inline like any other ingest — the layout of that dialog deliberately reserves fixed space for its verdict line, because anything that reflows moves the keys out from under the user's thumb mid-entry. `/admin/settings` is a read-only effective-config view (settings are env-driven and reproducible, not DB-stored). **Home Assistant reading** is configured here: an HA connector (`BaseUrl` + `TokenEnv`) + an HA source (entity id) drives `HomeAssistantWorker`'s REST poll, or — with the connector's **WebSocket push** toggle (`HaEndpointConfig.UseWebSocket`) — `HomeAssistantWebSocketWorker` holds a persistent `state_changed` subscription and ingests in real time (the poll worker skips WS endpoints, so each is served once; `HaWebSocketProtocol` is the pure, unit-tested handshake/parse logic). `HaConnectionTester` powers the connector "Test connection" button. **Meter topology & flow**: `MeterLink` (a directed `from→to` edge; a downstream meter is a *subsection* of an upstream one, multi-parent allowed) drives a per-energy-type page `/energy/{id}` with a hand-rolled SVG **Sankey** (`SankeyChart.razor`, since ApexCharts has no Sankey type) computed by `FlowService` (link value = downstream consumption, split proportionally across multiple parents; unaccounted remainder → an "Other" node). Upstream meters are wired cycle-safely in the meter editor; the nav lists a link per energy type. **CSV mapping wizard** (`/import/wizard`): upload an arbitrary CSV, map columns → meters/roles, dry-run preview, then commit as a revertible `import_batch` (the `/import` page lists batches with one-click revert). The `instant_rate` mode is normalized (`InstantRateNormalizer`: rate integrated over time, trapezoidal). **UI language** (M7's last item) is now English + German end to end — see *Localization* below. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo.
|
||||
|
||||
## Source of truth
|
||||
|
||||
@@ -84,6 +84,8 @@ sources (Tasmota/HA/MQTT/manual/CSV)
|
||||
|
||||
**In-app update (`UpdateRunner`):** the dashboard shows a banner when a newer tag exists (`UpdateCheckService`, cached, never blocks a render). Triggering an update is **off by default**; `MeterVault__AllowInAppUpdate` is the *only* gate — no API key, by explicit owner decision. With it on, anything that can reach the app can trigger a rebuild+restart as root (realistically a DoS, since the build comes from the owner's own repo; RCE if that repo is compromised). The REST endpoint additionally requires an `X-MeterVault-Update` header — a CSRF guard, not auth, so a foreign page cannot drive it via a LAN browser. Launches detached via `systemd-run` because the update restarts the service. Treat any change here as security-critical; `UpdateRunnerTests` pins that the flag defaults off and that API keys alone don't enable it.
|
||||
|
||||
**Localization (SDD §12, M7 — en + de):** UI strings live in `src/App/Localization/Strings.resx` (neutral = English) and `Strings.de.resx`. MSBuild generates a **strongly-typed** `Strings` class from the neutral resx (see the `EmbeddedResource` block in `MeterVault.App.csproj`), aliased as `S` in `_Imports.razor` — so components write `@S.Common_Save`, never a string key, and a stale key is a build error. `Loc.F(S.Key, args)` formats the `{0}` ones. **Adding a string means editing both resx files**: `StringResourceTests` fails the build on a missing or blank translation, a placeholder mismatch, an orphan, or a key nothing references — resource fallback would otherwise hide a half-translated release. Domain **enums stay bare identifiers** (they are persisted as text and appear in the REST API); `DisplayNames.Display()` is the single place that decides how each value is *spoken*, and `EnumDisplayNameTests` fails if a value has no wording. Anything from the database (meter names, energy-type display names, category names) is user data and is **never** translated. Language is per-request: the cookie the `/culture/set` endpoint writes, else `Accept-Language`, else `MeterVault__Locale` (default `en`). Switching must be a **full reload** (`forceLoad`) — a Blazor Server circuit is fixed to the culture of the request that opened it. `Format.*` formats against `CurrentCulture`, so numbers and month labels follow the reader; the CSV importer's de-DE parsing is unrelated and unchanged, because that dialect belongs to the files, not the reader.
|
||||
|
||||
**Secrets (SDD §6.4):** broker/HA tokens are **never** stored in DB plaintext. Two forms, chosen per connector in the admin UI: a *reference* (`token_env`/`password_env` naming an env var or Docker secret path) resolved at runtime, or *encrypted at rest* (`token_enc`/`password_enc`) via `SecretProtector` over the ASP.NET Core data-protection key ring. Exactly one survives a save; `EndpointSecret.Resolve` is the single resolution path (encrypted wins). The key ring lives outside the app directory (`MeterVault__DataProtectionKeyPath`, default `/var/lib/metervault/keys`) because the LXC updater republishes `/opt/metervault`. `ExportService` drops `*_enc` values — they are bound to the originating key ring.
|
||||
|
||||
## Reference-data behaviours the code must reproduce (from `sampledata/`)
|
||||
|
||||
@@ -67,6 +67,7 @@ Configuration is via environment variables (`Section__Key` double-underscore map
|
||||
|----------|---------|
|
||||
| `ConnectionStrings__Default` | PostgreSQL/Timescale connection string |
|
||||
| `MeterVault__TimeZone` | Local timezone for buckets/display (default `Europe/Berlin`) |
|
||||
| `MeterVault__Locale` | Default UI language, `en` or `de` (default `en`). Each visitor can switch it from the app bar; the choice is remembered in a cookie. |
|
||||
| `MeterVault__ApiKeys__0` | An API key accepted on the `X-Api-Key` header |
|
||||
| `MeterVault__AllowAnonymousApi` | `true` to open the REST API without a key (trusted LAN only) |
|
||||
| `MeterVault__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy |
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
|
||||
<Config Name="Time zone" Target="MeterVault__TimeZone" Default="Europe/Berlin" Mode="" Description="IANA timezone for bucketing/display" Type="Variable" Display="always" Required="false">Europe/Berlin</Config>
|
||||
|
||||
<Config Name="Language" Target="MeterVault__Locale" Default="en" Mode="" Description="Default UI language: en or de. Each visitor can switch it in the app bar." Type="Variable" Display="always" Required="false">en</Config>
|
||||
|
||||
<Config Name="API key" Target="MeterVault__ApiKeys__0" Default="" Mode="" Description="API key for the REST API (X-Api-Key header). Leave blank to leave the API open." Type="Variable" Display="always" Required="false" Mask="true"/>
|
||||
|
||||
<Config Name="Reverse-proxy trust" Target="MeterVault__ReverseProxyTrust" Default="false" Mode="" Description="Honour X-Forwarded-User from a trusted auth proxy" Type="Variable" Display="advanced" Required="false">false</Config>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@using System.Globalization
|
||||
@using Microsoft.AspNetCore.Localization
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="@CultureInfo.CurrentUICulture.TwoLetterISOLanguageName">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -23,3 +26,26 @@
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
// Pin whatever the middleware negotiated (Accept-Language, or the configured default) into the
|
||||
// culture cookie on the very first visit. Without this the language picker would be the only
|
||||
// thing that ever writes the cookie, so a reader whose browser asked for German would be served
|
||||
// German until the moment they touched the picker — and the picker would open showing English.
|
||||
protected override void OnInitialized() =>
|
||||
HttpContext?.Response.Cookies.Append(
|
||||
CookieRequestCultureProvider.DefaultCookieName,
|
||||
CookieRequestCultureProvider.MakeCookieValue(
|
||||
new RequestCulture(CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture)),
|
||||
new CookieOptions
|
||||
{
|
||||
Path = "/",
|
||||
Expires = DateTimeOffset.UtcNow.AddYears(1),
|
||||
SameSite = SameSiteMode.Lax,
|
||||
HttpOnly = true,
|
||||
IsEssential = true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@inherits LayoutComponentBase
|
||||
@using System.Globalization
|
||||
@using MeterVault.App.Theme
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<MudThemeProvider Theme="MeterVaultTheme.Instance" @bind-IsDarkMode="_darkMode" />
|
||||
<MudPopoverProvider />
|
||||
@@ -9,11 +11,24 @@
|
||||
<MudLayout>
|
||||
<MudAppBar Elevation="1" Dense="true">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
|
||||
OnClick="@(() => _drawerOpen = !_drawerOpen)" aria-label="Toggle navigation" />
|
||||
OnClick="@(() => _drawerOpen = !_drawerOpen)" aria-label="@S.Layout_ToggleNavigation" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.Bolt" Class="mr-2" />
|
||||
<MudText Typo="Typo.h6">MeterVault</MudText>
|
||||
<MudSpacer />
|
||||
<MudTooltip Text="@(_darkMode ? "Light mode" : "Dark mode")">
|
||||
<MudTooltip Text="@S.Layout_Language">
|
||||
<MudMenu Icon="@Icons.Material.Filled.Translate" Color="Color.Inherit"
|
||||
AriaLabel="@S.Layout_Language" Dense="true">
|
||||
@foreach (var culture in Loc.SupportedCultures)
|
||||
{
|
||||
<MudMenuItem OnClick="@(() => SwitchCulture(culture))"
|
||||
Icon="@(culture == _current ? Icons.Material.Filled.Check : null)"
|
||||
IconColor="Color.Primary">
|
||||
@Loc.DisplayName(culture)
|
||||
</MudMenuItem>
|
||||
}
|
||||
</MudMenu>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="@(_darkMode ? S.Layout_LightMode : S.Layout_DarkMode)">
|
||||
<MudIconButton Icon="@(_darkMode ? Icons.Material.Filled.LightMode : Icons.Material.Filled.DarkMode)"
|
||||
Color="Color.Inherit" OnClick="@(() => _darkMode = !_darkMode)" />
|
||||
</MudTooltip>
|
||||
@@ -31,12 +46,33 @@
|
||||
</MudLayout>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
@S.Layout_UnhandledError
|
||||
<a href="." class="reload">@S.Layout_Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool _drawerOpen = true;
|
||||
private bool _darkMode = true;
|
||||
|
||||
private string _current = Loc.SupportedCultures[0];
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
Loc.TryResolve(CultureInfo.CurrentUICulture.Name, out _current);
|
||||
|
||||
// A circuit is stuck with the culture it was opened under, so changing language is a real
|
||||
// navigation: the endpoint writes the cookie and forceLoad tears the circuit down so the
|
||||
// reload comes back translated. Returning to the current path keeps the reader in place.
|
||||
private void SwitchCulture(string culture)
|
||||
{
|
||||
if (culture == _current)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var here = new Uri(Navigation.Uri).GetComponents(UriComponents.PathAndQuery, UriFormat.Unescaped);
|
||||
Navigation.NavigateTo(
|
||||
$"/culture/set?culture={Uri.EscapeDataString(culture)}&redirectUri={Uri.EscapeDataString(here)}",
|
||||
forceLoad: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,25 +3,25 @@
|
||||
@using MeterVault.Core.Domain
|
||||
|
||||
<MudNavMenu>
|
||||
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Overview</MudNavLink>
|
||||
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">Trends</MudNavLink>
|
||||
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">@S.Nav_Overview</MudNavLink>
|
||||
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">@S.Nav_Trends</MudNavLink>
|
||||
|
||||
@foreach (var type in _energyTypes)
|
||||
{
|
||||
<MudNavLink Href="@($"/energy/{type.Id}")" Match="NavLinkMatch.Prefix" Icon="@TypeIcon(type.Icon)">@type.DisplayName</MudNavLink>
|
||||
}
|
||||
|
||||
<MudNavLink Href="/solar" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WbSunny">Solar / PV</MudNavLink>
|
||||
<MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">Oil / consumables</MudNavLink>
|
||||
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">Meters</MudNavLink>
|
||||
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Import</MudNavLink>
|
||||
<MudNavLink Href="/solar" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WbSunny">@S.Nav_Solar</MudNavLink>
|
||||
<MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">@S.Nav_Consumables</MudNavLink>
|
||||
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">@S.Nav_Meters</MudNavLink>
|
||||
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">@S.Nav_Import</MudNavLink>
|
||||
<MudDivider Class="my-2" />
|
||||
<MudNavGroup Title="Admin" Icon="@Icons.Material.Filled.Settings" Expanded="false">
|
||||
<MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">Energy types</MudNavLink>
|
||||
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">Tariffs</MudNavLink>
|
||||
<MudNavLink Href="/admin/categories" Icon="@Icons.Material.Filled.Folder">Cost categories</MudNavLink>
|
||||
<MudNavLink Href="/admin/connectors" Icon="@Icons.Material.Filled.SettingsInputComponent">Connectors</MudNavLink>
|
||||
<MudNavLink Href="/admin/settings" Icon="@Icons.Material.Filled.Tune">Settings</MudNavLink>
|
||||
<MudNavGroup Title="@S.Nav_Admin" Icon="@Icons.Material.Filled.Settings" Expanded="false">
|
||||
<MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">@S.Nav_EnergyTypes</MudNavLink>
|
||||
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">@S.Nav_Tariffs</MudNavLink>
|
||||
<MudNavLink Href="/admin/categories" Icon="@Icons.Material.Filled.Folder">@S.Nav_CostCategories</MudNavLink>
|
||||
<MudNavLink Href="/admin/connectors" Icon="@Icons.Material.Filled.SettingsInputComponent">@S.Nav_Connectors</MudNavLink>
|
||||
<MudNavLink Href="/admin/settings" Icon="@Icons.Material.Filled.Tune">@S.Nav_Settings</MudNavLink>
|
||||
</MudNavGroup>
|
||||
</MudNavMenu>
|
||||
|
||||
|
||||
@@ -7,25 +7,25 @@
|
||||
<div></div>
|
||||
</div>
|
||||
<p class="components-reconnect-first-attempt-visible">
|
||||
Rejoining the server...
|
||||
@S.Reconnect_Rejoining
|
||||
</p>
|
||||
<p class="components-reconnect-repeated-attempt-visible">
|
||||
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
||||
@S.Reconnect_RetryCountdownPrefix <span id="components-seconds-to-next-attempt"></span> @S.Reconnect_RetryCountdownSuffix
|
||||
</p>
|
||||
<p class="components-reconnect-failed-visible">
|
||||
Failed to rejoin.<br />Please retry or reload the page.
|
||||
@S.Reconnect_RejoinFailed<br />@S.Reconnect_RetryOrReload
|
||||
</p>
|
||||
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||
Retry
|
||||
@S.Reconnect_Retry
|
||||
</button>
|
||||
<p class="components-pause-visible">
|
||||
The session has been paused by the server.
|
||||
@S.Reconnect_Paused
|
||||
</p>
|
||||
<p class="components-resume-failed-visible">
|
||||
Failed to resume the session.<br />Please retry or reload the page.
|
||||
@S.Reconnect_ResumeFailed<br />@S.Reconnect_RetryOrReload
|
||||
</p>
|
||||
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
||||
Resume
|
||||
@S.Reconnect_Resume
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Cost categories</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_CostCategories</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Cost categories</MudText>
|
||||
<MudText Typo="Typo.h4">@S.Nav_CostCategories</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add category
|
||||
@S.Categories_AddCategory
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@@ -22,22 +22,22 @@ else
|
||||
{
|
||||
<MudTable Items="_categories" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Sort</MudTh>
|
||||
<MudTh>Members</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
<MudTh>@S.Common_Name</MudTh>
|
||||
<MudTh>@S.Categories_Sort</MudTh>
|
||||
<MudTh>@S.Categories_Members</MudTh>
|
||||
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">
|
||||
<MudTd DataLabel="@S.Common_Name">
|
||||
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
|
||||
{
|
||||
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
|
||||
}
|
||||
@context.Name
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Sort">@context.Sort</MudTd>
|
||||
<MudTd DataLabel="Members">@MemberSummary(context)</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudTd DataLabel="@S.Categories_Sort">@context.Sort</MudTd>
|
||||
<MudTd DataLabel="@S.Categories_Members">@MemberSummary(context)</MudTd>
|
||||
<MudTd DataLabel="@S.Common_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>
|
||||
@@ -47,20 +47,20 @@ else
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New category" : $"Edit {_working.Name}")</MudText>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Categories_NewCategory : Loc.F(S.Categories_EditCategory, _working.Name))</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #ff9800)" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_working.Sort" Label="Sort order" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Name" Label="@S.Common_Name" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.ColorHex" Label="@S.Categories_ColorHex" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_working.Sort" Label="@S.Categories_SortOrder" Class="mb-2" />
|
||||
|
||||
@if (_working.Id != 0)
|
||||
{
|
||||
<MudDivider Class="my-3" />
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Members</MudText>
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">@S.Categories_Members</MudText>
|
||||
@if (_members.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No members yet — add a meter or an energy type.</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Categories_NoMembers</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -77,30 +77,30 @@ else
|
||||
</MudList>
|
||||
}
|
||||
<div class="d-flex align-center mt-2" style="gap:.5rem; flex-wrap:wrap">
|
||||
<MudSelect T="int?" @bind-Value="_addMeterId" Label="Add meter" Dense="true" Style="min-width:180px">
|
||||
<MudSelect T="int?" @bind-Value="_addMeterId" Label="@S.Categories_AddMeter" Dense="true" Style="min-width:180px">
|
||||
@foreach (var meter in _meters)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)meter.Id)">@meter.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudButton Size="Size.Small" OnClick="AddMeterMemberAsync" Disabled="_addMeterId is null">Add</MudButton>
|
||||
<MudSelect T="int?" @bind-Value="_addTypeId" Label="Add energy type" Dense="true" Style="min-width:180px">
|
||||
<MudButton Size="Size.Small" OnClick="AddMeterMemberAsync" Disabled="_addMeterId is null">@S.Categories_Add</MudButton>
|
||||
<MudSelect T="int?" @bind-Value="_addTypeId" Label="@S.Categories_AddEnergyType" Dense="true" Style="min-width:180px">
|
||||
@foreach (var t in _energyTypes)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudButton Size="Size.Small" OnClick="AddTypeMemberAsync" Disabled="_addTypeId is null">Add</MudButton>
|
||||
<MudButton Size="Size.Small" OnClick="AddTypeMemberAsync" Disabled="_addTypeId is null">@S.Categories_Add</MudButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">Save the category first to add members.</MudAlert>
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">@S.Categories_SaveFirst</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Close</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">@S.Categories_Close</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@@ -129,12 +129,12 @@ else
|
||||
{
|
||||
var meters = c.Members.Count(m => m.MeterId is not null);
|
||||
var types = c.Members.Count(m => m.EnergyTypeId is not null);
|
||||
return meters + types == 0 ? "—" : $"{meters} meter(s), {types} type(s)";
|
||||
return meters + types == 0 ? "—" : Loc.F(S.Categories_MemberSummary, meters, types);
|
||||
}
|
||||
|
||||
private string MemberLabel(CostCategoryMember m) =>
|
||||
m.MeterId is { } meterId ? $"Meter: {_meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}"}"
|
||||
: m.EnergyTypeId is { } typeId ? $"Type: {_energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}"}"
|
||||
m.MeterId is { } meterId ? Loc.F(S.Categories_MemberMeter, _meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}")
|
||||
: m.EnergyTypeId is { } typeId ? Loc.F(S.Categories_MemberType, _energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}")
|
||||
: "—";
|
||||
|
||||
private void OpenEdit(CostCategory? category)
|
||||
@@ -158,7 +158,7 @@ else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Name))
|
||||
{
|
||||
Snackbar.Add("Name is required.", Severity.Warning);
|
||||
Snackbar.Add(S.Common_NameRequired, Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ else
|
||||
db.CostCategories.Add(category);
|
||||
await db.SaveChangesAsync();
|
||||
// Re-open on the new category so members can be added.
|
||||
Snackbar.Add("Saved. Add members below.", Severity.Success);
|
||||
Snackbar.Add(S.Categories_SavedAddMembers, Severity.Success);
|
||||
await LoadAsync();
|
||||
OpenEdit(_categories!.First(c => c.Id == category.Id));
|
||||
return;
|
||||
@@ -181,7 +181,7 @@ else
|
||||
existing.Sort = _working.Sort;
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
@@ -235,8 +235,8 @@ else
|
||||
|
||||
private async Task DeleteAsync(CostCategory category)
|
||||
{
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete category",
|
||||
$"Delete '{category.Name}' and its {category.Members.Count} membership(s)? Manual costs in this category are kept but unlinked."))
|
||||
if (!await Confirm.DeleteAsync(DialogService, S.Categories_DeleteTitle,
|
||||
Loc.F(S.Categories_DeleteBody, category.Name, category.Members.Count)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -244,7 +244,7 @@ else
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
// Members cascade with the category; manual_cost.category_id is SetNull.
|
||||
await db.CostCategories.Where(c => c.Id == category.Id).ExecuteDeleteAsync();
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,18 +8,17 @@
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Connectors</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_Connectors</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Connectors</MudText>
|
||||
<MudText Typo="Typo.h4">@S.Nav_Connectors</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add connector
|
||||
@S.Connectors_AddConnector
|
||||
</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.
|
||||
@S.Connectors_SecretsNoticePrefix <b>@S.Connectors_SecretsNoticeEnvVar</b> @S.Connectors_SecretsNoticeSuffix
|
||||
</MudAlert>
|
||||
|
||||
@if (_endpoints is null)
|
||||
@@ -30,20 +29,20 @@ 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>
|
||||
<MudTh>@S.Common_Name</MudTh>
|
||||
<MudTh>@S.Common_Type</MudTh>
|
||||
<MudTh>@S.Common_Enabled</MudTh>
|
||||
<MudTh>@S.Connectors_LastStatus</MudTh>
|
||||
<MudTh>@S.Common_LastSeen</MudTh>
|
||||
<MudTh Style="text-align:right">@S.Common_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">
|
||||
<MudTd DataLabel="@S.Common_Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Type">@context.Type.Display()</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Enabled">@(context.IsEnabled ? S.Connectors_Yes : S.Connectors_No)</MudTd>
|
||||
<MudTd DataLabel="@S.Connectors_LastStatus">@(context.LastStatus ?? "—")</MudTd>
|
||||
<MudTd DataLabel="@S.Common_LastSeen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd>
|
||||
<MudTd DataLabel="@S.Common_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>
|
||||
@@ -51,49 +50,49 @@ else
|
||||
</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>
|
||||
<MudAlert Severity="Severity.Normal" Class="mt-4">@S.Connectors_Empty</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New connector" : $"Edit {_working.Name}")</MudText>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Connectors_NewConnector : Loc.F(S.Connectors_EditTitle, _working.Name))</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudSelect T="EndpointType" @bind-Value="_working.Type" Label="Type" Class="mb-2">
|
||||
<MudSelect T="EndpointType" @bind-Value="_working.Type" Label="@S.Common_Type" Class="mb-2">
|
||||
@foreach (var type in Enum.GetValues<EndpointType>())
|
||||
{
|
||||
<MudSelectItem T="EndpointType" Value="type">@type</MudSelectItem>
|
||||
<MudSelectItem T="EndpointType" Value="type">@type.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Name" Label="@S.Common_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" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseDirectToken" Label="Enter the token here" Color="Color.Primary" Class="mb-1" />
|
||||
<MudTextField @bind-Value="_working.BaseUrl" Label="@S.Connectors_BaseUrl" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseDirectToken" Label="@S.Connectors_EnterTokenHere" Color="Color.Primary" Class="mb-1" />
|
||||
@if (_working.UseDirectToken)
|
||||
{
|
||||
<MudTextField @bind-Value="_working.Token" InputType="InputType.Password" Class="mb-1"
|
||||
Label="@(_working.HasStoredToken ? "Long-lived access token (stored — type to replace)" : "Long-lived access token")" />
|
||||
Label="@(_working.HasStoredToken ? S.Connectors_TokenStored : S.Connectors_Token)" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
Encrypted before it is stored; database dumps and JSON exports carry nothing usable.
|
||||
@S.Connectors_EncryptedHint
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTextField @bind-Value="_working.TokenEnv" Label="Token env-var name (e.g. HA_TOKEN)" Class="mb-1" />
|
||||
<MudTextField @bind-Value="_working.TokenEnv" Label="@S.Connectors_TokenEnv" Class="mb-1" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
The variable's <em>name</em>, not the token. Set it on the server and restart the app.
|
||||
@S.Connectors_TokenEnvHintPrefix <em>@S.Connectors_TokenEnvHintEmphasis</em>@S.Connectors_TokenEnvHintSuffix
|
||||
</MudText>
|
||||
}
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseWebSocket" Label="Real-time WebSocket push" Color="Color.Primary" Class="mb-1" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseWebSocket" Label="@S.Connectors_WebSocketPush" Color="Color.Primary" Class="mb-1" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
On: subscribe to state changes and ingest in real time. Off: REST poll on each source's interval.
|
||||
@S.Connectors_WebSocketHint
|
||||
</MudText>
|
||||
<MudTextField @bind-Value="_working.TestEntityId" Label="Test entity id (optional, e.g. sensor.house_power)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.TestEntityId" Label="@S.Connectors_TestEntityId" Class="mb-2" />
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestHaAsync" Disabled="_testing" Class="mb-2">
|
||||
Test connection
|
||||
@S.Connectors_TestConnection
|
||||
</MudButton>
|
||||
@if (_testing)
|
||||
{
|
||||
@@ -101,36 +100,36 @@ else
|
||||
}
|
||||
@if (_testResult is not null)
|
||||
{
|
||||
<MudAlert Severity="@(_testResult.Ok ? Severity.Success : Severity.Error)" Dense="true" Class="mb-2">@_testResult.Message</MudAlert>
|
||||
<MudAlert Severity="@(_testResult.Ok ? Severity.Success : Severity.Error)" Dense="true" Class="mb-2">@TestText(_testResult)</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" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseDirectCredentials" Label="Enter credentials here" Color="Color.Primary" Class="mb-1" />
|
||||
<MudTextField @bind-Value="_working.Host" Label="@S.Connectors_Host" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_working.Port" Label="@S.Connectors_Port" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.Tls" Label="@S.Connectors_Tls" Color="Color.Primary" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseDirectCredentials" Label="@S.Connectors_EnterCredentialsHere" Color="Color.Primary" Class="mb-1" />
|
||||
@if (_working.UseDirectCredentials)
|
||||
{
|
||||
<MudTextField @bind-Value="_working.Username" Label="Username (optional)" Class="mb-1" />
|
||||
<MudTextField @bind-Value="_working.Username" Label="@S.Connectors_UsernameOptional" Class="mb-1" />
|
||||
<MudTextField @bind-Value="_working.Password" InputType="InputType.Password" Class="mb-1"
|
||||
Label="@(_working.HasStoredPassword ? "Password (stored — type to replace)" : "Password (optional)")" />
|
||||
Label="@(_working.HasStoredPassword ? S.Connectors_PasswordStored : S.Connectors_PasswordOptional)" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
Encrypted before it is stored; database dumps and JSON exports carry nothing usable.
|
||||
@S.Connectors_EncryptedHint
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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.UsernameEnv" Label="@S.Connectors_UsernameEnv" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.PasswordEnv" Label="@S.Connectors_PasswordEnv" Class="mb-2" />
|
||||
}
|
||||
<MudTextField @bind-Value="_working.ExtraTopics" Label="Extra topics (comma-separated, optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.ExtraTopics" Label="@S.Connectors_ExtraTopics" Class="mb-2" />
|
||||
}
|
||||
<MudSwitch T="bool" @bind-Value="_working.IsEnabled" Label="Enabled" Color="Color.Primary" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.IsEnabled" Label="@S.Common_Enabled" Color="Color.Primary" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@@ -210,9 +209,7 @@ else
|
||||
// "Test connection" into an exfiltration primitive — point it at any host and the
|
||||
// token arrives as a Bearer header. A stored secret only ever goes to the origin
|
||||
// it was saved for; testing elsewhere means typing the token again.
|
||||
_testResult = new HaTestResult(false,
|
||||
"Base URL differs from the saved one. Re-enter the token to test against a different host — "
|
||||
+ "a stored token is only sent to the host it was saved for.");
|
||||
_testResult = new HaTestResult(false, S.Connectors_TestBaseUrlChanged);
|
||||
}
|
||||
else if (Secrets.TryUnprotect(_working.TokenEnc, out var stored) && stored is { Length: > 0 })
|
||||
{
|
||||
@@ -220,19 +217,17 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
_testResult = new HaTestResult(false, "Enter a token first.");
|
||||
_testResult = new HaTestResult(false, S.Connectors_TestNoToken);
|
||||
}
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(_working.TokenEnv))
|
||||
{
|
||||
_testResult = new HaTestResult(false,
|
||||
"Name the environment variable holding the token, or switch on \"Enter the token here\".");
|
||||
_testResult = new HaTestResult(false, S.Connectors_TestNoTokenEnv);
|
||||
}
|
||||
else if (Environment.GetEnvironmentVariable(_working.TokenEnv) is not { Length: > 0 } envToken)
|
||||
{
|
||||
_testResult = new HaTestResult(false,
|
||||
$"Environment variable '{_working.TokenEnv}' is not set on the server. Set it and restart the app, "
|
||||
+ "or switch on \"Enter the token here\" to store the token directly.");
|
||||
Loc.F(S.Connectors_TestEnvVarMissing, _working.TokenEnv));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -249,7 +244,7 @@ else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Name))
|
||||
{
|
||||
Snackbar.Add("Name is required.", Severity.Warning);
|
||||
Snackbar.Add(S.Common_NameRequired, Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -258,7 +253,7 @@ else
|
||||
&& string.IsNullOrWhiteSpace(_working.Token)
|
||||
&& !_working.HasStoredToken)
|
||||
{
|
||||
Snackbar.Add("Enter the token, or switch off \"Enter the token here\" and name an env var.", Severity.Warning);
|
||||
Snackbar.Add(S.Connectors_TokenRequired, Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -305,7 +300,7 @@ else
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
@@ -316,15 +311,16 @@ else
|
||||
// Routing is endpoint-scoped, so "unlinked" now means those sources stop ingesting entirely
|
||||
// rather than falling back to any broker. Say so plainly.
|
||||
var note = sourceCount > 0
|
||||
? $" {sourceCount} source(s) use it and will stop ingesting until reassigned to another connector."
|
||||
? " " + Loc.F(S.Connectors_DeleteInUseNote, sourceCount)
|
||||
: "";
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete connector", $"Delete '{endpoint.Name}'?{note}"))
|
||||
if (!await Confirm.DeleteAsync(
|
||||
DialogService, S.Connectors_DeleteTitle, Loc.F(S.Connectors_DeleteBody, endpoint.Name) + note))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await db.IngestionEndpoints.Where(e => e.Id == endpoint.Id).ExecuteDeleteAsync();
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
@@ -398,4 +394,23 @@ else
|
||||
|
||||
public bool HasStoredPassword => !string.IsNullOrWhiteSpace(PasswordEnc);
|
||||
}
|
||||
|
||||
// HaTestResult.Message stays English for the log; the reader gets the verdict in their own
|
||||
// language. Anything the connector page decided for itself (Precondition) already carries its
|
||||
// own localized wording, and HA's own diagnostics — an HTTP status, an exception — are passed
|
||||
// through untranslated because they are not ours to reword.
|
||||
private static string TestText(HaTestResult result) => result.Outcome switch
|
||||
{
|
||||
HaTestOutcome.Connected => S.Connectors_TestConnected,
|
||||
HaTestOutcome.ConnectedWithValue => Loc.F(
|
||||
S.Connectors_TestConnectedValue,
|
||||
result.EntityId ?? string.Empty,
|
||||
result.SampleValue is { } value ? Format.Number(value, 2) : string.Empty),
|
||||
HaTestOutcome.BaseUrlMissing => S.Connectors_TestBaseUrlRequired,
|
||||
HaTestOutcome.TokenMissing => S.Connectors_TestTokenMissing,
|
||||
HaTestOutcome.HttpError => Loc.F(S.Connectors_TestHttpError, result.Detail ?? string.Empty),
|
||||
HaTestOutcome.NoNumericState => Loc.F(S.Connectors_TestNoNumericState, result.EntityId ?? string.Empty),
|
||||
HaTestOutcome.RequestFailed => Loc.F(S.Connectors_TestFailed, result.Detail ?? string.Empty),
|
||||
_ => result.Message,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Energy types</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_EnergyTypes</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Energy types</MudText>
|
||||
<MudText Typo="Typo.h4">@S.Nav_EnergyTypes</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add energy type
|
||||
@S.EnergyTypes_Add
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@@ -22,24 +22,24 @@ else
|
||||
{
|
||||
<MudTable Items="_types" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>Key</MudTh>
|
||||
<MudTh>Display name</MudTh>
|
||||
<MudTh>Base unit</MudTh>
|
||||
<MudTh>Default mode</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
<MudTh>@S.EnergyTypes_Key</MudTh>
|
||||
<MudTh>@S.EnergyTypes_DisplayName</MudTh>
|
||||
<MudTh>@S.EnergyTypes_BaseUnit</MudTh>
|
||||
<MudTh>@S.EnergyTypes_DefaultMode</MudTh>
|
||||
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Key">@context.Key</MudTd>
|
||||
<MudTd DataLabel="Display name">
|
||||
<MudTd DataLabel="@S.EnergyTypes_Key">@context.Key</MudTd>
|
||||
<MudTd DataLabel="@S.EnergyTypes_DisplayName">
|
||||
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
|
||||
{
|
||||
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
|
||||
}
|
||||
@context.DisplayName
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Base unit">@context.BaseUnit</MudTd>
|
||||
<MudTd DataLabel="Default mode">@context.DefaultMode</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudTd DataLabel="@S.EnergyTypes_BaseUnit">@context.BaseUnit</MudTd>
|
||||
<MudTd DataLabel="@S.EnergyTypes_DefaultMode">@context.DefaultMode.Display()</MudTd>
|
||||
<MudTd DataLabel="@S.Common_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>
|
||||
@@ -49,24 +49,24 @@ else
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New energy type" : $"Edit {_working.DisplayName}")</MudText>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.EnergyTypes_NewTitle : Loc.F(S.EnergyTypes_EditTitle, _working.DisplayName))</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField @bind-Value="_working.Key" Label="Key (stable machine key, e.g. electricity)" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.DisplayName" Label="Display name" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.BaseUnit" Label="Base unit (kWh, m3, L, h)" Required="true" Class="mb-2" />
|
||||
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="Default mode" Class="mb-2">
|
||||
<MudTextField @bind-Value="_working.Key" Label="@S.EnergyTypes_KeyLabel" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.DisplayName" Label="@S.EnergyTypes_DisplayName" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.BaseUnit" Label="@S.EnergyTypes_BaseUnitLabel" Required="true" Class="mb-2" />
|
||||
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="@S.EnergyTypes_DefaultMode" Class="mb-2">
|
||||
@foreach (var mode in Enum.GetValues<MeterMode>())
|
||||
{
|
||||
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem>
|
||||
<MudSelectItem T="MeterMode" Value="mode">@mode.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField @bind-Value="_working.Icon" Label="Icon (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #4caf50)" />
|
||||
<MudTextField @bind-Value="_working.Icon" Label="@S.EnergyTypes_IconLabel" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.ColorHex" Label="@S.EnergyTypes_ColorLabel" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@@ -105,14 +105,14 @@ else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Key) || string.IsNullOrWhiteSpace(_working.DisplayName) || string.IsNullOrWhiteSpace(_working.BaseUnit))
|
||||
{
|
||||
Snackbar.Add("Key, display name and base unit are required.", Severity.Warning);
|
||||
Snackbar.Add(S.EnergyTypes_RequiredFields, Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (await db.EnergyTypes.AnyAsync(t => t.Key == _working.Key && t.Id != _working.Id))
|
||||
{
|
||||
Snackbar.Add($"Key '{_working.Key}' is already in use.", Severity.Error);
|
||||
Snackbar.Add(Loc.F(S.EnergyTypes_KeyInUse, _working.Key), Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ else
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
@@ -151,11 +151,11 @@ else
|
||||
var meterCount = await db.Meters.CountAsync(m => m.EnergyTypeId == type.Id);
|
||||
if (meterCount > 0)
|
||||
{
|
||||
Snackbar.Add($"Cannot delete '{type.DisplayName}': {meterCount} meter(s) still use it.", Severity.Error);
|
||||
Snackbar.Add(Loc.F(S.EnergyTypes_DeleteBlocked, type.DisplayName, meterCount), Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete energy type", $"Delete '{type.DisplayName}'? This cannot be undone."))
|
||||
if (!await Confirm.DeleteAsync(DialogService, S.EnergyTypes_DeleteTitle, Loc.F(S.EnergyTypes_DeleteBody, type.DisplayName)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -165,7 +165,7 @@ else
|
||||
{
|
||||
db.EnergyTypes.Remove(target);
|
||||
await db.SaveChangesAsync();
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||
}
|
||||
|
||||
await LoadAsync();
|
||||
|
||||
@@ -2,29 +2,28 @@
|
||||
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Settings</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_Settings</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-2">Settings</MudText>
|
||||
<MudText Typo="Typo.h4" Class="mb-2">@S.Nav_Settings</MudText>
|
||||
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
||||
These are the <b>effective</b> settings the running instance is using. They are configured via environment
|
||||
variables (<code>MeterVault__Key</code> / <code>Section__Key</code>) or Docker/compose, not stored in the
|
||||
database — so config stays reproducible and secrets never land in the DB. Change them in your compose/env and restart.
|
||||
@S.Settings_EffectiveLead <b>@S.Settings_EffectiveEmphasis</b> @S.Settings_EffectiveRest
|
||||
(<code>MeterVault__Key</code> / <code>Section__Key</code>) @S.Settings_EffectiveTail
|
||||
</MudAlert>
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Locale & time</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Settings_LocaleAndTime</MudText>
|
||||
<MudSimpleTable Dense="true">
|
||||
<tbody>
|
||||
<tr><td>Timezone</td><td style="text-align:right"><code>@_o.TimeZone</code></td></tr>
|
||||
<tr><td>Locale</td><td style="text-align:right"><code>@_o.Locale</code></td></tr>
|
||||
<tr><td>Currency</td><td style="text-align:right"><code>@_o.Currency</code></td></tr>
|
||||
<tr><td>Raw-reading retention</td><td style="text-align:right">@_o.RawRetentionDays days</td></tr>
|
||||
<tr><td>@S.Settings_Timezone</td><td style="text-align:right"><code>@_o.TimeZone</code></td></tr>
|
||||
<tr><td>@S.Settings_Locale</td><td style="text-align:right"><code>@_o.Locale</code></td></tr>
|
||||
<tr><td>@S.Common_Currency</td><td style="text-align:right"><code>@_o.Currency</code></td></tr>
|
||||
<tr><td>@S.Settings_RawRetention</td><td style="text-align:right">@Loc.F(S.Settings_RetentionDays, _o.RawRetentionDays)</td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
||||
Env keys: <code>MeterVault__TimeZone</code>, <code>MeterVault__Locale</code>,
|
||||
@S.Settings_EnvKeysLabel <code>MeterVault__TimeZone</code>, <code>MeterVault__Locale</code>,
|
||||
<code>MeterVault__Currency</code>, <code>MeterVault__RawRetentionDays</code>.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
@@ -32,7 +31,7 @@
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Access & ingestion</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Settings_AccessAndIngestion</MudText>
|
||||
<MudSimpleTable Dense="true">
|
||||
<tbody>
|
||||
<tr>
|
||||
@@ -40,26 +39,26 @@
|
||||
<td style="text-align:right">
|
||||
@if (_o.ApiKeys.Count > 0)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">@_o.ApiKeys.Count key(s) configured</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">@Loc.F(S.Settings_ApiKeysConfigured, _o.ApiKeys.Count)</MudChip>
|
||||
}
|
||||
else if (_o.AllowAnonymousApi)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">open (anonymous)</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">@S.Settings_ApiOpen</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">closed (401)</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">@S.Settings_ApiClosed</MudChip>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>Reverse-proxy trust</td><td style="text-align:right">@(_o.ReverseProxyTrust ? "on" : "off")</td></tr>
|
||||
<tr><td>Live ingestion workers</td><td style="text-align:right">@(_o.EnableLiveIngestion ? "on" : "off")</td></tr>
|
||||
<tr><td>Seed reference data on start</td><td style="text-align:right">@(_o.SeedReferenceData ? "on" : "off")</td></tr>
|
||||
<tr><td>@S.Settings_ReverseProxyTrust</td><td style="text-align:right">@(_o.ReverseProxyTrust ? S.Settings_On : S.Settings_Off)</td></tr>
|
||||
<tr><td>@S.Settings_LiveIngestionWorkers</td><td style="text-align:right">@(_o.EnableLiveIngestion ? S.Settings_On : S.Settings_Off)</td></tr>
|
||||
<tr><td>@S.Settings_SeedReferenceData</td><td style="text-align:right">@(_o.SeedReferenceData ? S.Settings_On : S.Settings_Off)</td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
||||
Set API keys with <code>MeterVault__ApiKeys__0</code>. Keys themselves are never shown here.
|
||||
API docs at <MudLink Href="/swagger" Target="_blank">/swagger</MudLink>.
|
||||
@S.Settings_ApiKeysHintBefore <code>MeterVault__ApiKeys__0</code>. @S.Settings_ApiKeysHintAfter
|
||||
@S.Settings_ApiDocsLabel <MudLink Href="/swagger" Target="_blank">/swagger</MudLink>.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Tariffs</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_Tariffs</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Tariffs</MudText>
|
||||
<MudText Typo="Typo.h4">@S.Nav_Tariffs</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add tariff
|
||||
@S.Tariffs_AddTariff
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@@ -22,22 +22,22 @@ else
|
||||
{
|
||||
<MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>Scope</MudTh>
|
||||
<MudTh>Component</MudTh>
|
||||
<MudTh>Value</MudTh>
|
||||
<MudTh>Unit</MudTh>
|
||||
<MudTh>Valid from</MudTh>
|
||||
<MudTh>Valid to</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
<MudTh>@S.Common_Scope</MudTh>
|
||||
<MudTh>@S.Tariffs_Component</MudTh>
|
||||
<MudTh>@S.Common_Value</MudTh>
|
||||
<MudTh>@S.Common_Unit</MudTh>
|
||||
<MudTh>@S.Tariffs_ValidFrom</MudTh>
|
||||
<MudTh>@S.Tariffs_ValidTo</MudTh>
|
||||
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Scope">@ScopeLabel(context)</MudTd>
|
||||
<MudTd DataLabel="Component">@context.Component</MudTd>
|
||||
<MudTd DataLabel="Value">@Format.Number(context.Value, 4)</MudTd>
|
||||
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
||||
<MudTd DataLabel="Valid from">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd>
|
||||
<MudTd DataLabel="Valid to">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudTd DataLabel="@S.Common_Scope">@ScopeLabel(context)</MudTd>
|
||||
<MudTd DataLabel="@S.Tariffs_Component">@context.Component.Display()</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Value">@Format.Number(context.Value, 4)</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Unit">@context.Unit</MudTd>
|
||||
<MudTd DataLabel="@S.Tariffs_ValidFrom">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd>
|
||||
<MudTd DataLabel="@S.Tariffs_ValidTo">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? S.Tariffs_OpenEnded)</MudTd>
|
||||
<MudTd DataLabel="@S.Common_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>
|
||||
@@ -45,24 +45,24 @@ else
|
||||
</MudTable>
|
||||
@if (_tariffs.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">No tariffs yet. Add one, or load the reference data from <MudLink Href="/import">Import</MudLink>.</MudAlert>
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">@S.Tariffs_EmptyState <MudLink Href="/import">@S.Nav_Import</MudLink>.</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New tariff" : "Edit tariff")</MudText>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Tariffs_NewTariff : S.Tariffs_EditTariff)</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudSelect T="TariffScope" @bind-Value="_working.ScopeType" Label="Scope" Class="mb-2">
|
||||
<MudSelect T="TariffScope" @bind-Value="_working.ScopeType" Label="@S.Common_Scope" Class="mb-2">
|
||||
@foreach (var scope in Enum.GetValues<TariffScope>())
|
||||
{
|
||||
<MudSelectItem T="TariffScope" Value="scope">@scope</MudSelectItem>
|
||||
<MudSelectItem T="TariffScope" Value="scope">@scope.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (_working.ScopeType == TariffScope.EnergyType)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Energy type" Class="mb-2">
|
||||
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="@S.Common_EnergyType" Class="mb-2">
|
||||
@foreach (var t in _energyTypes)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
|
||||
@@ -71,29 +71,29 @@ else
|
||||
}
|
||||
else if (_working.ScopeType == TariffScope.Meter)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Meter" Class="mb-2">
|
||||
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="@S.Common_Meter" Class="mb-2">
|
||||
@foreach (var m in _meters)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)m.Id)">@m.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
<MudSelect T="TariffComponent" @bind-Value="_working.Component" Label="Component" Class="mb-2">
|
||||
<MudSelect T="TariffComponent" @bind-Value="_working.Component" Label="@S.Tariffs_Component" Class="mb-2">
|
||||
@foreach (var component in Enum.GetValues<TariffComponent>())
|
||||
{
|
||||
<MudSelectItem T="TariffComponent" Value="component">@component</MudSelectItem>
|
||||
<MudSelectItem T="TariffComponent" Value="component">@component.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudNumericField T="double" @bind-Value="_working.Value" Label="Value" Format="0.####" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Unit" Label="Unit (e.g. EUR/kWh, EUR/m3, EUR/month)" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Currency" Label="Currency" Class="mb-2" />
|
||||
<MudDatePicker @bind-Date="_working.ValidFrom" Label="Valid from" Class="mb-2" />
|
||||
<MudDatePicker @bind-Date="_working.ValidTo" Label="Valid to (empty = open-ended)" Clearable="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Notes" Label="Notes (optional)" />
|
||||
<MudNumericField T="double" @bind-Value="_working.Value" Label="@S.Common_Value" Format="0.####" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Unit" Label="@S.Tariffs_UnitLabel" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Currency" Label="@S.Common_Currency" Class="mb-2" />
|
||||
<MudDatePicker @bind-Date="_working.ValidFrom" Label="@S.Tariffs_ValidFrom" Class="mb-2" />
|
||||
<MudDatePicker @bind-Date="_working.ValidTo" Label="@S.Tariffs_ValidToLabel" Clearable="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Notes" Label="@S.Tariffs_NotesLabel" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@@ -117,9 +117,9 @@ else
|
||||
|
||||
private string ScopeLabel(Tariff t) => t.ScopeType switch
|
||||
{
|
||||
TariffScope.Global => "Global",
|
||||
TariffScope.EnergyType => $"Type: {_energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"}",
|
||||
TariffScope.Meter => $"Meter: {_meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"}",
|
||||
TariffScope.Global => S.Tariffs_ScopeGlobal,
|
||||
TariffScope.EnergyType => Loc.F(S.Tariffs_ScopeType, _energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"),
|
||||
TariffScope.Meter => Loc.F(S.Tariffs_ScopeMeter, _meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"),
|
||||
_ => t.ScopeType.ToString(),
|
||||
};
|
||||
|
||||
@@ -147,13 +147,13 @@ else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Unit) || _working.ValidFrom is null)
|
||||
{
|
||||
Snackbar.Add("Unit and valid-from are required.", Severity.Warning);
|
||||
Snackbar.Add(S.Tariffs_UnitAndValidFromRequired, Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_working.ScopeType != TariffScope.Global && _working.ScopeId is null)
|
||||
{
|
||||
Snackbar.Add("Select the energy type or meter this tariff applies to.", Severity.Warning);
|
||||
Snackbar.Add(S.Tariffs_ScopeTargetRequired, Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -191,13 +191,14 @@ else
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Tariff tariff)
|
||||
{
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete tariff", $"Delete this {tariff.Component} tariff ({Format.Number(tariff.Value, 4)} {tariff.Unit})?"))
|
||||
if (!await Confirm.DeleteAsync(DialogService, S.Tariffs_DeleteTitle,
|
||||
Loc.F(S.Tariffs_DeleteBody, tariff.Component.Display(), Format.Number(tariff.Value, 4), tariff.Unit)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -208,7 +209,7 @@ else
|
||||
{
|
||||
db.Tariffs.Remove(target);
|
||||
await db.SaveChangesAsync();
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||
}
|
||||
|
||||
await LoadAsync();
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
@inject ConsumableService ConsumablesSvc
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Consumables</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Consumables_PageTitle</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Oil / consumables</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
||||
<MudText Typo="Typo.h4">@S.Nav_Consumables</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="@S.Common_Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">@S.Common_RangeLast5Years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">@S.Common_RangeAllTime</MudSelectItem>
|
||||
</MudSelect>
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
else if (_items.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
No consumable meters found. Add a meter with mode <b>ConsumableBalance</b> and a tank, or load the reference
|
||||
data from <MudLink Href="/import">Import</MudLink>.
|
||||
@S.Consumables_NoMetersLead <b>@MeterMode.ConsumableBalance.Display()</b> @S.Consumables_NoMetersTail
|
||||
<MudLink Href="/import">@S.Nav_Import</MudLink>.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
@@ -33,20 +33,20 @@ else
|
||||
<MudText Typo="Typo.h6" Class="mb-3">@item.Name</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Tank level</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_TankLevel</MudText>
|
||||
<MudText Typo="Typo.h5">
|
||||
@(item.CurrentLevel is { } l ? $"{Format.Number(l, 0)} {item.Unit}" : "—")
|
||||
</MudText>
|
||||
<MudProgressLinear Color="@FillColor(item.FillFraction)" Value="@(item.FillFraction * 100)" Class="my-2" Size="Size.Large" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
@Format.Number(item.FillFraction * 100, 0)% of @Format.Number(item.Capacity, 0) @item.Unit
|
||||
@Loc.F(S.Consumables_FillOfCapacity, Format.Number(item.FillFraction * 100, 0), Format.Number(item.Capacity, 0), item.Unit)
|
||||
@if (item.PhysicalLevel is { } cm && item.PhysicalUnit is "cm")
|
||||
{
|
||||
<text> · @Format.Number(cm, 0) cm</text>
|
||||
}
|
||||
@if (item.LevelAsOf is { } asOf)
|
||||
{
|
||||
<text> · as of @asOf.ToString("yyyy-MM-dd")</text>
|
||||
<text> · @Loc.F(S.Consumables_AsOf, asOf.ToString("yyyy-MM-dd"))</text>
|
||||
}
|
||||
</MudText>
|
||||
</MudItem>
|
||||
@@ -54,15 +54,15 @@ else
|
||||
<MudItem xs="12" md="8">
|
||||
<MudGrid>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Used (range)</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_UsedRange</MudText>
|
||||
<MudText Typo="Typo.subtitle1">@Format.Number(item.ConsumptionInRange, 0) @item.Unit</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Burner runtime</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_BurnerRuntime</MudText>
|
||||
<MudText Typo="Typo.subtitle1">@(item.BurnerHours is { } h ? $"{Format.Number(h, 0)} h" : "—")</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Effective rate</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_EffectiveRate</MudText>
|
||||
<MudText Typo="Typo.subtitle1">
|
||||
@if (item.FixedRate is { } fr)
|
||||
{
|
||||
@@ -77,20 +77,20 @@ else
|
||||
<text>—</text>
|
||||
}
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@(item.RateMode)</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@item.RateMode.Display()</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_CostRange</MudText>
|
||||
<MudText Typo="Typo.subtitle1">@Format.Euro(item.CostInRange)</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Forecast to empty</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_ForecastEmpty</MudText>
|
||||
<MudText Typo="Typo.subtitle1">
|
||||
@(item.ForecastEmpty is { } fe ? fe.ToString("yyyy-MM-dd") : "—")
|
||||
@if (item.AveragePerDay is { } apd)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-inline">
|
||||
(@Format.Number(apd, 1) @item.Unit/day)
|
||||
@Loc.F(S.Consumables_PerDay, Format.Number(apd, 1), item.Unit)
|
||||
</MudText>
|
||||
}
|
||||
</MudText>
|
||||
@@ -99,22 +99,22 @@ else
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="7">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Consumption by month</MudText>
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">@S.Consumables_ConsumptionByMonth</MudText>
|
||||
<SeriesChart Series="@ChartFor(item)" Decimals="0" Height="260" />
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="5">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Deliveries (@item.Deliveries.Count)</MudText>
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">@Loc.F(S.Consumables_DeliveriesCount, item.Deliveries.Count)</MudText>
|
||||
@if (item.Deliveries.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No deliveries recorded.</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Consumables_NoDeliveries</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="max-height:260px; overflow-y:auto">
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead>
|
||||
<tr><th>Date</th><th style="text-align:right">Amount</th></tr>
|
||||
<tr><th>@S.Common_Date</th><th style="text-align:right">@S.Common_Amount</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var delivery in item.Deliveries)
|
||||
@@ -171,9 +171,9 @@ else
|
||||
private static IReadOnlyList<SeriesChart.SeriesDef> ChartFor(ConsumableSummary item)
|
||||
{
|
||||
var points = item.Months
|
||||
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Consumption))
|
||||
.Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.Consumption))
|
||||
.ToList();
|
||||
return [new SeriesChart.SeriesDef($"{item.Unit} used", ApexCharts.SeriesType.Bar, points)];
|
||||
return [new SeriesChart.SeriesDef(Loc.F(S.Consumables_UnitUsed, item.Unit), ApexCharts.SeriesType.Bar, points)];
|
||||
}
|
||||
|
||||
private static Color FillColor(double fraction) => fraction switch
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
@page "/"
|
||||
@inject DashboardService Dash
|
||||
|
||||
<PageTitle>MeterVault — Overview</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_Overview</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Overview</MudText>
|
||||
<MudText Typo="Typo.h4" Class="mb-4">@S.Nav_Overview</MudText>
|
||||
|
||||
<UpdateBanner />
|
||||
|
||||
@@ -16,28 +16,28 @@ else
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">This month</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Dashboard_ThisMonth</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Euro(_summary.Month.Current)</MudText>
|
||||
<DeltaChip Kpi="_summary.Month" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">This year</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_ThisYear</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Euro(_summary.Year.Current)</MudText>
|
||||
<DeltaChip Kpi="_summary.Year" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Latest month with data</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Dashboard_LatestMonthWithData</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Euro(_summary.LatestMonthCost)</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">What costs most (this year)</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Dashboard_WhatCostsMost</MudText>
|
||||
@if (_breakdown is { Count: > 0 })
|
||||
{
|
||||
<CategoryDonut Slices="_breakdown" />
|
||||
@@ -55,17 +55,17 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No cost data yet — import a sheet or add tariffs.</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Dashboard_NoCostData</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">What cost more / less (year vs last year)</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Dashboard_WhatChanged</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead>
|
||||
<tr><th>Category</th><th style="text-align:right">Now</th><th style="text-align:right">Prev</th><th style="text-align:right">Δ</th></tr>
|
||||
<tr><th>@S.Dashboard_ColCategory</th><th style="text-align:right">@S.Dashboard_ColCurrent</th><th style="text-align:right">@S.Dashboard_ColPrevious</th><th style="text-align:right">Δ</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in _difference)
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — @(_graph?.EnergyType ?? "Energy")</PageTitle>
|
||||
<PageTitle>MeterVault — @(_graph?.EnergyType ?? S.EnergyView_EnergyFallback)</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">@(_graph?.EnergyType ?? "Energy") flow</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
||||
<MudText Typo="Typo.h4">@Loc.F(S.EnergyView_FlowTitle, _graph?.EnergyType ?? S.EnergyView_EnergyFallback)</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="@S.Common_Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">@S.Common_RangeLast5Years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">@S.Common_RangeAllTime</MudSelectItem>
|
||||
</MudSelect>
|
||||
</div>
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
else if (!_graph.HasData)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
No meters for this energy type yet. Add meters in <MudLink Href="/meters">Meters</MudLink>, or load the
|
||||
reference data from <MudLink Href="/import">Import</MudLink>.
|
||||
@S.EnergyView_NoMetersIntro <MudLink Href="/meters">@S.Nav_Meters</MudLink>@S.EnergyView_NoMetersOr
|
||||
<MudLink Href="/import">@S.Nav_Import</MudLink>.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
@@ -33,48 +33,48 @@ else
|
||||
<MudGrid Class="mb-2">
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Top-level throughput</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.EnergyView_TopLevelThroughput</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Number(_graph.Total, 0) @_graph.Unit</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_CostRange</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Euro(_cost)</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Meters</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_Meters</MudText>
|
||||
<MudText Typo="Typo.h5">@_meters.Count</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-1">Flow</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-1">@S.EnergyView_Flow</MudText>
|
||||
@if (_graph.HasChain)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
|
||||
Where the top-level flow goes. Arrow thickness ∝ amount; "Other" is the unmetered remainder.
|
||||
@S.EnergyView_FlowCaption
|
||||
</MudText>
|
||||
<SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="@_graph.Unit" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
|
||||
No meter chain configured yet. In <MudLink Href="/meters">Meters</MudLink> → edit a sub-meter and set its
|
||||
<b>upstream meter(s)</b> to show where the main meter's flow divides (e.g. main → car, pool, other).
|
||||
@S.EnergyView_NoChainIntro <MudLink Href="/meters">@S.Nav_Meters</MudLink> @S.EnergyView_NoChainMiddle
|
||||
<b>@S.EnergyView_NoChainUpstream</b> @S.EnergyView_NoChainRest
|
||||
</MudAlert>
|
||||
@if (_graph.Nodes.Count > 0)
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Meter</th><th style="text-align:right">Consumption</th></tr></thead>
|
||||
<thead><tr><th>@S.Common_Meter</th><th style="text-align:right">@S.EnergyView_ColConsumption</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value))
|
||||
{
|
||||
<tr>
|
||||
<td>@node.Label</td>
|
||||
<td>@(node.IsOther ? Loc.F(S.Flow_OtherNode, node.Label) : node.Label)</td>
|
||||
<td style="text-align:right">@Format.Number(node.Value, 0) @_graph.Unit</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -85,15 +85,15 @@ else
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Meters</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Common_Meters</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Name</th><th>Mode</th><th>Upstream of</th><th style="text-align:right">Consumption</th></tr></thead>
|
||||
<thead><tr><th>@S.Common_Name</th><th>@S.Common_Mode</th><th>@S.EnergyView_ColUpstreamOf</th><th style="text-align:right">@S.EnergyView_ColConsumption</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var meter in _meters)
|
||||
{
|
||||
<tr>
|
||||
<td><MudLink Href="@($"/meters/{meter.Id}")">@meter.Name</MudLink></td>
|
||||
<td>@meter.Mode</td>
|
||||
<td>@meter.Mode.Display()</td>
|
||||
<td>@UpstreamLabel(meter.Id)</td>
|
||||
<td style="text-align:right">@Format.Number(NodeValue(meter.Id), 0) @_graph.Unit</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
<PageTitle>@S.Error_PageTitle</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
<h1 class="text-danger">@S.Error_Heading</h1>
|
||||
<h2 class="text-danger">@S.Error_Message</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
<strong>@S.Error_RequestId</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<h3>@S.Error_DevelopmentMode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
@* "Development" and ASPNETCORE_ENVIRONMENT are literal environment names — emphasised, never translated. *@
|
||||
@((MarkupString)Loc.F(S.Error_DevelopmentSwap, "<strong>Development</strong>"))
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
<strong>@S.Error_DevelopmentWarning</strong>
|
||||
@S.Error_DevelopmentWarningDetail
|
||||
@((MarkupString)Loc.F(S.Error_DevelopmentEnableHint, "<strong>Development</strong>", "<strong>ASPNETCORE_ENVIRONMENT</strong>"))
|
||||
</p>
|
||||
|
||||
@code{
|
||||
|
||||
@@ -9,21 +9,20 @@
|
||||
@using MeterVault.Infrastructure.Persistence
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
<PageTitle>MeterVault — Import</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_Import</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Import</MudText>
|
||||
<MudText Typo="Typo.h4" Class="mb-4">@S.Nav_Import</MudText>
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6">Reference dataset</MudText>
|
||||
<MudText Typo="Typo.h6">@S.Import_ReferenceDataset</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
|
||||
Load the bundled Energiebilanz sheets (electricity, water, heating oil, costs) as a
|
||||
starter dataset with meters, tariffs and categories.
|
||||
@S.Import_ReferenceDatasetHelp
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.CloudDownload"
|
||||
OnClick="LoadReferenceAsync" Disabled="_loadingReference || _referenceLoaded">
|
||||
@(_referenceLoaded ? "Loaded" : "Load reference data")
|
||||
@(_referenceLoaded ? S.Import_ReferenceLoaded : S.Import_LoadReferenceData)
|
||||
</MudButton>
|
||||
@if (_loadingReference)
|
||||
{
|
||||
@@ -35,22 +34,21 @@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<MudText Typo="Typo.h6">Your own CSV</MudText>
|
||||
<MudText Typo="Typo.h6">@S.Import_YourOwnCsv</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Secondary" Href="/import/wizard"
|
||||
StartIcon="@Icons.Material.Filled.AutoFixHigh">Mapping wizard</MudButton>
|
||||
StartIcon="@Icons.Material.Filled.AutoFixHigh">@S.Import_MappingWizard</MudButton>
|
||||
</div>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3 mt-1">
|
||||
Map an arbitrary sheet's columns to your meters/categories, preview and commit it as a
|
||||
revertible import. Or dry-run against one of the built-in reference profiles below.
|
||||
@S.Import_YourOwnCsvHelp
|
||||
</MudText>
|
||||
<MudSelect T="string" @bind-Value="_profileName" Label="Reference profile" Dense="true" Class="mb-2">
|
||||
<MudSelectItem T="string" Value="@("Strom")">Electricity (Strom)</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Wasser")">Water (Wasser)</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Heizöl")">Heating oil (Heizöl)</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Kosten")">Costs (Kosten)</MudSelectItem>
|
||||
<MudSelect T="string" @bind-Value="_profileName" Label="@S.Import_ReferenceProfile" Dense="true" Class="mb-2">
|
||||
<MudSelectItem T="string" Value="@("Strom")">@S.Import_ProfileElectricity</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Wasser")">@S.Import_ProfileWater</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Heizöl")">@S.Import_ProfileHeatingOil</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Kosten")">@S.Import_ProfileCosts</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudButton HtmlTag="label" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.UploadFile" for="csvUpload">
|
||||
Dry-run a reference sheet
|
||||
@S.Import_DryRunReferenceSheet
|
||||
</MudButton>
|
||||
<InputFile id="csvUpload" OnChange="PreviewAsync" accept=".csv" style="display:none" />
|
||||
</MudPaper>
|
||||
@@ -60,20 +58,20 @@
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Preview</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Common_Preview</MudText>
|
||||
<div class="d-flex" style="gap:2rem; flex-wrap:wrap">
|
||||
<MudText>Readings: <b>@_preview.Readings.Count</b></MudText>
|
||||
<MudText>Events: <b>@_preview.Events.Count</b></MudText>
|
||||
<MudText>Manual costs: <b>@_preview.ManualCosts.Count</b></MudText>
|
||||
<MudText>Skipped rows: <b>@_preview.SkippedRows</b></MudText>
|
||||
<MudText>@S.Common_ReadingsLabel <b>@_preview.Readings.Count</b></MudText>
|
||||
<MudText>@S.Common_EventsLabel <b>@_preview.Events.Count</b></MudText>
|
||||
<MudText>@S.Common_ManualCostsLabel <b>@_preview.ManualCosts.Count</b></MudText>
|
||||
<MudText>@S.Common_SkippedRowsLabel <b>@_preview.SkippedRows</b></MudText>
|
||||
</div>
|
||||
@if (_preview.Warnings.Count > 0)
|
||||
{
|
||||
<MudExpansionPanels Class="mt-3">
|
||||
<MudExpansionPanel Text="@($"{_preview.Warnings.Count} warnings")">
|
||||
<MudExpansionPanel Text="@Loc.F(S.Import_WarningsCount, _preview.Warnings.Count)">
|
||||
@foreach (var warning in _preview.Warnings.Take(50))
|
||||
{
|
||||
<MudText Typo="Typo.body2">@warning</MudText>
|
||||
<MudText Typo="Typo.body2">@warning.Display()</MudText>
|
||||
}
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
@@ -84,15 +82,15 @@
|
||||
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Recent imports</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Import_RecentImports</MudText>
|
||||
@if (_batches.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No imports yet.</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Import_NoImportsYet</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>#</th><th>Source</th><th style="text-align:right">Rows</th><th>Imported</th><th>Status</th><th></th></tr></thead>
|
||||
<thead><tr><th>#</th><th>@S.Import_ColumnSource</th><th style="text-align:right">@S.Import_ColumnRows</th><th>@S.Import_ColumnImported</th><th>@S.Common_Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var batch in _batches)
|
||||
{
|
||||
@@ -104,18 +102,18 @@
|
||||
<td>
|
||||
@if (batch.RevertedAt is not null)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">reverted</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">@S.Import_StatusReverted</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">active</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">@S.Import_StatusActive</MudChip>
|
||||
}
|
||||
</td>
|
||||
<td style="text-align:right">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Error"
|
||||
StartIcon="@Icons.Material.Filled.Undo"
|
||||
Disabled="@(batch.RevertedAt is not null || _reverting == batch.Id)"
|
||||
OnClick="@(() => RevertAsync(batch))">Revert</MudButton>
|
||||
OnClick="@(() => RevertAsync(batch))">@S.Import_Revert</MudButton>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -151,9 +149,9 @@
|
||||
|
||||
private async Task RevertAsync(ImportBatch batch)
|
||||
{
|
||||
if (!await Confirm.ConfirmAsync(Dialogs, "Revert import?",
|
||||
$"Delete all {batch.RowCount} rows from import #{batch.Id} ({batch.SourceName ?? "unnamed"}) and recompute the affected meters?",
|
||||
"Revert"))
|
||||
if (!await Confirm.ConfirmAsync(Dialogs, S.Import_RevertConfirmTitle,
|
||||
Loc.F(S.Import_RevertConfirmBody, batch.RowCount, batch.Id, batch.SourceName ?? S.Import_UnnamedSource),
|
||||
S.Import_Revert))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -162,12 +160,12 @@
|
||||
try
|
||||
{
|
||||
await ImportService.RevertAsync(batch.Id);
|
||||
Snackbar.Add($"Import #{batch.Id} reverted.", Severity.Success);
|
||||
Snackbar.Add(Loc.F(S.Import_BatchReverted, batch.Id), Severity.Success);
|
||||
await LoadBatchesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Revert failed: {ex.Message}", Severity.Error);
|
||||
Snackbar.Add(Loc.F(S.Import_RevertFailed, ex.Message), Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -183,12 +181,12 @@
|
||||
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
|
||||
await ReferenceImporter.LoadAsync(dir);
|
||||
_referenceLoaded = true;
|
||||
Snackbar.Add("Reference data loaded.", Severity.Success);
|
||||
Snackbar.Add(S.Import_ReferenceDataLoaded, Severity.Success);
|
||||
await LoadBatchesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Import failed: {ex.Message}", Severity.Error);
|
||||
Snackbar.Add(Loc.F(S.Import_Failed, ex.Message), Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -10,29 +10,28 @@
|
||||
@using MeterVault.Infrastructure.Persistence
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
<PageTitle>MeterVault — Import wizard</PageTitle>
|
||||
<PageTitle>MeterVault — @S.ImportWizard_Title</PageTitle>
|
||||
|
||||
<div class="d-flex align-center mb-4" style="gap:1rem">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/import" aria-label="Back to Import" />
|
||||
<MudText Typo="Typo.h4">Import wizard</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/import" aria-label="@S.ImportWizard_BackToImport" />
|
||||
<MudText Typo="Typo.h4">@S.ImportWizard_Title</MudText>
|
||||
</div>
|
||||
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4">
|
||||
Upload any CSV, map its columns to your meters and categories, preview what would be staged, then
|
||||
commit it as a revertible import. Values may use the German dialect (decimal comma, unit suffixes,
|
||||
<code>Monat JJJJ</code> or <code>TT.MM.JJJJ</code> dates) — the same parser the reference sheets use.
|
||||
@S.ImportWizard_IntroLead
|
||||
<code>Monat JJJJ</code> @S.ImportWizard_IntroOr <code>TT.MM.JJJJ</code> @S.ImportWizard_IntroTail
|
||||
</MudText>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<div class="d-flex align-center" style="gap:1rem; flex-wrap:wrap">
|
||||
<MudButton HtmlTag="label" Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.UploadFile" for="wizardUpload">
|
||||
Choose CSV
|
||||
@S.ImportWizard_ChooseCsv
|
||||
</MudButton>
|
||||
<InputFile id="wizardUpload" OnChange="OnFileAsync" accept=".csv" style="display:none" />
|
||||
@if (_fileName is not null)
|
||||
{
|
||||
<MudText><b>@_fileName</b> — @_rows.Count rows, @_colCount columns</MudText>
|
||||
<MudText><b>@_fileName</b> — @Loc.F(S.ImportWizard_FileSummary, _rows.Count, _colCount)</MudText>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
@@ -40,10 +39,10 @@
|
||||
@if (_colCount > 0)
|
||||
{
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">1. Parsing options</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step1Title</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudSelect T="int" @bind-Value="_dateColumn" Label="Date column" Dense="true">
|
||||
<MudSelect T="int" @bind-Value="_dateColumn" Label="@S.ImportWizard_DateColumn" Dense="true">
|
||||
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||
{
|
||||
<MudSelectItem T="int" Value="i">@ColLabel(i)</MudSelectItem>
|
||||
@@ -51,27 +50,27 @@
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudSelect T="DateKind" @bind-Value="_dateKind" Label="Date format" Dense="true">
|
||||
<MudSelectItem T="DateKind" Value="DateKind.Auto">Auto-detect</MudSelectItem>
|
||||
<MudSelectItem T="DateKind" Value="DateKind.MonthName">Month name (Januar 2024)</MudSelectItem>
|
||||
<MudSelectItem T="DateKind" Value="DateKind.DayDotMonthYear">Day (31.12.2024)</MudSelectItem>
|
||||
<MudSelect T="DateKind" @bind-Value="_dateKind" Label="@S.ImportWizard_DateFormat" Dense="true">
|
||||
<MudSelectItem T="DateKind" Value="DateKind.Auto">@S.ImportWizard_DateAutoDetect</MudSelectItem>
|
||||
<MudSelectItem T="DateKind" Value="DateKind.MonthName">@S.ImportWizard_DateMonthName</MudSelectItem>
|
||||
<MudSelectItem T="DateKind" Value="DateKind.DayDotMonthYear">@S.ImportWizard_DateDay</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="6" md="2">
|
||||
<MudNumericField T="int" @bind-Value="_headerRow" Label="Header row" Min="0" Margin="Margin.Dense" />
|
||||
<MudNumericField T="int" @bind-Value="_headerRow" Label="@S.ImportWizard_HeaderRow" Min="0" Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="6" md="2">
|
||||
<MudNumericField T="int" @bind-Value="_firstDataRow" Label="First data row" Min="0" Margin="Margin.Dense" />
|
||||
<MudNumericField T="int" @bind-Value="_firstDataRow" Label="@S.ImportWizard_FirstDataRow" Min="0" Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="2" Class="d-flex flex-column">
|
||||
<MudSwitch T="bool" @bind-Value="_skipAllZero" Label="Skip zero rows" Color="Color.Primary" />
|
||||
<MudSwitch T="bool" @bind-Value="_detectSwaps" Label="Detect swaps" Color="Color.Primary" />
|
||||
<MudSwitch T="bool" @bind-Value="_skipAllZero" Label="@S.ImportWizard_SkipZeroRows" Color="Color.Primary" />
|
||||
<MudSwitch T="bool" @bind-Value="_detectSwaps" Label="@S.ImportWizard_DetectSwaps" Color="Color.Primary" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">2. Column preview</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step2Title</MudText>
|
||||
<div style="overflow-x:auto">
|
||||
<MudSimpleTable Dense="true" Bordered="true" Style="min-width:100%">
|
||||
<thead>
|
||||
@@ -79,7 +78,7 @@
|
||||
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||
{
|
||||
<th style="@(i == _dateColumn ? "background:var(--mud-palette-primary-hover)" : "")">
|
||||
Col @i@(i == _dateColumn ? " 📅" : "")
|
||||
@Loc.F(S.ImportWizard_ColumnN, i)@(i == _dateColumn ? " 📅" : "")
|
||||
</th>
|
||||
}
|
||||
</tr>
|
||||
@@ -98,23 +97,23 @@
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Faded rows are before the first data row. The 📅 column supplies the date.
|
||||
@S.ImportWizard_PreviewCaption
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">3. Map columns</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step3Title</MudText>
|
||||
<div style="overflow-x:auto">
|
||||
<MudSimpleTable Dense="true" Style="min-width:100%">
|
||||
<thead>
|
||||
<tr><th>Column</th><th>Sample</th><th style="min-width:160px">Role</th><th style="min-width:220px">Target</th><th style="min-width:120px">Unit</th></tr>
|
||||
<tr><th>@S.ImportWizard_HeaderColumn</th><th>@S.ImportWizard_HeaderSample</th><th style="min-width:160px">@S.ImportWizard_HeaderRole</th><th style="min-width:220px">@S.Common_Target</th><th style="min-width:120px">@S.Common_Unit</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<b>Col @i</b>
|
||||
<b>@Loc.F(S.ImportWizard_ColumnN, i)</b>
|
||||
@if (!string.IsNullOrWhiteSpace(Header(i)))
|
||||
{
|
||||
<br /><span style="font-size:0.72rem; opacity:0.7">@Header(i)</span>
|
||||
@@ -125,7 +124,7 @@
|
||||
<MudSelect T="MappingRole" @bind-Value="_columns[i].Role" Dense="true" Margin="Margin.Dense">
|
||||
@foreach (var role in Enum.GetValues<MappingRole>())
|
||||
{
|
||||
<MudSelectItem T="MappingRole" Value="role">@role</MudSelectItem>
|
||||
<MudSelectItem T="MappingRole" Value="role">@role.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</td>
|
||||
@@ -133,7 +132,7 @@
|
||||
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_columns[i].MeterId" Dense="true" Margin="Margin.Dense"
|
||||
Placeholder="Select meter" Clearable="true">
|
||||
Placeholder="@S.ImportWizard_SelectMeter" Clearable="true">
|
||||
@foreach (var m in _meters)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="m.Id">@m.Name (@m.Unit)</MudSelectItem>
|
||||
@@ -143,7 +142,7 @@
|
||||
else if (_columns[i].Role == MappingRole.ManualCost)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_columns[i].CategoryId" Dense="true" Margin="Margin.Dense"
|
||||
Placeholder="Select category" Clearable="true">
|
||||
Placeholder="@S.ImportWizard_SelectCategory" Clearable="true">
|
||||
@foreach (var c in _categories)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="c.Id">@c.Name</MudSelectItem>
|
||||
@@ -154,7 +153,7 @@
|
||||
<td>
|
||||
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel)
|
||||
{
|
||||
<MudTextField @bind-Value="_columns[i].Unit" Placeholder="e.g. kWh" Margin="Margin.Dense" />
|
||||
<MudTextField @bind-Value="_columns[i].Unit" Placeholder="@S.ImportWizard_UnitPlaceholder" Margin="Margin.Dense" />
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -176,12 +175,12 @@
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<div class="d-flex align-center mb-2" style="gap:1rem; flex-wrap:wrap">
|
||||
<MudText Typo="Typo.h6">4. Preview & commit</MudText>
|
||||
<MudText Typo="Typo.h6">@S.ImportWizard_Step4Title</MudText>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Visibility"
|
||||
OnClick="Preview">Dry-run preview</MudButton>
|
||||
OnClick="Preview">@S.ImportWizard_DryRunButton</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Success" StartIcon="@Icons.Material.Filled.Save"
|
||||
OnClick="CommitAsync" Disabled="_staged is null || _staged.TotalRows == 0 || _committing">
|
||||
Commit import
|
||||
@S.ImportWizard_CommitButton
|
||||
</MudButton>
|
||||
@if (_committing)
|
||||
{
|
||||
@@ -192,24 +191,24 @@
|
||||
@if (_staged is not null)
|
||||
{
|
||||
<div class="d-flex mt-2" style="gap:2rem; flex-wrap:wrap">
|
||||
<MudText>Readings: <b>@_staged.Readings.Count</b></MudText>
|
||||
<MudText>Events: <b>@_staged.Events.Count</b></MudText>
|
||||
<MudText>Manual costs: <b>@_staged.ManualCosts.Count</b></MudText>
|
||||
<MudText>Skipped rows: <b>@_staged.SkippedRows</b></MudText>
|
||||
<MudText>@S.Common_ReadingsLabel <b>@_staged.Readings.Count</b></MudText>
|
||||
<MudText>@S.Common_EventsLabel <b>@_staged.Events.Count</b></MudText>
|
||||
<MudText>@S.Common_ManualCostsLabel <b>@_staged.ManualCosts.Count</b></MudText>
|
||||
<MudText>@S.Common_SkippedRowsLabel <b>@_staged.SkippedRows</b></MudText>
|
||||
</div>
|
||||
@if (_staged.TotalRows == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">
|
||||
Nothing staged. Check the first-data-row, date column and column mappings above.
|
||||
@S.ImportWizard_NothingStaged
|
||||
</MudAlert>
|
||||
}
|
||||
@if (_staged.Warnings.Count > 0)
|
||||
{
|
||||
<MudExpansionPanels Class="mt-3">
|
||||
<MudExpansionPanel Text="@($"{_staged.Warnings.Count} warnings")">
|
||||
<MudExpansionPanel Text="@Loc.F(S.Import_WarningsCount, _staged.Warnings.Count)">
|
||||
@foreach (var warning in _staged.Warnings.Take(100))
|
||||
{
|
||||
<MudText Typo="Typo.body2">@warning</MudText>
|
||||
<MudText Typo="Typo.body2">@warning.Display()</MudText>
|
||||
}
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
@@ -326,7 +325,7 @@
|
||||
{
|
||||
_staged = null;
|
||||
_stagedMapping = null;
|
||||
_validationErrors = ["The mapping changed after the preview. Run the dry run again, then commit."];
|
||||
_validationErrors = [S.ImportWizard_MappingChanged];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -334,12 +333,12 @@
|
||||
try
|
||||
{
|
||||
var batchId = await ImportService.CommitAsync(_staged, _fileName, mappingJson);
|
||||
Snackbar.Add($"Imported batch #{batchId}: {_staged.TotalRows} rows staged. Consumption recomputed.", Severity.Success);
|
||||
Snackbar.Add(Loc.F(S.ImportWizard_CommitSuccess, batchId, _staged.TotalRows), Severity.Success);
|
||||
Nav.NavigateTo("/import");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Commit failed: {ex.Message}", Severity.Error);
|
||||
Snackbar.Add(Loc.F(S.ImportWizard_CommitFailed, ex.Message), Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -352,7 +351,7 @@
|
||||
var errors = new List<string>();
|
||||
if (_columns.Count(c => c.Role != MappingRole.Ignore) == 0)
|
||||
{
|
||||
errors.Add("Map at least one column to a role other than Ignore.");
|
||||
errors.Add(S.ImportWizard_ValidateNoMappedColumn);
|
||||
}
|
||||
|
||||
for (var i = 0; i < _columns.Length; i++)
|
||||
@@ -360,12 +359,12 @@
|
||||
var c = _columns[i];
|
||||
if (c.Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel && c.MeterId is null)
|
||||
{
|
||||
errors.Add($"Col {i} ({c.Role}) needs a target meter.");
|
||||
errors.Add(Loc.F(S.ImportWizard_ValidateNeedsMeter, i, c.Role.Display()));
|
||||
}
|
||||
|
||||
if (c.Role == MappingRole.ManualCost && c.CategoryId is null)
|
||||
{
|
||||
errors.Add($"Col {i} (ManualCost) needs a target category.");
|
||||
errors.Add(Loc.F(S.ImportWizard_ValidateNeedsCategory, i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,9 +378,10 @@
|
||||
|
||||
foreach (var group in duplicateTargets)
|
||||
{
|
||||
var meterName = _meters.FirstOrDefault(m => m.Id == group.Key)?.Name ?? $"meter {group.Key}";
|
||||
var cols = string.Join(", ", group.Select(x => $"Col {x.Index}"));
|
||||
errors.Add($"{cols} all read into '{meterName}'. Each Reading column needs its own meter.");
|
||||
var meterName = _meters.FirstOrDefault(m => m.Id == group.Key)?.Name
|
||||
?? Loc.F(S.ImportWizard_MeterFallback, group.Key);
|
||||
var cols = string.Join(", ", group.Select(x => Loc.F(S.ImportWizard_ColumnN, x.Index)));
|
||||
errors.Add(Loc.F(S.ImportWizard_ValidateDuplicateMeter, cols, meterName));
|
||||
}
|
||||
|
||||
return errors;
|
||||
@@ -431,6 +431,8 @@
|
||||
private string ColLabel(int col)
|
||||
{
|
||||
var header = Header(col);
|
||||
return string.IsNullOrWhiteSpace(header) ? $"Col {col}" : $"Col {col}: {header}";
|
||||
return string.IsNullOrWhiteSpace(header)
|
||||
? Loc.F(S.ImportWizard_ColumnN, col)
|
||||
: Loc.F(S.ImportWizard_ColumnWithHeader, col, header);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Meter</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Common_Meter</PageTitle>
|
||||
|
||||
@if (_detail is null)
|
||||
{
|
||||
@if (_notFound)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning">Meter #@Id not found. <MudLink Href="/meters">Back to meters</MudLink></MudAlert>
|
||||
<MudAlert Severity="Severity.Warning">@Loc.F(S.MeterDetail_NotFound, Id) <MudLink Href="/meters">@S.MeterDetail_BackToMeters</MudLink></MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -43,10 +43,10 @@ else
|
||||
<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>
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@_detail.Mode.Display()</MudChip>
|
||||
@if (!_detail.IsActive)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">retired</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">@S.MeterDetail_Retired</MudChip>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -55,41 +55,41 @@ else
|
||||
<MudGrid Class="mb-2">
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@p.Label this month</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@Loc.F(S.MeterDetail_LabelThisMonth, p.Kind.Display())</MudText>
|
||||
<MudText Typo="Typo.h6">@Format.Number(p.MonthToDate, 0) @p.Unit</MudText>
|
||||
@if (p.MonthIsPartial)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
≈ @Format.Number(p.MonthProjected, 0) @p.Unit by month end
|
||||
@Loc.F(S.MeterDetail_ProjectedByMonthEnd, Format.Number(p.MonthProjected, 0), p.Unit)
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">vs last month</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_VsLastMonth</MudText>
|
||||
<MudText Typo="Typo.h6">@ChangeText(p.MonthChange)</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
last month @Format.Number(p.LastMonth, 0) @p.Unit
|
||||
@Loc.F(S.MeterDetail_LastMonthValue, Format.Number(p.LastMonth, 0), p.Unit)
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">This year</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_ThisYear</MudText>
|
||||
<MudText Typo="Typo.h6">@Format.Number(p.YearToDate, 0) @p.Unit</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
@ChangeText(p.YearChange) vs @Format.Number(p.LastYear, 0) last year
|
||||
@Loc.F(S.MeterDetail_VsLastYear, ChangeText(p.YearChange), Format.Number(p.LastYear, 0))
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost this year</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_CostThisYear</MudText>
|
||||
<MudText Typo="Typo.h6">@Format.Number(p.YearToDateCost, 2) @p.Currency</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
≈ @Format.Number(p.YearProjectedCost, 0) @p.Currency full year
|
||||
@(p.LastYearCost > 0 ? $"· {Format.Number(p.LastYearCost, 0)} last year" : "")
|
||||
@Loc.F(S.MeterDetail_ProjectedFullYear, Format.Number(p.YearProjectedCost, 0), p.Currency)
|
||||
@(p.LastYearCost > 0 ? Loc.F(S.MeterDetail_LastYearCost, Format.Number(p.LastYearCost, 0)) : "")
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
@@ -98,7 +98,7 @@ else
|
||||
@if (p.HasHistory)
|
||||
{
|
||||
<MudPaper Class="pa-3 mb-2" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Last 12 months</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_RangeLast12Months</MudText>
|
||||
<div class="d-flex align-end mt-2" style="gap:.35rem; height:110px">
|
||||
@foreach (var m in p.Last12Months)
|
||||
{
|
||||
@@ -118,31 +118,30 @@ else
|
||||
@if (_periods is null && _detail.Mode == MeterMode.Virtual)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||
Virtual meter — its value is an expression over other meters, evaluated when read, so it has
|
||||
no stored series of its own. See <MudLink Href="/trends">Trends</MudLink> for its figures.
|
||||
@S.MeterDetail_VirtualNotice <MudLink Href="/trends">@S.MeterDetail_VirtualNoticeTrends</MudLink>
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudExpansionPanels Elevation="0" Class="mb-2">
|
||||
<MudExpansionPanel Text="Meter register details">
|
||||
<MudExpansionPanel Text="@S.MeterDetail_RegisterDetails">
|
||||
<div class="d-flex flex-wrap" style="gap:2rem">
|
||||
<div>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Register span</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_RegisterSpan</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
@(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") →
|
||||
@(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—")
|
||||
(baseline @Format.Number(_detail.InitialBaseline, 0))
|
||||
@Loc.F(S.MeterDetail_BaselineValue, Format.Number(_detail.InitialBaseline, 0))
|
||||
</MudText>
|
||||
</div>
|
||||
<div>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Readings</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_Readings</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
@_detail.ReadingCount ·
|
||||
@(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
|
||||
</MudText>
|
||||
</div>
|
||||
<div>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Lifetime total</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_LifetimeTotal</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
@Format.Number(_detail.TotalGeneration != 0 ? _detail.TotalGeneration : _detail.TotalConsumption, 0) @_detail.Unit
|
||||
</MudText>
|
||||
@@ -152,12 +151,11 @@ else
|
||||
</MudExpansionPanels>
|
||||
|
||||
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2">
|
||||
<MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")">
|
||||
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabReadings, _detail.ReadingCount)">
|
||||
@if (_detail.Mode == MeterMode.Virtual)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
A virtual meter is an expression over other meters, so it stores no readings of its own —
|
||||
enter the reading on the meter the expression refers to.
|
||||
@S.MeterDetail_VirtualNoReadings
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
@@ -165,21 +163,21 @@ else
|
||||
<div class="d-flex justify-end mb-2">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add" OnClick="OpenReading">
|
||||
Add reading
|
||||
@S.MeterDetail_AddReading
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
@if (_detail.RecentReadings.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No raw readings.</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoRawReadings</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Most recent @_detail.RecentReadings.Count (raw, immutable audit truth). Times in @_tz.Id.
|
||||
@Loc.F(S.MeterDetail_RecentReadingsCaption, _detail.RecentReadings.Count, _tz.Id)
|
||||
</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>
|
||||
<thead><tr><th>@S.MeterDetail_Time</th><th style="text-align:right">@S.Common_Value</th><th>@S.MeterDetail_Quality</th><th>@S.MeterDetail_Flags</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _detail.RecentReadings)
|
||||
{
|
||||
@@ -187,7 +185,7 @@ else
|
||||
<td>@Local(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>
|
||||
<td>@r.Flags.Display()</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -195,23 +193,23 @@ else
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Consumption ({_detail.ConsumptionCount})")">
|
||||
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabConsumption, _detail.ConsumptionCount)">
|
||||
@if (_detail.RecentConsumption.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No normalized consumption yet.</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoConsumption</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentConsumption.Count normalized deltas.</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@Loc.F(S.MeterDetail_RecentConsumptionCaption, _detail.RecentConsumption.Count)</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>
|
||||
<thead><tr><th>@S.MeterDetail_Time</th><th style="text-align:right">@S.Common_Amount</th><th>@S.MeterDetail_Kind</th><th>@S.MeterDetail_Quality</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _detail.RecentConsumption)
|
||||
{
|
||||
<tr>
|
||||
<td>@Local(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>@c.Kind.Display()</td>
|
||||
<td>@QualityChip(c.Quality)</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -220,21 +218,21 @@ else
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Events ({_detail.Events.Count})")">
|
||||
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabEvents, _detail.Events.Count)">
|
||||
@if (_detail.Events.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No events (swaps, deliveries, corrections).</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoEvents</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>
|
||||
<thead><tr><th>@S.MeterDetail_Time</th><th>@S.Common_Type</th><th style="text-align:right">@S.Common_Amount</th><th style="text-align:right">@S.MeterDetail_PrevNew</th><th>@S.MeterDetail_Notes</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var e in _detail.Events)
|
||||
{
|
||||
<tr>
|
||||
<td>@Local(e.Time).ToString("yyyy-MM-dd")</td>
|
||||
<td>@e.Type</td>
|
||||
<td>@e.Type.Display()</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>
|
||||
@@ -245,25 +243,25 @@ else
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Tariffs ({_detail.Tariffs.Count})")">
|
||||
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabTariffs, _detail.Tariffs.Count)">
|
||||
@if (_detail.Tariffs.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No applicable tariffs.</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoTariffs</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>
|
||||
<thead><tr><th>@S.Common_Scope</th><th>@S.MeterDetail_Component</th><th style="text-align:right">@S.Common_Value</th><th>@S.Common_Unit</th><th>@S.MeterDetail_From</th><th>@S.MeterDetail_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>@t.Scope.Display() @(t.ScopeId is { } id ? $"#{id}" : "")</td>
|
||||
<td>@t.Component.Display()</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>
|
||||
<td>@(t.ValidTo?.ToString("yyyy-MM-dd") ?? S.MeterDetail_TariffOpenEnd)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -271,25 +269,25 @@ else
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Sources ({_sources.Count})")">
|
||||
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabSources, _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
|
||||
@S.MeterDetail_AddSource
|
||||
</MudButton>
|
||||
</div>
|
||||
@if (_sources.Count == 0)
|
||||
{
|
||||
<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>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoSources</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Type</th><th>Target</th><th>Connector</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>
|
||||
<thead><tr><th>@S.Common_Type</th><th>@S.Common_Target</th><th>@S.MeterDetail_Connector</th><th>@S.Common_Enabled</th><th>@S.Common_LastSeen</th><th style="text-align:right">@S.MeterDetail_LastValue</th><th>@S.Common_Status</th><th style="text-align:right">@S.Common_Actions</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var s in _sources)
|
||||
{
|
||||
<tr>
|
||||
<td>@s.SourceType</td>
|
||||
<td>@s.SourceType.Display()</td>
|
||||
<td>@SourceTarget(s)</td>
|
||||
<td>
|
||||
@{ var problem = ConnectorProblem(s); }
|
||||
@@ -305,7 +303,7 @@ else
|
||||
</MudTooltip>
|
||||
}
|
||||
</td>
|
||||
<td>@(s.IsEnabled ? "yes" : "no")</td>
|
||||
<td>@(s.IsEnabled ? S.MeterDetail_Yes : S.MeterDetail_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>
|
||||
@@ -323,13 +321,13 @@ else
|
||||
|
||||
<MudDialog @bind-Visible="_readingOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Add reading — @_detail.Name</MudText>
|
||||
<MudText Typo="Typo.h6">@Loc.F(S.MeterDetail_AddReadingTitle, _detail.Name)</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">@LastReadingCaption()</MudText>
|
||||
|
||||
<MudTextField T="string" Value="_entry.Text" ValueChanged="OnReadingTyped" Immediate="true"
|
||||
Label="@($"Reading ({_detail.Unit})")" Variant="Variant.Outlined"
|
||||
Label="@Loc.F(S.MeterDetail_ReadingLabel, _detail.Unit)" Variant="Variant.Outlined"
|
||||
InputMode="DecimalKeyboard" Class="mv-reading-value" Clearable="true" />
|
||||
|
||||
@* Fixed height, and above the keypad on purpose. The verdict on a value has to be visible
|
||||
@@ -338,12 +336,12 @@ else
|
||||
thumb mid-entry. So the slot is always the same size whether or not it says anything. *@
|
||||
<div class="mv-reading-verdict mt-1 mb-3">
|
||||
<MudText Typo="Typo.caption" Color="@(_entry.Value is null ? Color.Error : Color.Secondary)">
|
||||
@(_entry.Value is { } parsed ? $"= {Format.Number(parsed, 3)} {_detail.Unit}" : "Enter a value")
|
||||
@(_entry.Value is { } parsed ? $"= {Format.Number(parsed, 3)} {_detail.Unit}" : S.MeterDetail_EnterValue)
|
||||
</MudText>
|
||||
@if (ChangeSinceLast is { } change)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="@(WouldBeRejected ? Color.Warning : Color.Secondary)">
|
||||
@ChangeSinceText(change)@(WouldBeRejected ? " — will be rejected" : "")
|
||||
@ChangeSinceText(change)@(WouldBeRejected ? S.MeterDetail_WillBeRejectedSuffix : "")
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
@@ -357,66 +355,64 @@ else
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center flex-wrap" style="gap:.75rem">
|
||||
<MudDatePicker @bind-Date="_readingDate" Label="Date" Variant="Variant.Outlined"
|
||||
<MudDatePicker @bind-Date="_readingDate" Label="@S.Common_Date" Variant="Variant.Outlined"
|
||||
Class="flex-grow-1" Style="min-width:150px" />
|
||||
<MudTimePicker @bind-Time="_readingTime" Label="Time" Variant="Variant.Outlined"
|
||||
<MudTimePicker @bind-Time="_readingTime" Label="@S.MeterDetail_TimeOfDay" Variant="Variant.Outlined"
|
||||
Class="flex-grow-1" Style="min-width:130px" />
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.Schedule" OnClick="SetNow">Now</MudButton>
|
||||
StartIcon="@Icons.Material.Filled.Schedule" OnClick="SetNow">@S.Common_Now</MudButton>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mt-1">Local time in @_tz.Id.</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mt-1">@Loc.F(S.MeterDetail_LocalTimeIn, _tz.Id)</MudText>
|
||||
|
||||
@* Everything below here can reflow freely: the dialog's buttons sit outside this scroll
|
||||
area, so nothing the user is aiming at moves. *@
|
||||
@if (EnteredTimeSkipped)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3">
|
||||
That clock time never happened in @_tz.Id — the clocks moved forward. Pick another time.
|
||||
@Loc.F(S.MeterDetail_SkippedTime, _tz.Id)
|
||||
</MudAlert>
|
||||
}
|
||||
@if (WouldBeRejected)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-3">
|
||||
Below the last reading (@Format.Number(_detail.LastReadingValue ?? 0, 2) @_detail.Unit) on a
|
||||
register that only counts up, so it will be rejected. If the meter was swapped or reset,
|
||||
record that on the Events tab first.
|
||||
@Loc.F(S.MeterDetail_DecreaseWarning, Format.Number(_detail.LastReadingValue ?? 0, 2), _detail.Unit)
|
||||
</MudAlert>
|
||||
}
|
||||
@if (ReplacesRecentReading)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
This meter already has a reading at that time — saving replaces its value.
|
||||
@S.MeterDetail_ReplaceNotice
|
||||
</MudAlert>
|
||||
}
|
||||
@if (IsFuture)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">That time is in the future.</MudAlert>
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">@S.MeterDetail_FutureTime</MudAlert>
|
||||
}
|
||||
else if (IsBackdated)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
Backdated before the latest reading — consumption from there on is recomputed.
|
||||
@S.MeterDetail_BackdatedNotice
|
||||
</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _readingOpen = false)" Disabled="_readingSaving">Cancel</MudButton>
|
||||
<MudButton OnClick="@(() => _readingOpen = false)" Disabled="_readingSaving">@S.Common_Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" Size="Size.Large"
|
||||
OnClick="SaveReadingAsync" Disabled="@(!CanSaveReading)">
|
||||
@(_readingSaving ? "Saving…" : "Save reading")
|
||||
@(_readingSaving ? S.MeterDetail_Saving : S.MeterDetail_SaveReading)
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
<MudDialog @bind-Visible="_sourceOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? "New source" : "Edit source")</MudText>
|
||||
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? S.MeterDetail_NewSource : S.MeterDetail_EditSource)</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudSelect T="SourceType" Value="_sourceEdit.SourceType" ValueChanged="OnSourceTypeChanged" Label="Source type" Class="mb-2">
|
||||
<MudSelect T="SourceType" Value="_sourceEdit.SourceType" ValueChanged="OnSourceTypeChanged" Label="@S.MeterDetail_SourceType" Class="mb-2">
|
||||
@foreach (var type in Enum.GetValues<SourceType>())
|
||||
{
|
||||
<MudSelectItem T="SourceType" Value="type">@type</MudSelectItem>
|
||||
<MudSelectItem T="SourceType" Value="type">@type.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (RequiredEndpointType(_sourceEdit.SourceType) is { } needed)
|
||||
@@ -424,13 +420,13 @@ else
|
||||
if (ConnectorsFor(needed).Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2">
|
||||
No @needed connector yet — <MudLink Href="/admin/connectors">create one</MudLink>
|
||||
(set it up once; every source then just picks it).
|
||||
@Loc.F(S.MeterDetail_NoConnectorYet, needed.Display()) <MudLink Href="/admin/connectors">@S.MeterDetail_CreateConnectorLink</MudLink>
|
||||
@S.MeterDetail_CreateConnectorHint
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_sourceEdit.EndpointId" Label="Connector" Required="true" Class="mb-2">
|
||||
<MudSelect T="int?" @bind-Value="_sourceEdit.EndpointId" Label="@S.MeterDetail_Connector" Required="true" Class="mb-2">
|
||||
@foreach (var e in ConnectorsFor(needed))
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name</MudSelectItem>
|
||||
@@ -440,35 +436,35 @@ else
|
||||
}
|
||||
@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.PollMinutes" Label="Poll interval (minutes)" Class="mb-1" />
|
||||
<MudTextField @bind-Value="_sourceEdit.EntityId" Label="@S.MeterDetail_EntityIdLabel" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.Attribute" Label="@S.MeterDetail_AttributeLabel" Class="mb-2" />
|
||||
<MudNumericField T="int?" @bind-Value="_sourceEdit.PollMinutes" Label="@S.MeterDetail_PollIntervalLabel" Class="mb-1" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
Hourly is plenty for a meter — monthly totals and cost come out identical, with far less raw data.
|
||||
@S.MeterDetail_PollHint
|
||||
</MudText>
|
||||
}
|
||||
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" />
|
||||
<MudTextField @bind-Value="_sourceEdit.Topic" Label="@S.MeterDetail_TopicLabel" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.Path" Label="@S.MeterDetail_ValuePathLabel" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.TimePath" Label="@S.MeterDetail_TimePathLabel" Class="mb-2" />
|
||||
}
|
||||
<MudSelect T="SourceValueKind" @bind-Value="_sourceEdit.ValueKind" Label="Value kind" Class="mb-2">
|
||||
<MudSelect T="SourceValueKind" @bind-Value="_sourceEdit.ValueKind" Label="@S.MeterDetail_ValueKind" Class="mb-2">
|
||||
@foreach (var kind in Enum.GetValues<SourceValueKind>())
|
||||
{
|
||||
<MudSelectItem T="SourceValueKind" Value="kind">@kind</MudSelectItem>
|
||||
<MudSelectItem T="SourceValueKind" Value="kind">@kind.Display()</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" />
|
||||
<MudNumericField T="double" @bind-Value="_sourceEdit.Scale" Label="@S.MeterDetail_Scale" Class="mb-2" />
|
||||
<MudNumericField T="double" @bind-Value="_sourceEdit.Offset" Label="@S.MeterDetail_Offset" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_sourceEdit.Priority" Label="@S.MeterDetail_Priority" Class="mb-2" />
|
||||
</div>
|
||||
<MudSwitch T="bool" @bind-Value="_sourceEdit.IsEnabled" Label="Enabled" Color="Color.Primary" />
|
||||
<MudSwitch T="bool" @bind-Value="_sourceEdit.IsEnabled" Label="@S.Common_Enabled" Color="Color.Primary" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _sourceOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveSourceAsync">Save</MudButton>
|
||||
<MudButton OnClick="@(() => _sourceOpen = false)">@S.Common_Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveSourceAsync">@S.Common_Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
}
|
||||
@@ -547,11 +543,11 @@ else
|
||||
{
|
||||
if (change is not { } c)
|
||||
{
|
||||
return "no basis yet";
|
||||
return S.MeterDetail_NoBasisYet;
|
||||
}
|
||||
|
||||
return Math.Abs(c) < 0.005
|
||||
? "about the same"
|
||||
? S.MeterDetail_AboutTheSame
|
||||
: $"{(c > 0 ? "+" : "−")}{Format.Number(Math.Abs(c) * 100, 0)}%";
|
||||
}
|
||||
|
||||
@@ -613,8 +609,9 @@ else
|
||||
}
|
||||
|
||||
return detail is { LastReadingValue: { } value, LastReadingTime: { } time }
|
||||
? $"Last reading {Format.Number(value, 2)} {detail.Unit} on {Local(time):yyyy-MM-dd HH:mm}."
|
||||
: $"No readings yet — prefilled with this meter's baseline ({Format.Number(detail.InitialBaseline, 2)} {detail.Unit}).";
|
||||
? Loc.F(S.MeterDetail_LastReadingCaption,
|
||||
Format.Number(value, 2), detail.Unit, Local(time).ToString("yyyy-MM-dd HH:mm"))
|
||||
: Loc.F(S.MeterDetail_NoReadingsPrefill, Format.Number(detail.InitialBaseline, 2), detail.Unit);
|
||||
}
|
||||
|
||||
/// <summary>The wall-clock instant the two pickers describe, read in the instance timezone.</summary>
|
||||
@@ -678,8 +675,9 @@ else
|
||||
|
||||
private string ChangeSinceText(double change) =>
|
||||
Math.Abs(change) < 1e-9
|
||||
? "no change since last reading"
|
||||
: $"{(change > 0 ? "+" : "−")}{Format.Number(Math.Abs(change), 2)} {_detail?.Unit} since last reading";
|
||||
? S.MeterDetail_NoChangeSinceLast
|
||||
: Loc.F(S.MeterDetail_ChangeSinceLast,
|
||||
$"{(change > 0 ? "+" : "−")}{Format.Number(Math.Abs(change), 2)}", _detail?.Unit);
|
||||
|
||||
private async Task SaveReadingAsync()
|
||||
{
|
||||
@@ -701,20 +699,18 @@ else
|
||||
switch (outcome)
|
||||
{
|
||||
case IngestionOutcome.Written:
|
||||
Snackbar.Add($"Reading saved: {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success);
|
||||
Snackbar.Add(Loc.F(S.MeterDetail_ReadingSaved, Format.Number(value, 2), _detail.Unit), Severity.Success);
|
||||
break;
|
||||
case IngestionOutcome.Updated:
|
||||
Snackbar.Add($"Replaced the reading at that time with {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success);
|
||||
Snackbar.Add(Loc.F(S.MeterDetail_ReadingReplaced, Format.Number(value, 2), _detail.Unit), Severity.Success);
|
||||
break;
|
||||
case IngestionOutcome.RejectedDecrease:
|
||||
// Leave the dialog open: the typed value is still on screen to correct, and the
|
||||
// alternative fix — recording a reset or swap — is a decision, not a retry.
|
||||
Snackbar.Add(
|
||||
"Rejected — below the previous reading on a register that only counts up. "
|
||||
+ "Record a counter reset or meter swap first.", Severity.Error);
|
||||
Snackbar.Add(S.MeterDetail_ReadingRejected, Severity.Error);
|
||||
return;
|
||||
default:
|
||||
Snackbar.Add("This meter no longer exists.", Severity.Error);
|
||||
Snackbar.Add(S.MeterDetail_MeterGone, Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -783,19 +779,21 @@ else
|
||||
var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId);
|
||||
if (selected is null)
|
||||
{
|
||||
Snackbar.Add($"Pick a {needed} connector for this {_sourceEdit.SourceType} source.", Severity.Error);
|
||||
Snackbar.Add(Loc.F(S.MeterDetail_PickConnector, needed.Display(), _sourceEdit.SourceType.Display()), Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.Type != needed)
|
||||
{
|
||||
Snackbar.Add($"'{selected.Name}' is a {selected.Type} connector; a {_sourceEdit.SourceType} source needs {needed}.", Severity.Error);
|
||||
Snackbar.Add(
|
||||
Loc.F(S.MeterDetail_ConnectorTypeMismatch, selected.Name, selected.Type.Display(), _sourceEdit.SourceType.Display(), needed.Display()),
|
||||
Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selected.IsEnabled)
|
||||
{
|
||||
Snackbar.Add($"'{selected.Name}' is disabled, so this source would never ingest. Enable it first.", Severity.Error);
|
||||
Snackbar.Add(Loc.F(S.MeterDetail_ConnectorDisabled, selected.Name), Severity.Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -847,20 +845,21 @@ else
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_sourceOpen = false;
|
||||
Snackbar.Add("Source saved.", Severity.Success);
|
||||
Snackbar.Add(S.MeterDetail_SourceSaved, Severity.Success);
|
||||
await LoadSourcesAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteSourceAsync(MeterSource source)
|
||||
{
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete source", $"Delete this {source.SourceType} source?"))
|
||||
if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteSourceTitle,
|
||||
Loc.F(S.MeterDetail_DeleteSourceConfirm, source.SourceType.Display())))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
await db.MeterSources.Where(s => s.Id == source.Id).ExecuteDeleteAsync();
|
||||
Snackbar.Add("Source deleted.", Severity.Success);
|
||||
Snackbar.Add(S.MeterDetail_SourceDeleted, Severity.Success);
|
||||
await LoadSourcesAsync();
|
||||
}
|
||||
|
||||
@@ -898,9 +897,9 @@ else
|
||||
var endpoint = _endpoints.FirstOrDefault(e => e.Id == source.EndpointId);
|
||||
return endpoint switch
|
||||
{
|
||||
null => "no connector — never ingests",
|
||||
{ IsEnabled: false } => $"'{endpoint.Name}' is disabled",
|
||||
_ when endpoint.Type != needed => $"'{endpoint.Name}' is {endpoint.Type}, needs {needed}",
|
||||
null => S.MeterDetail_ProblemNoConnector,
|
||||
{ IsEnabled: false } => Loc.F(S.MeterDetail_ProblemDisabled, endpoint.Name),
|
||||
_ when endpoint.Type != needed => Loc.F(S.MeterDetail_ProblemTypeMismatch, endpoint.Name, endpoint.Type.Display(), needed.Display()),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -948,5 +947,5 @@ else
|
||||
}
|
||||
|
||||
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>;
|
||||
Color="@(quality == ReadingQuality.Measured ? Color.Success : quality == ReadingQuality.Estimated ? Color.Warning : Color.Default)">@quality.Display()</MudChip>;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Meters</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_Meters</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Meters</MudText>
|
||||
<MudText Typo="Typo.h4">@S.Common_Meters</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add meter
|
||||
@S.Meters_AddMeter
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@@ -23,29 +23,29 @@ else
|
||||
{
|
||||
<MudTable Items="_meters" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh>Mode</MudTh>
|
||||
<MudTh>Unit</MudTh>
|
||||
<MudTh>Sources</MudTh>
|
||||
<MudTh>Last seen</MudTh>
|
||||
<MudTh>Active</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
<MudTh>@S.Common_Name</MudTh>
|
||||
<MudTh>@S.Common_Type</MudTh>
|
||||
<MudTh>@S.Common_Mode</MudTh>
|
||||
<MudTh>@S.Common_Unit</MudTh>
|
||||
<MudTh>@S.Meters_Sources</MudTh>
|
||||
<MudTh>@S.Common_LastSeen</MudTh>
|
||||
<MudTh>@S.Meters_Active</MudTh>
|
||||
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name"><MudLink Href="@($"/meters/{context.Id}")">@context.Name</MudLink></MudTd>
|
||||
<MudTd DataLabel="Type">@context.EnergyType?.DisplayName</MudTd>
|
||||
<MudTd DataLabel="Mode">@context.Mode</MudTd>
|
||||
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
||||
<MudTd DataLabel="Sources">@context.Sources.Count</MudTd>
|
||||
<MudTd DataLabel="Last seen">
|
||||
<MudTd DataLabel="@S.Common_Name"><MudLink Href="@($"/meters/{context.Id}")">@context.Name</MudLink></MudTd>
|
||||
<MudTd DataLabel="@S.Common_Type">@context.EnergyType?.DisplayName</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Mode">@context.Mode.Display()</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Unit">@context.Unit</MudTd>
|
||||
<MudTd DataLabel="@S.Meters_Sources">@context.Sources.Count</MudTd>
|
||||
<MudTd DataLabel="@S.Common_LastSeen">
|
||||
@{
|
||||
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">
|
||||
<MudTd DataLabel="@S.Meters_Active">@(context.IsActive ? S.Meters_Yes : S.Meters_No)</MudTd>
|
||||
<MudTd DataLabel="@S.Common_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>
|
||||
@@ -55,76 +55,73 @@ else
|
||||
@if (_meters.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||
No meters yet. Add one, or go to <MudLink Href="/import">Import</MudLink> to load the reference data.
|
||||
@S.Meters_EmptyBefore <MudLink Href="/import">@S.Nav_Import</MudLink> @S.Meters_EmptyAfter
|
||||
</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New meter" : $"Edit {_working.Name}")</MudText>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Meters_NewMeter : Loc.F(S.Meters_EditMeter, _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">
|
||||
<MudTextField @bind-Value="_working.Name" Label="@S.Common_Name" Required="true" Class="mb-2" />
|
||||
<MudSelect T="short" @bind-Value="_working.EnergyTypeId" Label="@S.Common_EnergyType" 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">
|
||||
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="@S.Meters_MeasurementMode" Class="mb-2">
|
||||
@foreach (var mode in Enum.GetValues<MeterMode>())
|
||||
{
|
||||
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem>
|
||||
<MudSelectItem T="MeterMode" Value="mode">@mode.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (_working.Mode == MeterMode.Virtual)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||
Virtual "sum" meter — it has no readings of its own. In the flow view it equals the sum of the
|
||||
upstream meters you select below (e.g. Sum Solar = Solar 1 + Solar 2).
|
||||
@S.Meters_VirtualHelp
|
||||
</MudAlert>
|
||||
}
|
||||
else if (_working.Mode == MeterMode.InstantRate)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||
Power/flow sensor — readings are an instantaneous rate, integrated over time into consumption.
|
||||
Store the value as a <b>per-hour</b> rate in this meter's unit (e.g. kW for kWh, L/h for L): a
|
||||
source reporting W or L/min should carry a scale factor to convert it first.
|
||||
@S.Meters_InstantRateHelpBefore <b>@S.Meters_InstantRateHelpPerHour</b> @S.Meters_InstantRateHelpAfter
|
||||
</MudAlert>
|
||||
}
|
||||
<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>
|
||||
<MudTextField @bind-Value="_working.Unit" Label="@S.Common_Unit" Required="true" Class="mb-2" />
|
||||
<MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="@S.Meters_InitialBaseline" Class="mb-2" />
|
||||
<MudSelect T="string" @bind-Value="_working.Role" Label="@S.Meters_PvRole" Class="mb-2">
|
||||
<MudSelectItem T="string" Value="@("")">@S.Meters_RoleNone</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>
|
||||
<MudSelect T="int" MultiSelection="true" @bind-SelectedValues="_working.Upstream"
|
||||
Label="Sub-meter of (upstream meters)" Class="mb-2"
|
||||
Label="@S.Meters_UpstreamLabel" Class="mb-2"
|
||||
MultiSelectionTextFunc="@(ids => UpstreamText(ids))"
|
||||
HelperText="This meter measures a subsection of the selected meter(s)' flow.">
|
||||
HelperText="@S.Meters_UpstreamHelp">
|
||||
@foreach (var m in AvailableUpstream())
|
||||
{
|
||||
<MudSelectItem T="int" Value="m.Id">@m.Name</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" />
|
||||
<MudTextField @bind-Value="_working.Location" Label="@S.Meters_Location" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.SerialNumber" Label="@S.Meters_SerialNumber" 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" />
|
||||
<MudTextField @bind-Value="_working.Manufacturer" Label="@S.Meters_Manufacturer" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Model" Label="@S.Meters_Model" Class="mb-2" />
|
||||
</div>
|
||||
<MudSwitch T="bool" @bind-Value="_working.IsActive" Label="Active" Color="Color.Primary" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.IsActive" Label="@S.Meters_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>
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">@S.Meters_RecomputeNotice</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@@ -228,7 +225,7 @@ else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Name) || string.IsNullOrWhiteSpace(_working.Unit) || _working.EnergyTypeId == 0)
|
||||
{
|
||||
Snackbar.Add("Name, energy type and unit are required.", Severity.Warning);
|
||||
Snackbar.Add(S.Meters_RequiredFields, Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -286,7 +283,7 @@ else
|
||||
await SyncUpstreamAsync(db, meterId, _working.Upstream);
|
||||
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
@@ -314,11 +311,11 @@ else
|
||||
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)."
|
||||
: "";
|
||||
var message = readings + consumption > 0
|
||||
? Loc.F(S.Meters_DeleteConfirmWithData, meter.Name, readings, consumption)
|
||||
: Loc.F(S.Meters_DeleteConfirm, meter.Name);
|
||||
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete meter", $"Delete '{meter.Name}'?{detail} This cannot be undone."))
|
||||
if (!await Confirm.DeleteAsync(DialogService, S.Meters_DeleteTitle, message))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -330,7 +327,7 @@ else
|
||||
await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
|
||||
<h3>Not Found</h3>
|
||||
<p>Sorry, the content you are looking for does not exist.</p>
|
||||
<h3>@S.NotFound_Title</h3>
|
||||
<p>@S.NotFound_Message</p>
|
||||
@@ -2,15 +2,15 @@
|
||||
@inject SolarService SolarSvc
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Solar / PV</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_Solar</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Solar / PV</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
||||
<MudText Typo="Typo.h4">@S.Nav_Solar</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="@S.Common_Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">@S.Common_RangeLast5Years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">@S.Common_RangeAllTime</MudSelectItem>
|
||||
</MudSelect>
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
else if (!_summary.HasGeneration)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
No generation meters found. Add a meter with mode <b>GenerationCounter</b>, or load the reference data from
|
||||
<MudLink Href="/import">Import</MudLink>.
|
||||
@S.Solar_NoGenerationLead <b>@MeterMode.GenerationCounter.Display()</b> @S.Solar_NoGenerationTail
|
||||
<MudLink Href="/import">@S.Nav_Import</MudLink>.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
@@ -30,47 +30,47 @@ else
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Generation</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_Generation</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Number(_summary.Generation, 0) kWh</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Self-consumption</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_SelfConsumption</MudText>
|
||||
<MudText Typo="Typo.h5">@(_summary.SelfConsumption is { } s ? $"{Format.Number(s, 0)} kWh" : "—")</MudText>
|
||||
@if (_summary.SelfConsumptionRatio is { } ratio)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@Format.Number(ratio * 100, 0)% of generation</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@Loc.F(S.Solar_ShareOfGeneration, Format.Number(ratio * 100, 0))</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Autarky</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_Autarky</MudText>
|
||||
<MudText Typo="Typo.h5">@(_summary.Autarky is { } a ? $"{Format.Number(a * 100, 0)} %" : "—")</MudText>
|
||||
@if (_summary.GridImport is { } grid)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Grid draw @Format.Number(grid, 0) kWh</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@Loc.F(S.Solar_GridDraw, Format.Number(grid, 0))</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Savings (Ersparnis)</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_Savings</MudText>
|
||||
<MudText Typo="Typo.h5">@(_summary.Savings is { } sav ? Format.Euro(sav) : "—")</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="8">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Generation & self-consumption</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Solar_GenerationAndSelfConsumption</MudText>
|
||||
<SeriesChart Series="_chart" Decimals="0" Height="340" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="4">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Generation by meter</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Solar_GenerationByMeter</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
@foreach (var meter in _summary.Meters)
|
||||
@@ -85,8 +85,8 @@ else
|
||||
@if (!_summary.HasLoadContext)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
Tag a meter <code>total_load</code> and one <code>grid_import</code> (in meter metadata)
|
||||
to unlock self-consumption, autarky and savings.
|
||||
@S.Solar_TagMetersLead <code>total_load</code> @S.Solar_TagMetersMid <code>grid_import</code>
|
||||
@S.Solar_TagMetersTail
|
||||
</MudAlert>
|
||||
}
|
||||
</MudPaper>
|
||||
@@ -124,18 +124,18 @@ else
|
||||
_summary = await SolarSvc.GetSummaryAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
|
||||
|
||||
var generation = _summary.Months
|
||||
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Generation))
|
||||
.Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.Generation))
|
||||
.ToList();
|
||||
var series = new List<SeriesChart.SeriesDef>
|
||||
{
|
||||
new("Generation", ApexCharts.SeriesType.Bar, generation),
|
||||
new(S.Solar_Generation, ApexCharts.SeriesType.Bar, generation),
|
||||
};
|
||||
if (_summary.HasLoadContext)
|
||||
{
|
||||
var self = _summary.Months
|
||||
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.SelfConsumption ?? 0))
|
||||
.Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.SelfConsumption ?? 0))
|
||||
.ToList();
|
||||
series.Add(new("Self-consumption", ApexCharts.SeriesType.Bar, self));
|
||||
series.Add(new(S.Solar_SelfConsumption, ApexCharts.SeriesType.Bar, self));
|
||||
}
|
||||
|
||||
_chart = series;
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
@page "/trends"
|
||||
@inject DashboardService Dash
|
||||
|
||||
<PageTitle>MeterVault — Trends</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_Trends</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Cost trend</MudText>
|
||||
<MudText Typo="Typo.h4" Class="mb-4">@S.Trends_Title</MudText>
|
||||
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<div class="d-flex align-center mb-3" style="gap:1rem">
|
||||
<MudSelect T="int" @bind-Value="_months" Label="Range" Dense="true" Style="max-width:180px">
|
||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="48">Last 48 months</MudSelectItem>
|
||||
<MudSelect T="int" @bind-Value="_months" Label="@S.Common_Range" Dense="true" Style="max-width:180px">
|
||||
<MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="48">@S.Trends_RangeLast48Months</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync" Disabled="_loading">Apply</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync" Disabled="_loading">@S.Trends_Apply</MudButton>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
@@ -23,7 +23,7 @@
|
||||
{
|
||||
<TrendChart Points="_points" />
|
||||
<MudText Typo="Typo.subtitle2" Class="mt-4">
|
||||
Total over range: @Format.Euro(_points.Sum(p => p.Cost))
|
||||
@Loc.F(S.Trends_TotalOverRange, Format.Euro(_points.Sum(p => p.Cost)))
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<ApexPointSeries TItem="CategorySlice"
|
||||
Items="Slices"
|
||||
SeriesType="SeriesType.Donut"
|
||||
Name="Cost"
|
||||
Name="@S.CategoryDonut_Cost"
|
||||
XValue="s => s.Name"
|
||||
YValue="s => (decimal)Math.Round(s.Cost, 2)" />
|
||||
</ApexChart>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
@if (string.IsNullOrEmpty(_svg))
|
||||
{
|
||||
<MudBlazor.MudText Typo="MudBlazor.Typo.body2" Color="MudBlazor.Color.Secondary">No flow to show for this period.</MudBlazor.MudText>
|
||||
<MudBlazor.MudText Typo="MudBlazor.Typo.body2" Color="MudBlazor.Color.Secondary">@S.Sankey_NoFlow</MudBlazor.MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -102,11 +102,11 @@ else
|
||||
}
|
||||
|
||||
var color = Nodes.ToDictionary(n => n.Id, n => n.ColorHex ?? "#607D8B");
|
||||
var label = Nodes.ToDictionary(n => n.Id, n => n.Label);
|
||||
var label = Nodes.ToDictionary(n => n.Id, NodeLabel);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(CultureInfo.InvariantCulture,
|
||||
$"<svg viewBox=\"0 0 {F(W)} {F(height)}\" width=\"100%\" style=\"height:{F(height)}px;min-width:520px;color:var(--mud-palette-text-primary)\" role=\"img\" aria-label=\"Flow diagram\">");
|
||||
$"<svg viewBox=\"0 0 {F(W)} {F(height)}\" width=\"100%\" style=\"height:{F(height)}px;min-width:520px;color:var(--mud-palette-text-primary)\" role=\"img\" aria-label=\"{Enc(S.Sankey_AriaLabel)}\">");
|
||||
|
||||
// Ribbons first (under nodes).
|
||||
foreach (var link in Links)
|
||||
@@ -142,7 +142,7 @@ else
|
||||
var labelX = rightmost ? g.X - 6 : g.X + NodeWidth + 6;
|
||||
var anchor = rightmost ? "end" : "start";
|
||||
var fill = Enc(node.ColorHex ?? "#607D8B");
|
||||
var name = Enc(node.Label);
|
||||
var name = Enc(NodeLabel(node));
|
||||
var val = Enc(Fmt(node.Value));
|
||||
sb.Append(CultureInfo.InvariantCulture,
|
||||
$"<rect x=\"{F(g.X)}\" y=\"{F(g.Y)}\" width=\"{F(NodeWidth)}\" height=\"{F(g.H)}\" rx=\"2\" fill=\"{fill}\"><title>{name}: {val}</title></rect>");
|
||||
@@ -155,7 +155,14 @@ else
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string Fmt(double value) => $"{value.ToString("N0", CultureInfo.GetCultureInfo("de-DE"))} {Unit}".Trim();
|
||||
/// <summary>
|
||||
/// A remainder node carries only its parent meter's name (see <see cref="FlowNode"/>); the
|
||||
/// "Other (…)" framing is added here, where the reader's language is known.
|
||||
/// </summary>
|
||||
private static string NodeLabel(FlowNode node) =>
|
||||
node.IsOther ? Loc.F(S.Flow_OtherNode, node.Label) : node.Label;
|
||||
|
||||
private string Fmt(double value) => $"{Format.Number(value)} {Unit}".Trim();
|
||||
|
||||
private static string F(double value) => value.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">No data in this range.</MudText>
|
||||
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">@S.Common_NoDataInRange</MudText>
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
<ApexPointSeries TItem="TrendPoint"
|
||||
Items="Points"
|
||||
SeriesType="SeriesType.Bar"
|
||||
Name="Monthly cost"
|
||||
Name="@S.TrendChart_MonthlyCost"
|
||||
XValue="Label"
|
||||
YValue="p => (decimal)Math.Round(p.Cost, 2)" />
|
||||
</ApexChart>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">No data in this range.</MudText>
|
||||
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">@S.Common_NoDataInRange</MudText>
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -27,5 +27,6 @@ else
|
||||
DataLabels = new DataLabels { Enabled = false },
|
||||
};
|
||||
|
||||
private static object Label(TrendPoint p) => p.Period.ToString("MMM yy", CultureInfo.InvariantCulture);
|
||||
// Qualified: ApexCharts also exports a `Format`, and this file imports it.
|
||||
private static object Label(TrendPoint p) => MeterVault.App.Format.MonthLabel(p.Period);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@using System.Net
|
||||
@using MeterVault.Infrastructure.Update
|
||||
@inject UpdateCheckService Updates
|
||||
@inject UpdateRunner Runner
|
||||
@@ -12,12 +13,12 @@
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-4" Icon="@Icons.Material.Filled.SystemUpdateAlt">
|
||||
<div class="d-flex flex-wrap align-center" style="gap:.75rem">
|
||||
<span>MeterVault <b>@latest</b> is available — this instance runs <b>@running</b>.</span>
|
||||
<span>@((MarkupString)Loc.F(S.UpdateBanner_ReleaseAvailable, Bold(latest), Bold(running)))</span>
|
||||
@if (Runner.Availability is UpdateAvailability.Allowed)
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.SystemUpdateAlt" OnClick="@(() => _confirmOpen = true)">
|
||||
Update now
|
||||
@S.UpdateBanner_UpdateNow
|
||||
</MudButton>
|
||||
}
|
||||
else
|
||||
@@ -29,17 +30,16 @@
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_confirmOpen" Options="_dialogOptions">
|
||||
<TitleContent><MudText Typo="Typo.h6">Update MeterVault</MudText></TitleContent>
|
||||
<TitleContent><MudText Typo="Typo.h6">@S.UpdateBanner_DialogTitle</MudText></TitleContent>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.body2" Class="mb-3">
|
||||
This pulls the latest source, rebuilds it, and restarts the service. It takes a few minutes,
|
||||
during which MeterVault is unavailable. Readings are not affected — ingestion resumes on restart.
|
||||
@S.UpdateBanner_DialogBody
|
||||
</MudText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _confirmOpen = false)" Disabled="_starting">Cancel</MudButton>
|
||||
<MudButton OnClick="@(() => _confirmOpen = false)" Disabled="_starting">@S.Common_Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="StartUpdateAsync" Disabled="_starting">
|
||||
@(_starting ? "Starting…" : "Update now")
|
||||
@(_starting ? S.UpdateBanner_Starting : S.UpdateBanner_UpdateNow)
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
@@ -80,7 +80,7 @@
|
||||
try
|
||||
{
|
||||
var launch = await Runner.LaunchAsync();
|
||||
Snackbar.Add(launch.Message, launch.Started ? Severity.Success : Severity.Error);
|
||||
Snackbar.Add(OutcomeText(launch), launch.Started ? Severity.Success : Severity.Error);
|
||||
if (launch.Started)
|
||||
{
|
||||
_confirmOpen = false;
|
||||
@@ -92,6 +92,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateLaunch.Message stays English for the log; the reader gets the outcome in their own
|
||||
// language, with the launcher's own diagnostic text appended untranslated — it comes from
|
||||
// systemd, not from us, and mangling it would make it unsearchable.
|
||||
private static string OutcomeText(UpdateLaunch launch) => launch.Outcome switch
|
||||
{
|
||||
UpdateOutcome.Started => S.Update_LaunchStarted,
|
||||
UpdateOutcome.NotAllowed => Loc.F(
|
||||
S.Update_LaunchNotAllowed,
|
||||
launch.Availability?.Display() ?? string.Empty),
|
||||
UpdateOutcome.LauncherMissing => S.Update_LaunchLauncherMissing,
|
||||
UpdateOutcome.LauncherFailed => launch.Detail is { Length: > 0 } detail
|
||||
? Loc.F(S.Update_LaunchFailedWithDetail, detail)
|
||||
: S.Update_LaunchFailed,
|
||||
_ => launch.Detail is { Length: > 0 } error
|
||||
? Loc.F(S.Update_LaunchFailedWithDetail, error)
|
||||
: S.Update_LaunchFailed,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// How this particular install updates. The LXC has an <c>update</c> command; a container is
|
||||
/// replaced by pulling a new image, and telling those users to run <c>update</c> would send them
|
||||
@@ -99,6 +117,9 @@
|
||||
/// </summary>
|
||||
private static string UpdateCommandHint() =>
|
||||
UpdateRunner.IsSupportedHere
|
||||
? "run: update"
|
||||
: "pull the new image and recreate the container";
|
||||
? S.UpdateBanner_HintRunUpdate
|
||||
: S.UpdateBanner_HintPullImage;
|
||||
|
||||
/// <summary>Emphasises a version inside the banner sentence without letting it carry markup.</summary>
|
||||
private static string Bold(object value) => $"<b>{WebUtility.HtmlEncode(value.ToString())}</b>";
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
@using Microsoft.JSInterop
|
||||
@using MudBlazor
|
||||
@using MeterVault.App
|
||||
@using MeterVault.App.Localization
|
||||
@* UI strings live in Localization/Strings.resx and are reached through this alias: @S.Common_Save.
|
||||
Compiled properties, so a stale key fails the build. @Loc.F(...) formats the {0} ones. *@
|
||||
@using S = MeterVault.App.Localization.Strings
|
||||
@using MeterVault.App.Components
|
||||
@using MeterVault.App.Components.Layout
|
||||
@using MeterVault.App.Components.Shared
|
||||
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
using MeterVault.App.Localization;
|
||||
using MudBlazor;
|
||||
|
||||
namespace MeterVault.App;
|
||||
@@ -7,7 +8,7 @@ public static class Confirm
|
||||
{
|
||||
public static async Task<bool> DeleteAsync(IDialogService dialog, string title, string message)
|
||||
{
|
||||
var result = await dialog.ShowMessageBoxAsync(title, message, yesText: "Delete", cancelText: "Cancel")
|
||||
var result = await dialog.ShowMessageBoxAsync(title, message, yesText: Strings.Common_Delete, cancelText: Strings.Common_Cancel)
|
||||
.ConfigureAwait(false);
|
||||
return result == true;
|
||||
}
|
||||
@@ -15,7 +16,7 @@ public static class Confirm
|
||||
/// <summary>A generic yes/cancel confirmation with a caller-supplied confirm-button label.</summary>
|
||||
public static async Task<bool> ConfirmAsync(IDialogService dialog, string title, string message, string confirmText)
|
||||
{
|
||||
var result = await dialog.ShowMessageBoxAsync(title, message, yesText: confirmText, cancelText: "Cancel")
|
||||
var result = await dialog.ShowMessageBoxAsync(title, message, yesText: confirmText, cancelText: Strings.Common_Cancel)
|
||||
.ConfigureAwait(false);
|
||||
return result == true;
|
||||
}
|
||||
|
||||
+23
-6
@@ -2,18 +2,35 @@ using System.Globalization;
|
||||
|
||||
namespace MeterVault.App;
|
||||
|
||||
/// <summary>Small display formatters for the UI (locale-aware formatting arrives with i18n in M7).</summary>
|
||||
/// <summary>
|
||||
/// Small display formatters for the UI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything formats against <see cref="CultureInfo.CurrentCulture"/>, which the request
|
||||
/// localization middleware sets from the reader's chosen UI language — so 1234.5 renders as
|
||||
/// "1.234,5" for a German reader and "1,234.5" for an English one, from the same call site. This is
|
||||
/// display only: the CSV importer still parses the spreadsheet dialect with an explicit de-DE
|
||||
/// culture (<see cref="Core.Parsing.GermanNumber"/>), because that dialect is a property of the
|
||||
/// files, not of who is looking at them.
|
||||
/// </remarks>
|
||||
public static class Format
|
||||
{
|
||||
private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("de-DE");
|
||||
|
||||
public static string Euro(double value) => value.ToString("N2", Culture) + " €";
|
||||
/// <summary>
|
||||
/// A money amount. The symbol is the euro rather than the culture's own, because the figure is
|
||||
/// in the instance's configured currency — switching UI language must not restate the amount as
|
||||
/// dollars. Only the digit grouping follows the reader.
|
||||
/// </summary>
|
||||
public static string Euro(double value) => value.ToString("N2", CultureInfo.CurrentCulture) + " €";
|
||||
|
||||
public static string Number(double value, int decimals = 0) =>
|
||||
value.ToString("N" + decimals.ToString(CultureInfo.InvariantCulture), Culture);
|
||||
value.ToString("N" + decimals.ToString(CultureInfo.InvariantCulture), CultureInfo.CurrentCulture);
|
||||
|
||||
public static string Percent(double value) =>
|
||||
(value >= 0 ? "+" : "") + value.ToString("N1", Culture) + " %";
|
||||
(value >= 0 ? "+" : "") + value.ToString("N1", CultureInfo.CurrentCulture) + " %";
|
||||
|
||||
/// <summary>A month label for chart axes and period lists ("Mrz 25" / "Mar 25").</summary>
|
||||
public static string MonthLabel(DateOnly month) =>
|
||||
month.ToString("MMM yy", CultureInfo.CurrentCulture);
|
||||
|
||||
public static string DirectionIcon(int direction) => direction switch
|
||||
{
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.AspNetCore.Localization;
|
||||
|
||||
namespace MeterVault.App.Localization;
|
||||
|
||||
/// <summary>The endpoint behind the language picker in the app bar.</summary>
|
||||
public static class CultureEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Persists a UI language and returns the user to where they were.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A redirect rather than an interactive state change on purpose: a Blazor Server circuit
|
||||
/// captures <see cref="System.Globalization.CultureInfo.CurrentUICulture"/> from the request
|
||||
/// that opened it, so switching language has to re-establish the circuit. The picker therefore
|
||||
/// navigates here with <c>forceLoad</c>, this writes the culture cookie the localization
|
||||
/// middleware reads, and the reload comes back in the new language.
|
||||
/// </remarks>
|
||||
public static IEndpointRouteBuilder MapCultureEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
|
||||
endpoints.MapGet("/culture/set", (HttpContext http, string? culture, string? redirectUri) =>
|
||||
{
|
||||
// Only ever store a language we ship, so a hand-edited link can't park an unusable
|
||||
// culture in the cookie and leave the UI stuck in fallback.
|
||||
if (!Loc.TryResolve(culture, out var resolved))
|
||||
{
|
||||
return Results.BadRequest($"Unsupported culture '{culture}'.");
|
||||
}
|
||||
|
||||
http.Response.Cookies.Append(
|
||||
CookieRequestCultureProvider.DefaultCookieName,
|
||||
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(resolved)),
|
||||
new CookieOptions
|
||||
{
|
||||
Path = "/",
|
||||
Expires = DateTimeOffset.UtcNow.AddYears(1),
|
||||
SameSite = SameSiteMode.Lax,
|
||||
// Read only by the localization middleware, never by script.
|
||||
HttpOnly = true,
|
||||
// A language preference is exempt from consent gating, and the app is
|
||||
// self-hosted and single-user anyway.
|
||||
IsEssential = true,
|
||||
});
|
||||
|
||||
// Anything but a local path is refused rather than followed: this endpoint takes a
|
||||
// redirect target from the query string, which is exactly the shape of an open redirect.
|
||||
return Results.LocalRedirect(IsLocalPath(redirectUri) ? redirectUri! : "/");
|
||||
})
|
||||
.ExcludeFromDescription();
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The framework's own local-URL rule: rooted, and not the "//host" or "/\host" forms browsers
|
||||
/// resolve as protocol-relative absolute URLs.
|
||||
/// </summary>
|
||||
private static bool IsLocalPath(string? url) =>
|
||||
!string.IsNullOrEmpty(url)
|
||||
&& url[0] == '/'
|
||||
&& (url.Length == 1 || (url[1] != '/' && url[1] != '\\'));
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Import;
|
||||
using MeterVault.Infrastructure.Update;
|
||||
|
||||
namespace MeterVault.App.Localization;
|
||||
|
||||
/// <summary>
|
||||
/// Human wording for the domain enums the UI puts on screen.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The enums themselves stay bare identifiers: they are persisted as text (SDD §5.2) and appear in
|
||||
/// the REST API, so their names are part of the data contract and must not move with the reader's
|
||||
/// language. This is the one place that decides how each value is <em>spoken</em>, which keeps the
|
||||
/// domain layer free of presentation and gives every page the same word for the same concept —
|
||||
/// a <c>TariffComponent.UnitPrice</c> is "Arbeitspreis" in the table, the dropdown and the
|
||||
/// confirm dialog alike.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every arm resolves a <c>Enum_<Type>_<Value></c> resource. The fallback arms return the
|
||||
/// identifier rather than throwing, so adding an enum value can never crash a dashboard — and
|
||||
/// <c>EnumDisplayNameTests</c> fails the build if one is ever left without a translation, which is
|
||||
/// what stops that safety net from quietly becoming the shipping behaviour.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DisplayNames
|
||||
{
|
||||
/// <summary>The enums this class is responsible for; the resource-coverage test walks this list.</summary>
|
||||
public static IReadOnlyList<Type> LocalizedEnums { get; } =
|
||||
[
|
||||
typeof(MeterMode),
|
||||
typeof(ConsumptionKind),
|
||||
typeof(ReadingQuality),
|
||||
typeof(ReadingFlags),
|
||||
typeof(MeterEventType),
|
||||
typeof(SourceType),
|
||||
typeof(SourceValueKind),
|
||||
typeof(TariffScope),
|
||||
typeof(TariffComponent),
|
||||
typeof(TankRateMode),
|
||||
typeof(EndpointType),
|
||||
typeof(MappingRole),
|
||||
typeof(UpdateAvailability),
|
||||
];
|
||||
|
||||
public static string Display(this MeterMode value) => value switch
|
||||
{
|
||||
MeterMode.CumulativeCounter => Strings.Enum_MeterMode_CumulativeCounter,
|
||||
MeterMode.GenerationCounter => Strings.Enum_MeterMode_GenerationCounter,
|
||||
MeterMode.RuntimeCounter => Strings.Enum_MeterMode_RuntimeCounter,
|
||||
MeterMode.ConsumableBalance => Strings.Enum_MeterMode_ConsumableBalance,
|
||||
MeterMode.DirectDelta => Strings.Enum_MeterMode_DirectDelta,
|
||||
MeterMode.InstantRate => Strings.Enum_MeterMode_InstantRate,
|
||||
MeterMode.Virtual => Strings.Enum_MeterMode_Virtual,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this ConsumptionKind value) => value switch
|
||||
{
|
||||
ConsumptionKind.Consumption => Strings.Enum_ConsumptionKind_Consumption,
|
||||
ConsumptionKind.Generation => Strings.Enum_ConsumptionKind_Generation,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this ReadingQuality value) => value switch
|
||||
{
|
||||
ReadingQuality.Measured => Strings.Enum_ReadingQuality_Measured,
|
||||
ReadingQuality.Estimated => Strings.Enum_ReadingQuality_Estimated,
|
||||
ReadingQuality.Manual => Strings.Enum_ReadingQuality_Manual,
|
||||
ReadingQuality.Imported => Strings.Enum_ReadingQuality_Imported,
|
||||
ReadingQuality.Interpolated => Strings.Enum_ReadingQuality_Interpolated,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// A bitmask rendered as the set flags, comma-joined. <see cref="ReadingFlags.None"/> gives an
|
||||
/// empty string: the readings table shows a flag column that is blank for almost every row, and
|
||||
/// printing "None" a thousand times down a page is noise, not information.
|
||||
/// </summary>
|
||||
public static string Display(this ReadingFlags value)
|
||||
{
|
||||
if (value == ReadingFlags.None)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var names = new List<string>(3);
|
||||
if (value.HasFlag(ReadingFlags.CounterReset))
|
||||
{
|
||||
names.Add(Strings.Enum_ReadingFlags_CounterReset);
|
||||
}
|
||||
|
||||
if (value.HasFlag(ReadingFlags.MeterSwap))
|
||||
{
|
||||
names.Add(Strings.Enum_ReadingFlags_MeterSwap);
|
||||
}
|
||||
|
||||
if (value.HasFlag(ReadingFlags.Anomaly))
|
||||
{
|
||||
names.Add(Strings.Enum_ReadingFlags_Anomaly);
|
||||
}
|
||||
|
||||
return names.Count > 0 ? string.Join(", ", names) : value.ToString();
|
||||
}
|
||||
|
||||
public static string Display(this MeterEventType value) => value switch
|
||||
{
|
||||
MeterEventType.MeterSwap => Strings.Enum_MeterEventType_MeterSwap,
|
||||
MeterEventType.CounterReset => Strings.Enum_MeterEventType_CounterReset,
|
||||
MeterEventType.Delivery => Strings.Enum_MeterEventType_Delivery,
|
||||
MeterEventType.TankLevel => Strings.Enum_MeterEventType_TankLevel,
|
||||
MeterEventType.Correction => Strings.Enum_MeterEventType_Correction,
|
||||
MeterEventType.Note => Strings.Enum_MeterEventType_Note,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this SourceType value) => value switch
|
||||
{
|
||||
SourceType.Mqtt => Strings.Enum_SourceType_Mqtt,
|
||||
SourceType.Tasmota => Strings.Enum_SourceType_Tasmota,
|
||||
SourceType.HomeAssistant => Strings.Enum_SourceType_HomeAssistant,
|
||||
SourceType.Manual => Strings.Enum_SourceType_Manual,
|
||||
SourceType.Import => Strings.Enum_SourceType_Import,
|
||||
SourceType.Virtual => Strings.Enum_SourceType_Virtual,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this SourceValueKind value) => value switch
|
||||
{
|
||||
SourceValueKind.Register => Strings.Enum_SourceValueKind_Register,
|
||||
SourceValueKind.Delta => Strings.Enum_SourceValueKind_Delta,
|
||||
SourceValueKind.Rate => Strings.Enum_SourceValueKind_Rate,
|
||||
SourceValueKind.Level => Strings.Enum_SourceValueKind_Level,
|
||||
SourceValueKind.Runtime => Strings.Enum_SourceValueKind_Runtime,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this TariffScope value) => value switch
|
||||
{
|
||||
TariffScope.Global => Strings.Enum_TariffScope_Global,
|
||||
TariffScope.EnergyType => Strings.Enum_TariffScope_EnergyType,
|
||||
TariffScope.Meter => Strings.Enum_TariffScope_Meter,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this TariffComponent value) => value switch
|
||||
{
|
||||
TariffComponent.UnitPrice => Strings.Enum_TariffComponent_UnitPrice,
|
||||
TariffComponent.BasePrice => Strings.Enum_TariffComponent_BasePrice,
|
||||
TariffComponent.FeedIn => Strings.Enum_TariffComponent_FeedIn,
|
||||
TariffComponent.Bonus => Strings.Enum_TariffComponent_Bonus,
|
||||
TariffComponent.Discount => Strings.Enum_TariffComponent_Discount,
|
||||
TariffComponent.Tax => Strings.Enum_TariffComponent_Tax,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this TankRateMode value) => value switch
|
||||
{
|
||||
TankRateMode.Fixed => Strings.Enum_TankRateMode_Fixed,
|
||||
TankRateMode.Empirical => Strings.Enum_TankRateMode_Empirical,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this EndpointType value) => value switch
|
||||
{
|
||||
EndpointType.MqttBroker => Strings.Enum_EndpointType_MqttBroker,
|
||||
EndpointType.HomeAssistant => Strings.Enum_EndpointType_HomeAssistant,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this UpdateAvailability value) => value switch
|
||||
{
|
||||
UpdateAvailability.Allowed => Strings.Enum_UpdateAvailability_Allowed,
|
||||
UpdateAvailability.NotEnabled => Strings.Enum_UpdateAvailability_NotEnabled,
|
||||
UpdateAvailability.NotSupportedHere => Strings.Enum_UpdateAvailability_NotSupportedHere,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
public static string Display(this MappingRole value) => value switch
|
||||
{
|
||||
MappingRole.Ignore => Strings.Enum_MappingRole_Ignore,
|
||||
MappingRole.Reading => Strings.Enum_MappingRole_Reading,
|
||||
MappingRole.Delivery => Strings.Enum_MappingRole_Delivery,
|
||||
MappingRole.TankLevel => Strings.Enum_MappingRole_TankLevel,
|
||||
MappingRole.ManualCost => Strings.Enum_MappingRole_ManualCost,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using MeterVault.Infrastructure.Import;
|
||||
|
||||
namespace MeterVault.App.Localization;
|
||||
|
||||
/// <summary>
|
||||
/// Says a staging warning in the reader's language.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The importer emits <see cref="ImportWarning"/> values carrying an English sentence plus the
|
||||
/// arguments that filled it (see <c>ImportWarnings</c>). Re-formatting from the arguments rather
|
||||
/// than translating the finished sentence is what lets the numbers pick up the reader's digit
|
||||
/// grouping — a register that reads <c>2.940,19</c> everywhere else must not read <c>2940.19</c>
|
||||
/// only inside a warning.
|
||||
/// </remarks>
|
||||
public static class ImportWarningText
|
||||
{
|
||||
public static string Display(this ImportWarning warning)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(warning);
|
||||
|
||||
var args = warning.Args as object?[] ?? [.. warning.Args];
|
||||
|
||||
return warning.Kind switch
|
||||
{
|
||||
ImportWarningKind.RowSkipped => Loc.F(Strings.ImportWarning_RowSkipped, args),
|
||||
ImportWarningKind.UnparseableDate => Loc.F(Strings.ImportWarning_UnparseableDate, args),
|
||||
ImportWarningKind.UnitMismatch => Loc.F(Strings.ImportWarning_UnitMismatch, args),
|
||||
ImportWarningKind.RegisterDropped => Loc.F(Strings.ImportWarning_RegisterDropped, args),
|
||||
ImportWarningKind.RegisterDroppedWithAmount =>
|
||||
Loc.F(Strings.ImportWarning_RegisterDroppedWithAmount, args),
|
||||
|
||||
// A kind added later still says something useful rather than blanking the panel.
|
||||
_ => warning.Message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace MeterVault.App.Localization;
|
||||
|
||||
/// <summary>
|
||||
/// UI-language plumbing around <see cref="Strings"/>, the strongly-typed accessor MSBuild generates
|
||||
/// from <c>Strings.resx</c> (see the <c>EmbeddedResource</c> block in the project file).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The neutral resource is English and every other language ships as a satellite assembly, so an
|
||||
/// <c>Accept-Language</c> we don't translate degrades to English rather than to raw resource keys.
|
||||
/// Strings are referenced as compiled properties (<c>S.Common_Save</c>), not string lookups, so a
|
||||
/// key that no longer exists is a build error instead of a mystery label at runtime.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Number and date <em>formatting</em> follows <see cref="CultureInfo.CurrentCulture"/> and the UI
|
||||
/// language follows <see cref="CultureInfo.CurrentUICulture"/>; the request-localization middleware
|
||||
/// sets both from the same choice, so the two never disagree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class Loc
|
||||
{
|
||||
/// <summary>Cultures the UI ships translations for. The first entry is the neutral fallback.</summary>
|
||||
public static IReadOnlyList<string> SupportedCultures { get; } = ["en", "de"];
|
||||
|
||||
/// <summary>Formats a resource carrying <c>{0}</c>-style placeholders in the request's culture.</summary>
|
||||
public static string F(string format, params object?[] args) =>
|
||||
string.Format(CultureInfo.CurrentCulture, format, args);
|
||||
|
||||
/// <summary>
|
||||
/// Maps a requested language onto one we actually ship, matching on the two-letter tag so
|
||||
/// <c>de-AT</c> and <c>de-CH</c> get German instead of falling through to English.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> when the request named a language we translate.</returns>
|
||||
public static bool TryResolve(string? requested, out string resolved)
|
||||
{
|
||||
resolved = SupportedCultures[0];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requested))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = requested.Trim();
|
||||
var separator = trimmed.IndexOfAny(['-', '_']);
|
||||
var language = separator < 0 ? trimmed : trimmed[..separator];
|
||||
|
||||
foreach (var supported in SupportedCultures)
|
||||
{
|
||||
if (string.Equals(supported, language, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
resolved = supported;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>The display name of a supported culture, written in that language ("Deutsch", "English").</summary>
|
||||
public static string DisplayName(string culture) =>
|
||||
CultureInfo.GetCultureInfo(culture).NativeName;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -26,4 +26,20 @@
|
||||
<Content Include="..\..\sampledata\*.csv" Link="sampledata\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- UI strings (SDD §12, M7). The neutral resx generates a strongly-typed `Strings` class, so
|
||||
components reference compiled properties (S.Common_Save) instead of string keys: a renamed or
|
||||
deleted string breaks the build rather than silently rendering its key. Generation runs in
|
||||
MSBuild, not the IDE designer, so `dotnet build` alone reproduces it on any platform.
|
||||
Translations live in Strings.<culture>.resx and ship as satellite assemblies. -->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Localization\Strings.resx">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
<StronglyTypedFileName>$(IntermediateOutputPath)\Strings.Designer.cs</StronglyTypedFileName>
|
||||
<StronglyTypedLanguage>CSharp</StronglyTypedLanguage>
|
||||
<StronglyTypedNamespace>MeterVault.App.Localization</StronglyTypedNamespace>
|
||||
<StronglyTypedClassName>Strings</StronglyTypedClassName>
|
||||
<PublicClass>true</PublicClass>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MeterVault.App.Api;
|
||||
using MeterVault.App.Components;
|
||||
using MeterVault.App.Localization;
|
||||
using MeterVault.Infrastructure;
|
||||
using MeterVault.Infrastructure.Options;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
@@ -71,6 +72,7 @@ try
|
||||
builder.Services.AddMeterVaultIngestion();
|
||||
}
|
||||
|
||||
builder.Services.AddLocalization();
|
||||
builder.Services.AddMudServices();
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
@@ -100,6 +102,24 @@ try
|
||||
// that terminates TLS (SDD §10). HTTPS redirection here would break the container and proxy.
|
||||
app.UseAntiforgery();
|
||||
|
||||
// UI language (SDD §12, M7). Must run before MapRazorComponents: a Blazor Server circuit takes
|
||||
// its culture from the request that opens it, so this is what every render downstream sees.
|
||||
// Order of preference is the culture cookie the picker writes, then Accept-Language, then
|
||||
// MeterVault__Locale — an instance can be pinned to one language, and a user can still switch.
|
||||
// TryResolve yields the neutral fallback when it fails, so defaultCulture is usable either way.
|
||||
if (!Loc.TryResolve(options.Locale, out var defaultCulture) && !string.IsNullOrWhiteSpace(options.Locale))
|
||||
{
|
||||
Log.Warning(
|
||||
"MeterVault__Locale is '{Locale}', which has no translations; falling back to '{Fallback}'. "
|
||||
+ "Supported: {Supported}",
|
||||
options.Locale, defaultCulture, string.Join(", ", Loc.SupportedCultures));
|
||||
}
|
||||
|
||||
app.UseRequestLocalization(new RequestLocalizationOptions()
|
||||
.SetDefaultCulture(defaultCulture)
|
||||
.AddSupportedCultures([.. Loc.SupportedCultures])
|
||||
.AddSupportedUICultures([.. Loc.SupportedCultures]));
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "MeterVault API v1"));
|
||||
|
||||
@@ -108,6 +128,7 @@ try
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
app.MapMeterVaultApi();
|
||||
app.MapCultureEndpoints();
|
||||
|
||||
// Liveness/readiness probe for Gatus/Compose healthchecks (SDD §9).
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>A node in the flow graph: a meter, or a synthetic "Other/unmetered" remainder.</summary>
|
||||
/// <param name="Label">
|
||||
/// A meter's name — for a remainder node (<paramref name="IsOther"/>) the <em>parent</em> meter's
|
||||
/// name. The surrounding wording ("Other (…)") is the UI's to supply, because it is the only layer
|
||||
/// that knows the reader's language; see <c>Flow_OtherNode</c>.
|
||||
/// </param>
|
||||
public sealed record FlowNode(string Id, string Label, double Value, int Depth, string? ColorHex, bool IsOther, int? MeterId);
|
||||
|
||||
/// <summary>A directed flow edge with the quantity that flows along it, in the energy type's base unit.</summary>
|
||||
|
||||
@@ -115,7 +115,9 @@ public sealed class FlowService(IDbContextFactory<MeterVaultDbContext> contextFa
|
||||
if (remainder > Epsilon)
|
||||
{
|
||||
var otherId = $"other{meter.Id}";
|
||||
nodes.Add(new FlowNode(otherId, $"Other ({meter.Name})", remainder, depth.GetValueOrDefault(meter.Id) + 1, "#78909C", true, null));
|
||||
// Just the parent's name: the "Other (…)" phrasing is added by the UI, which is
|
||||
// where the reader's language is known.
|
||||
nodes.Add(new FlowNode(otherId, meter.Name, remainder, depth.GetValueOrDefault(meter.Id) + 1, "#78909C", true, null));
|
||||
flowLinks.Add(new FlowLink(NodeId(meter.Id), otherId, remainder));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ public sealed record MeterMonthPoint(DateOnly Month, double Amount, double Cost)
|
||||
/// <summary>
|
||||
/// A meter framed the way it is actually read: what it used this period, how that compares with the
|
||||
/// last one, and where the year is heading. Amounts are generation for a generation counter and
|
||||
/// consumption otherwise, so <see cref="Label"/> says which.
|
||||
/// consumption otherwise, so <see cref="Kind"/> says which — as the enum, not a word, because the
|
||||
/// wording belongs to whichever language the reader picked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Month- and year-to-date are compared against a <em>projection</em> of the current period rather
|
||||
@@ -22,7 +23,7 @@ public sealed record MeterMonthPoint(DateOnly Month, double Amount, double Cost)
|
||||
/// collapse in usage when nothing has changed. Projections are flagged so the UI can mark them.
|
||||
/// </remarks>
|
||||
public sealed record MeterPeriodView(
|
||||
string Label,
|
||||
ConsumptionKind Kind,
|
||||
string Unit,
|
||||
string Currency,
|
||||
double MonthToDate,
|
||||
|
||||
@@ -86,7 +86,7 @@ public sealed class MeterPeriodService(
|
||||
var yearToDateCost = SumYear(costs, today.Year);
|
||||
|
||||
return new MeterPeriodView(
|
||||
Label: isGeneration ? "Generation" : "Consumption",
|
||||
Kind: kind,
|
||||
Unit: meter.Unit,
|
||||
Currency: _options.Currency,
|
||||
MonthToDate: monthToDate,
|
||||
|
||||
@@ -31,7 +31,7 @@ public sealed class CsvImporter
|
||||
staged.SkippedRows++;
|
||||
if (!reason.StartsWith("blank", StringComparison.Ordinal))
|
||||
{
|
||||
staged.Warnings.Add($"Row {r + 1}: skipped ({reason}).");
|
||||
staged.Warnings.Add(ImportWarnings.RowSkipped(r + 1, reason));
|
||||
}
|
||||
|
||||
continue;
|
||||
@@ -40,7 +40,7 @@ public sealed class CsvImporter
|
||||
if (!TryGetDate(row, profile, out var time))
|
||||
{
|
||||
staged.SkippedRows++;
|
||||
staged.Warnings.Add($"Row {r + 1}: unparseable date, skipped.");
|
||||
staged.Warnings.Add(ImportWarnings.UnparseableDate(r + 1));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -140,8 +140,7 @@ public sealed class CsvImporter
|
||||
if (column.Unit is not null && split.Value.Unit is not null
|
||||
&& !string.Equals(column.Unit, split.Value.Unit, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
staged.Warnings.Add(
|
||||
$"Row {rowIndex + 1}: expected unit '{column.Unit}' but found '{split.Value.Unit}'.");
|
||||
staged.Warnings.Add(ImportWarnings.UnitMismatch(rowIndex + 1, column.Unit, split.Value.Unit));
|
||||
}
|
||||
|
||||
if (detectSwaps && previousByMeter.TryGetValue(meterId, out var previous) && value < previous)
|
||||
@@ -157,9 +156,9 @@ public sealed class CsvImporter
|
||||
Amount = override_,
|
||||
Notes = "Auto-detected register decrease (meter swap / reset candidate).",
|
||||
});
|
||||
staged.Warnings.Add(
|
||||
$"Row {rowIndex + 1}: register for meter {meterId} dropped {previous}→{value}; " +
|
||||
(override_ is null ? "swap event created (verify)." : $"swap consumption set to {override_}."));
|
||||
staged.Warnings.Add(override_ is { } amount
|
||||
? ImportWarnings.RegisterDroppedWithAmount(rowIndex + 1, meterId, previous, value, amount)
|
||||
: ImportWarnings.RegisterDropped(rowIndex + 1, meterId, previous, value));
|
||||
}
|
||||
|
||||
staged.Readings.Add(new Reading
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace MeterVault.Infrastructure.Import;
|
||||
|
||||
/// <summary>What a staging warning is about.</summary>
|
||||
public enum ImportWarningKind
|
||||
{
|
||||
/// <summary>A row matched a skip rule (a summary line, an all-zero placeholder). Args: row, reason.</summary>
|
||||
RowSkipped,
|
||||
|
||||
/// <summary>The date cell parsed as neither <c>Monat JJJJ</c> nor <c>TT.MM.JJJJ</c>. Args: row.</summary>
|
||||
UnparseableDate,
|
||||
|
||||
/// <summary>The value carried a different unit suffix than the column expects. Args: row, expected, found.</summary>
|
||||
UnitMismatch,
|
||||
|
||||
/// <summary>A register went backwards; a swap event was staged for review. Args: row, meter, previous, new.</summary>
|
||||
RegisterDropped,
|
||||
|
||||
/// <summary>Same, with the consumption across the swap taken from the sheet. Args: row, meter, previous, new, amount.</summary>
|
||||
RegisterDroppedWithAmount,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One thing worth telling the user about a staged row.
|
||||
/// </summary>
|
||||
/// <param name="Message">
|
||||
/// The English sentence, kept for logs and any non-UI caller.
|
||||
/// </param>
|
||||
/// <param name="Args">
|
||||
/// The values that filled it, in order. The UI re-formats them into its own language's sentence, so
|
||||
/// numbers pick up the reader's digit grouping instead of being frozen into a string here.
|
||||
/// </param>
|
||||
public sealed record ImportWarning(ImportWarningKind Kind, string Message, IReadOnlyList<object?> Args)
|
||||
{
|
||||
/// <summary>Falls back to the English sentence, so a plain <c>ToString()</c> is still useful.</summary>
|
||||
public override string ToString() => Message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the warnings the CSV importer emits.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each kind has exactly one English format string here, and the arguments travel alongside the
|
||||
/// rendered sentence — so the importer stays free of any notion of who is reading, and the admin UI
|
||||
/// can say the same thing in German without this assembly growing a resource file.
|
||||
/// </remarks>
|
||||
internal static class ImportWarnings
|
||||
{
|
||||
public static ImportWarning RowSkipped(int row, string reason) =>
|
||||
Create(ImportWarningKind.RowSkipped, "Row {0}: skipped ({1}).", row, reason);
|
||||
|
||||
public static ImportWarning UnparseableDate(int row) =>
|
||||
Create(ImportWarningKind.UnparseableDate, "Row {0}: unparseable date, skipped.", row);
|
||||
|
||||
public static ImportWarning UnitMismatch(int row, string expected, string found) =>
|
||||
Create(ImportWarningKind.UnitMismatch, "Row {0}: expected unit '{1}' but found '{2}'.", row, expected, found);
|
||||
|
||||
public static ImportWarning RegisterDropped(int row, int meterId, double previous, double current) =>
|
||||
Create(
|
||||
ImportWarningKind.RegisterDropped,
|
||||
"Row {0}: register for meter {1} dropped {2}→{3}; swap event created (verify).",
|
||||
row, meterId, previous, current);
|
||||
|
||||
public static ImportWarning RegisterDroppedWithAmount(
|
||||
int row, int meterId, double previous, double current, double amount) =>
|
||||
Create(
|
||||
ImportWarningKind.RegisterDroppedWithAmount,
|
||||
"Row {0}: register for meter {1} dropped {2}→{3}; swap consumption set to {4}.",
|
||||
row, meterId, previous, current, amount);
|
||||
|
||||
private static ImportWarning Create(ImportWarningKind kind, string format, params object?[] args) =>
|
||||
new(kind, string.Format(CultureInfo.InvariantCulture, format, args), args);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public sealed class StagedImport
|
||||
|
||||
public List<ManualCost> ManualCosts { get; } = [];
|
||||
|
||||
public List<string> Warnings { get; } = [];
|
||||
public List<ImportWarning> Warnings { get; } = [];
|
||||
|
||||
public int SkippedRows { get; set; }
|
||||
|
||||
|
||||
@@ -3,8 +3,55 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MeterVault.Infrastructure.Ingestion;
|
||||
|
||||
/// <summary>Which way a Home Assistant connectivity test ended.</summary>
|
||||
/// <remarks>
|
||||
/// The verdict is shown to whoever clicked "Test connection", so it has to be sayable in their
|
||||
/// language — and this assembly has no business knowing what that is. The outcome travels as a
|
||||
/// value and the admin UI supplies the words.
|
||||
/// </remarks>
|
||||
public enum HaTestOutcome
|
||||
{
|
||||
/// <summary>
|
||||
/// No test was run — the caller decided beforehand (no token configured, base URL edited since
|
||||
/// saving) and put its own, already-localized wording in <see cref="HaTestResult.Message"/>.
|
||||
/// The default, so a result built by the UI needs no ceremony to say so.
|
||||
/// </summary>
|
||||
Precondition,
|
||||
|
||||
/// <summary>Reachable and the token was accepted; no entity was named to sample.</summary>
|
||||
Connected,
|
||||
|
||||
/// <summary>Reachable and the named entity returned a number — see <see cref="HaTestResult.SampleValue"/>.</summary>
|
||||
ConnectedWithValue,
|
||||
|
||||
/// <summary>No base URL was given.</summary>
|
||||
BaseUrlMissing,
|
||||
|
||||
/// <summary>No token was available to test with.</summary>
|
||||
TokenMissing,
|
||||
|
||||
/// <summary>HA answered, but not with success. <see cref="HaTestResult.Detail"/> carries the status.</summary>
|
||||
HttpError,
|
||||
|
||||
/// <summary>Reachable, but the named entity has no numeric state (unavailable/unknown/non-numeric).</summary>
|
||||
NoNumericState,
|
||||
|
||||
/// <summary>The request threw. <see cref="HaTestResult.Detail"/> carries the exception message.</summary>
|
||||
RequestFailed,
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a Home Assistant connectivity test.</summary>
|
||||
public sealed record HaTestResult(bool Ok, string Message, double? SampleValue = null);
|
||||
/// <param name="Message">The English summary, kept for logs and non-UI callers.</param>
|
||||
/// <param name="Outcome">The same verdict as a value, for a UI that has to phrase it in some language.</param>
|
||||
/// <param name="EntityId">The entity that was sampled, when one was named.</param>
|
||||
/// <param name="Detail">Diagnostic text (HTTP status, exception message). Not ours to translate.</param>
|
||||
public sealed record HaTestResult(
|
||||
bool Ok,
|
||||
string Message,
|
||||
double? SampleValue = null,
|
||||
HaTestOutcome Outcome = HaTestOutcome.Precondition,
|
||||
string? EntityId = null,
|
||||
string? Detail = null);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a Home Assistant connection from the admin UI: checks the base URL + token against
|
||||
@@ -26,12 +73,12 @@ public sealed class HaConnectionTester(IHttpClientFactory httpClientFactory, ILo
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return new HaTestResult(false, "Base URL is required.");
|
||||
return new HaTestResult(false, "Base URL is required.", Outcome: HaTestOutcome.BaseUrlMissing);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return new HaTestResult(false, "No token available to test.");
|
||||
return new HaTestResult(false, "No token available to test.", Outcome: HaTestOutcome.TokenMissing);
|
||||
}
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
@@ -44,23 +91,29 @@ public sealed class HaConnectionTester(IHttpClientFactory httpClientFactory, ILo
|
||||
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new HaTestResult(false, $"HA returned {(int)response.StatusCode} {response.ReasonPhrase}.");
|
||||
return new HaTestResult(false, $"HA returned {(int)response.StatusCode} {response.ReasonPhrase}.",
|
||||
Outcome: HaTestOutcome.HttpError,
|
||||
Detail: $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim());
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entityId))
|
||||
{
|
||||
return new HaTestResult(true, "Connected — Home Assistant API reachable and token accepted.");
|
||||
return new HaTestResult(true, "Connected — Home Assistant API reachable and token accepted.",
|
||||
Outcome: HaTestOutcome.Connected);
|
||||
}
|
||||
|
||||
var state = await new HaStateClient(client).GetStateAsync(baseUrl, token, entityId, null, cancellationToken).ConfigureAwait(false);
|
||||
return state is { } value
|
||||
? new HaTestResult(true, $"Connected — {entityId} = {value.Value}.", value.Value)
|
||||
: new HaTestResult(false, $"Connected, but '{entityId}' has no numeric state (unavailable/unknown or non-numeric).");
|
||||
? new HaTestResult(true, $"Connected — {entityId} = {value.Value}.", value.Value,
|
||||
HaTestOutcome.ConnectedWithValue, entityId)
|
||||
: new HaTestResult(false, $"Connected, but '{entityId}' has no numeric state (unavailable/unknown or non-numeric).",
|
||||
Outcome: HaTestOutcome.NoNumericState, EntityId: entityId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Home Assistant connection test failed for {BaseUrl}", baseUrl);
|
||||
return new HaTestResult(false, $"Connection failed: {ex.Message}");
|
||||
return new HaTestResult(false, $"Connection failed: {ex.Message}",
|
||||
Outcome: HaTestOutcome.RequestFailed, Detail: ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,43 @@ public enum UpdateAvailability
|
||||
NotSupportedHere,
|
||||
}
|
||||
|
||||
/// <summary>Which way an attempt to launch the updater ended.</summary>
|
||||
/// <remarks>
|
||||
/// The UI needs to say this in the reader's language, and this assembly has no business knowing
|
||||
/// what that language is — so the outcome travels as a value and the wording is chosen upstairs.
|
||||
/// </remarks>
|
||||
public enum UpdateOutcome
|
||||
{
|
||||
/// <summary>The transient unit is running; the service restarts when the rebuild finishes.</summary>
|
||||
Started,
|
||||
|
||||
/// <summary><see cref="UpdateRunner.Availability"/> refused before anything was launched.</summary>
|
||||
NotAllowed,
|
||||
|
||||
/// <summary><c>systemd-run</c> could not be started at all.</summary>
|
||||
LauncherMissing,
|
||||
|
||||
/// <summary><c>systemd-run</c> ran and exited non-zero — most often an update already in flight.</summary>
|
||||
LauncherFailed,
|
||||
|
||||
/// <summary>Launching threw. <see cref="UpdateLaunch.Detail"/> carries the exception message.</summary>
|
||||
LaunchError,
|
||||
}
|
||||
|
||||
/// <summary>Outcome of trying to launch the updater.</summary>
|
||||
public sealed record UpdateLaunch(bool Started, string Message);
|
||||
/// <param name="Message">
|
||||
/// The English summary. Kept as-is for logs and any non-UI caller, so a translation can never change
|
||||
/// what an operator reads in a log line.
|
||||
/// </param>
|
||||
/// <param name="Outcome">The same result as a value, for a UI that has to phrase it in some language.</param>
|
||||
/// <param name="Availability">Why it was refused, when <paramref name="Outcome"/> is <see cref="UpdateOutcome.NotAllowed"/>.</param>
|
||||
/// <param name="Detail">Diagnostic text (stderr, an exception message). Never translated — it isn't ours.</param>
|
||||
public sealed record UpdateLaunch(
|
||||
bool Started,
|
||||
string Message,
|
||||
UpdateOutcome Outcome = UpdateOutcome.LaunchError,
|
||||
UpdateAvailability? Availability = null,
|
||||
string? Detail = null);
|
||||
|
||||
/// <summary>
|
||||
/// Starts the in-container updater on request, gated hard.
|
||||
@@ -75,7 +110,8 @@ public sealed class UpdateRunner(IOptions<MeterVaultOptions> options, ILogger<Up
|
||||
{
|
||||
if (Availability is not UpdateAvailability.Allowed)
|
||||
{
|
||||
return new UpdateLaunch(false, $"Update cannot be started: {Availability}.");
|
||||
return new UpdateLaunch(false, $"Update cannot be started: {Availability}.",
|
||||
UpdateOutcome.NotAllowed, Availability);
|
||||
}
|
||||
|
||||
try
|
||||
@@ -97,7 +133,7 @@ public sealed class UpdateRunner(IOptions<MeterVaultOptions> options, ILogger<Up
|
||||
using var process = Process.Start(start);
|
||||
if (process is null)
|
||||
{
|
||||
return new UpdateLaunch(false, "Could not start systemd-run.");
|
||||
return new UpdateLaunch(false, "Could not start systemd-run.", UpdateOutcome.LauncherMissing);
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -108,7 +144,8 @@ public sealed class UpdateRunner(IOptions<MeterVaultOptions> options, ILogger<Up
|
||||
// Most likely an update already running: the unit name is taken until it is collected.
|
||||
_logger.LogWarning("systemd-run failed ({ExitCode}): {Error}", process.ExitCode, error);
|
||||
return new UpdateLaunch(false,
|
||||
string.IsNullOrWhiteSpace(error) ? "Could not start the update." : error);
|
||||
string.IsNullOrWhiteSpace(error) ? "Could not start the update." : error,
|
||||
UpdateOutcome.LauncherFailed, Detail: string.IsNullOrWhiteSpace(error) ? null : error);
|
||||
}
|
||||
|
||||
// Deliberately loud. With no key there is no caller to attribute this to, so the log is
|
||||
@@ -116,12 +153,14 @@ public sealed class UpdateRunner(IOptions<MeterVaultOptions> options, ILogger<Up
|
||||
// held in memory survives.
|
||||
_logger.LogWarning("In-app update started as transient unit {Unit}", TransientUnit);
|
||||
return new UpdateLaunch(true,
|
||||
"Update started. The service restarts when the rebuild finishes — this usually takes a few minutes.");
|
||||
"Update started. The service restarts when the rebuild finishes — this usually takes a few minutes.",
|
||||
UpdateOutcome.Started);
|
||||
}
|
||||
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not launch the updater");
|
||||
return new UpdateLaunch(false, $"Could not launch the updater: {ex.Message}");
|
||||
return new UpdateLaunch(false, $"Could not launch the updater: {ex.Message}",
|
||||
UpdateOutcome.LaunchError, Detail: ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using MeterVault.Infrastructure.Costing;
|
||||
using MeterVault.Infrastructure.Dashboard;
|
||||
using MeterVault.Infrastructure.Import;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Localization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -111,6 +112,45 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
var meterPage = await (await client.GetAsync(new Uri($"/meters/{hausId}", UriKind.Relative)))
|
||||
.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Add reading", meterPage, StringComparison.Ordinal);
|
||||
|
||||
// The same pages in German (SDD §12, M7). Real data rather than an empty instance, so
|
||||
// this covers the labels that only exist once rows have rendered — the branch a
|
||||
// smoke test against a bare database silently skips.
|
||||
using var germanClient = factory.CreateClient();
|
||||
germanClient.DefaultRequestHeaders.Add(
|
||||
"Cookie",
|
||||
CookieRequestCultureProvider.DefaultCookieName
|
||||
+ "="
|
||||
+ Uri.EscapeDataString(CookieRequestCultureProvider.MakeCookieValue(new RequestCulture("de"))));
|
||||
|
||||
// Decoded, because Blazor entity-encodes non-ASCII: "Übersicht" ships as "Übersicht".
|
||||
var germanOverview = System.Net.WebUtility.HtmlDecode(
|
||||
await germanClient.GetStringAsync(new Uri("/", UriKind.Relative)));
|
||||
Assert.Contains("lang=\"de\"", germanOverview, StringComparison.Ordinal);
|
||||
Assert.Contains("Übersicht", germanOverview, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("This month", germanOverview, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Latest month with data", germanOverview, StringComparison.Ordinal);
|
||||
|
||||
// Meter names are user data: they stay exactly as imported, in either language. The
|
||||
// meter list is where they render — the overview shows cost categories, not meters.
|
||||
var germanMeters = System.Net.WebUtility.HtmlDecode(
|
||||
await germanClient.GetStringAsync(new Uri("/meters", UriKind.Relative)));
|
||||
Assert.Contains("Zähler Haus", germanMeters, StringComparison.Ordinal);
|
||||
// ...while the meter's mode, which is an enum and not user data, is translated.
|
||||
Assert.Contains("Zählerstand (kumulativ)", germanMeters, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("CumulativeCounter", germanMeters, StringComparison.Ordinal);
|
||||
|
||||
foreach (var path in new[]
|
||||
{
|
||||
"/meters", "/trends", "/solar", "/consumables", "/import",
|
||||
"/admin/tariffs", "/admin/energy-types", "/admin/categories",
|
||||
"/admin/connectors", "/admin/settings", $"/meters/{hausId}",
|
||||
$"/energy/{electricityTypeId}",
|
||||
})
|
||||
{
|
||||
var response = await germanClient.GetAsync(new Uri(path, UriKind.Relative));
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Localization;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace MeterVault.Integration.Tests.Localization;
|
||||
|
||||
/// <summary>
|
||||
/// The language picker's endpoint and the culture the middleware hands each render (SDD §12, M7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A Blazor Server circuit is fixed to the culture of the request that opened it, so switching
|
||||
/// language cannot be an interactive state change — it is a redirect that writes a cookie and forces
|
||||
/// a reload. That makes <c>/culture/set</c> a redirector taking its target from the query string,
|
||||
/// which is the exact shape of an open redirect, so the off-site cases below are load-bearing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing here touches the database: no page on these paths queries, and startup migration is off,
|
||||
/// so the connection string only has to parse. They run without Docker.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CultureEndpointTests : IDisposable
|
||||
{
|
||||
private const string UnusedConnection = "Host=localhost;Port=1;Database=metervault;Username=none;Password=none";
|
||||
|
||||
/// <summary>Renders the full layout (app bar + nav) without needing any data.</summary>
|
||||
private static readonly Uri LayoutOnlyPage = new("/admin/settings", UriKind.Relative);
|
||||
|
||||
private readonly List<MeterVaultAppFactory> _factories = [];
|
||||
|
||||
[Fact]
|
||||
public async Task Setting_a_supported_culture_stores_the_cookie_and_returns_the_user_to_the_page()
|
||||
{
|
||||
using var client = RedirectlessClient();
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
new Uri("/culture/set?culture=de&redirectUri=%2Fmeters%2F7", UriKind.Relative));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Found, response.StatusCode);
|
||||
Assert.Equal("/meters/7", response.Headers.Location?.OriginalString);
|
||||
Assert.Contains("c%3Dde%7Cuic%3Dde", CultureCookie(response), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_regional_variant_is_stored_as_the_language_we_ship()
|
||||
{
|
||||
using var client = RedirectlessClient();
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
new Uri("/culture/set?culture=de-AT&redirectUri=%2F", UriKind.Relative));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Found, response.StatusCode);
|
||||
Assert.Contains("c%3Dde%7Cuic%3Dde", CultureCookie(response), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("fr")]
|
||||
[InlineData("")]
|
||||
[InlineData("../../etc/passwd")]
|
||||
public async Task A_language_we_do_not_ship_is_refused_rather_than_stored(string culture)
|
||||
{
|
||||
using var client = RedirectlessClient();
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
new Uri($"/culture/set?culture={Uri.EscapeDataString(culture)}&redirectUri=%2F", UriKind.Relative));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Null(CultureCookie(response));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://evil.example/phish")]
|
||||
[InlineData("//evil.example/phish")]
|
||||
[InlineData("/\\evil.example/phish")]
|
||||
[InlineData("")]
|
||||
public async Task An_off_site_redirect_target_lands_on_the_dashboard_instead(string redirectUri)
|
||||
{
|
||||
using var client = RedirectlessClient();
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
new Uri($"/culture/set?culture=de&redirectUri={Uri.EscapeDataString(redirectUri)}", UriKind.Relative));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Found, response.StatusCode);
|
||||
Assert.Equal("/", response.Headers.Location?.OriginalString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_culture_cookie_decides_the_language_of_the_rendered_page()
|
||||
{
|
||||
using var client = Factory().CreateClient();
|
||||
client.DefaultRequestHeaders.Add(
|
||||
"Cookie",
|
||||
CookieRequestCultureProvider.DefaultCookieName
|
||||
+ "="
|
||||
+ Uri.EscapeDataString(CookieRequestCultureProvider.MakeCookieValue(new RequestCulture("de"))));
|
||||
|
||||
var html = await RenderedTextAsync(client, LayoutOnlyPage);
|
||||
|
||||
Assert.Contains("lang=\"de\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains("Übersicht", html, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Overview", html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_cookie_the_instance_default_locale_applies()
|
||||
{
|
||||
using var client = Factory(locale: "de").CreateClient();
|
||||
|
||||
var html = await RenderedTextAsync(client, LayoutOnlyPage);
|
||||
|
||||
Assert.Contains("lang=\"de\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains("Übersicht", html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task English_is_the_shipped_default_when_nothing_is_configured()
|
||||
{
|
||||
using var client = Factory().CreateClient();
|
||||
|
||||
var html = await RenderedTextAsync(client, LayoutOnlyPage);
|
||||
|
||||
Assert.Contains("lang=\"en\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains("Overview", html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unshipped_default_locale_degrades_to_english_instead_of_failing_to_boot()
|
||||
{
|
||||
using var client = Factory(locale: "fr").CreateClient();
|
||||
|
||||
var html = await RenderedTextAsync(client, LayoutOnlyPage);
|
||||
|
||||
Assert.Contains("lang=\"en\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains("Overview", html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_first_visit_pins_the_negotiated_culture_into_the_cookie()
|
||||
{
|
||||
// Otherwise a reader whose browser asks for German is served German, but opens the picker
|
||||
// to find English ticked — the cookie is the only thing either side agrees to read.
|
||||
using var client = RedirectlessClient();
|
||||
client.DefaultRequestHeaders.Add("Accept-Language", "de-DE,de;q=0.9,en;q=0.8");
|
||||
|
||||
using var response = await client.GetAsync(LayoutOnlyPage);
|
||||
|
||||
Assert.Contains("c%3Dde%7Cuic%3Dde", CultureCookie(response), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var factory in _factories)
|
||||
{
|
||||
factory.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blazor entity-encodes every non-ASCII character it renders, so "Übersicht" arrives as
|
||||
/// "&#xDC;bersicht" — decoding first is what lets these assertions read like the UI does.
|
||||
/// </summary>
|
||||
private static async Task<string> RenderedTextAsync(HttpClient client, Uri page) =>
|
||||
WebUtility.HtmlDecode(await client.GetStringAsync(page));
|
||||
|
||||
private static string? CultureCookie(HttpResponseMessage response) =>
|
||||
response.Headers.TryGetValues("Set-Cookie", out var cookies)
|
||||
? cookies.FirstOrDefault(c =>
|
||||
c.StartsWith(CookieRequestCultureProvider.DefaultCookieName, StringComparison.Ordinal))
|
||||
: null;
|
||||
|
||||
private MeterVaultAppFactory Factory(string? locale = null)
|
||||
{
|
||||
var factory = new MeterVaultAppFactory(UnusedConnection, configureApiKey: false) { Locale = locale };
|
||||
_factories.Add(factory);
|
||||
return factory;
|
||||
}
|
||||
|
||||
private HttpClient RedirectlessClient() =>
|
||||
Factory().CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Globalization;
|
||||
using MeterVault.App.Localization;
|
||||
using MeterVault.Core.Domain;
|
||||
|
||||
namespace MeterVault.Integration.Tests.Localization;
|
||||
|
||||
/// <summary>
|
||||
/// Every domain enum value the UI renders has wording in every language (SDD §12, M7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="DisplayNames"/> ends each mapping with a fallback arm that returns the bare identifier,
|
||||
/// so adding an enum value can never throw mid-render on a dashboard. The cost of that safety is that
|
||||
/// a forgotten value degrades silently to English — these tests are what make it loud instead, by
|
||||
/// demanding a matching <c>Enum_<Type>_<Value></c> resource for every declared value.
|
||||
/// </remarks>
|
||||
public sealed class EnumDisplayNameTests
|
||||
{
|
||||
[Fact]
|
||||
public void Every_localized_enum_value_has_a_resource_in_every_language()
|
||||
{
|
||||
var missing = new List<string>();
|
||||
|
||||
foreach (var type in DisplayNames.LocalizedEnums)
|
||||
{
|
||||
foreach (var name in Enum.GetNames(type))
|
||||
{
|
||||
// None is the empty bitmask, deliberately rendered as nothing at all.
|
||||
if (type == typeof(ReadingFlags) && name == nameof(ReadingFlags.None))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = $"Enum_{type.Name}_{name}";
|
||||
foreach (var culture in Loc.SupportedCultures)
|
||||
{
|
||||
var value = Strings.ResourceManager.GetString(key, CultureInfo.GetCultureInfo(culture));
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
missing.Add($"{key} [{culture}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(missing.Count == 0, $"Missing enum display names: {string.Join(", ", missing)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Display_names_are_translated_rather_than_echoing_the_identifier()
|
||||
{
|
||||
// Not every value can differ — "Tasmota", "Bonus" and "Global" are the same word in German —
|
||||
// but if a whole enum came back as its own identifiers, the mapping was never written.
|
||||
var untranslated = new List<string>();
|
||||
|
||||
foreach (var type in DisplayNames.LocalizedEnums)
|
||||
{
|
||||
var names = Enum.GetNames(type);
|
||||
var translated = 0;
|
||||
foreach (var name in names)
|
||||
{
|
||||
var german = Strings.ResourceManager.GetString($"Enum_{type.Name}_{name}", CultureInfo.GetCultureInfo("de"));
|
||||
if (german is not null && !string.Equals(german, name, StringComparison.Ordinal))
|
||||
{
|
||||
translated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (translated == 0)
|
||||
{
|
||||
untranslated.Add(type.Name);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(untranslated.Count == 0, $"Enums with no German wording at all: {string.Join(", ", untranslated)}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("en", "Consumption")]
|
||||
[InlineData("de", "Verbrauch")]
|
||||
public void The_reader_s_language_decides_the_wording(string culture, string expected) =>
|
||||
Assert.Equal(expected, WithUiCulture(culture, () => ConsumptionKind.Consumption.Display()));
|
||||
|
||||
[Fact]
|
||||
public void A_bitmask_lists_the_flags_it_actually_carries()
|
||||
{
|
||||
// Blank rather than "None": the readings table has a flag column that is empty on almost
|
||||
// every row, and printing a word down the whole page is noise, not information.
|
||||
Assert.Equal(string.Empty, ReadingFlags.None.Display());
|
||||
|
||||
var both = WithUiCulture("de", () => (ReadingFlags.CounterReset | ReadingFlags.MeterSwap).Display());
|
||||
Assert.Equal("Zählerreset, Zählerwechsel", both);
|
||||
|
||||
Assert.Equal("Meter swap", WithUiCulture("en", () => ReadingFlags.MeterSwap.Display()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_undeclared_enum_value_degrades_to_its_identifier_instead_of_throwing()
|
||||
{
|
||||
// Guards the fallback arm itself: a value cast in from the database (or a future migration)
|
||||
// must not take a dashboard down.
|
||||
var unknown = (MeterMode)999;
|
||||
|
||||
Assert.Equal("999", unknown.Display());
|
||||
}
|
||||
|
||||
private static T WithUiCulture<T>(string culture, Func<T> body)
|
||||
{
|
||||
var previous = CultureInfo.CurrentUICulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(culture);
|
||||
return body();
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentUICulture = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Globalization;
|
||||
using MeterVault.App;
|
||||
|
||||
namespace MeterVault.Integration.Tests.Localization;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="Format"/> formats against the reader's culture rather than a fixed de-DE (SDD §12, M7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The importer's de-DE parsing is deliberately untouched by this: that dialect is a property of the
|
||||
/// spreadsheet files, not of who is looking at the dashboard, and <c>GermanParsingTests</c> pins it.
|
||||
/// </remarks>
|
||||
public sealed class FormatCultureTests
|
||||
{
|
||||
[Fact]
|
||||
public void Digit_grouping_follows_the_reader()
|
||||
{
|
||||
Assert.Equal("1.234,5", WithCulture("de", () => Format.Number(1234.5, 1)));
|
||||
Assert.Equal("1,234.5", WithCulture("en", () => Format.Number(1234.5, 1)));
|
||||
|
||||
Assert.Equal("2.940", WithCulture("de", () => Format.Number(2940)));
|
||||
Assert.Equal("2,940", WithCulture("en", () => Format.Number(2940)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_currency_symbol_stays_the_instances_own()
|
||||
{
|
||||
// Only the grouping is localized. The figures are in the instance's configured currency, so
|
||||
// an English reader must see the same money written their way — not relabelled as dollars.
|
||||
Assert.Equal("1.234,50 €", WithCulture("de", () => Format.Euro(1234.5)));
|
||||
Assert.Equal("1,234.50 €", WithCulture("en", () => Format.Euro(1234.5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Percentages_keep_their_explicit_sign()
|
||||
{
|
||||
Assert.Equal("+12,4 %", WithCulture("de", () => Format.Percent(12.4)));
|
||||
Assert.Equal("+12.4 %", WithCulture("en", () => Format.Percent(12.4)));
|
||||
|
||||
Assert.Equal("-7,2 %", WithCulture("de", () => Format.Percent(-7.2)));
|
||||
Assert.Equal("-7.2 %", WithCulture("en", () => Format.Percent(-7.2)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Month_labels_are_written_in_the_readers_language()
|
||||
{
|
||||
var march = new DateOnly(2025, 3, 1);
|
||||
|
||||
var german = WithCulture("de", () => Format.MonthLabel(march));
|
||||
var english = WithCulture("en", () => Format.MonthLabel(march));
|
||||
|
||||
// Asserting the exact German abbreviation would pin us to one ICU version ("Mrz" vs "Mär"),
|
||||
// so assert what actually matters: the label is culture-sensitive, not invariant.
|
||||
Assert.Equal("Mar 25", english);
|
||||
Assert.NotEqual(english, german);
|
||||
Assert.EndsWith("25", german, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Direction_icons_are_language_neutral()
|
||||
{
|
||||
Assert.Equal("▲", Format.DirectionIcon(1));
|
||||
Assert.Equal("▼", Format.DirectionIcon(-1));
|
||||
Assert.Equal("—", Format.DirectionIcon(0));
|
||||
}
|
||||
|
||||
private static T WithCulture<T>(string culture, Func<T> body)
|
||||
{
|
||||
var previous = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture);
|
||||
return body();
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Resources;
|
||||
using System.Text.RegularExpressions;
|
||||
using MeterVault.App.Localization;
|
||||
|
||||
namespace MeterVault.Integration.Tests.Localization;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the UI string catalogue (SDD §12, M7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resource lookup fails soft by design — ask for a key the German satellite doesn't carry and
|
||||
/// <see cref="ResourceManager"/> quietly serves the English one. That is the right runtime
|
||||
/// behaviour and the wrong build behaviour: a half-translated release would look perfectly healthy.
|
||||
/// These tests read each culture's resource set with <c>tryParents: false</c>, which is the only way
|
||||
/// to see what a satellite actually contains, and turn "untranslated" back into a failure.
|
||||
/// No database, so they run without Docker.
|
||||
/// </remarks>
|
||||
public sealed class StringResourceTests
|
||||
{
|
||||
/// <summary>Matches {0}, {1:N2}, {0,-8} — the index is what has to agree across languages.</summary>
|
||||
private static readonly Regex PlaceholderPattern = new(@"\{(\d+)(?:[,:][^}]*)?\}", RegexOptions.Compiled);
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> Neutral = ResourcesFor(CultureInfo.InvariantCulture);
|
||||
|
||||
[Fact]
|
||||
public void Neutral_resources_exist()
|
||||
{
|
||||
Assert.NotEmpty(Neutral);
|
||||
|
||||
// Every generated property is backed by a real entry, so `S.Foo` can never compile against
|
||||
// a key the resx no longer defines.
|
||||
var generated = typeof(Strings)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(p => p.PropertyType == typeof(string) && p.Name != "Culture")
|
||||
.Select(p => p.Name)
|
||||
.ToList();
|
||||
|
||||
Assert.NotEmpty(generated);
|
||||
Assert.Empty(generated.Except(Neutral.Keys, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TranslatedCultures))]
|
||||
public void Every_string_is_translated(string culture)
|
||||
{
|
||||
var translated = ResourcesFor(CultureInfo.GetCultureInfo(culture));
|
||||
|
||||
var missing = Neutral.Keys.Except(translated.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList();
|
||||
Assert.True(
|
||||
missing.Count == 0,
|
||||
$"Strings.{culture}.resx is missing {missing.Count} key(s): {string.Join(", ", missing)}");
|
||||
|
||||
// An entry that exists but is blank renders as nothing at all — worse than falling back.
|
||||
var blank = translated.Where(e => string.IsNullOrWhiteSpace(e.Value)).Select(e => e.Key).Order(StringComparer.Ordinal).ToList();
|
||||
Assert.True(blank.Count == 0, $"Strings.{culture}.resx has blank value(s): {string.Join(", ", blank)}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TranslatedCultures))]
|
||||
public void No_translation_is_orphaned(string culture)
|
||||
{
|
||||
var translated = ResourcesFor(CultureInfo.GetCultureInfo(culture));
|
||||
|
||||
// A key only the translation has is dead weight: nothing can reference it, because the
|
||||
// strongly-typed accessor is generated from the neutral resx alone.
|
||||
var orphans = translated.Keys.Except(Neutral.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList();
|
||||
Assert.True(
|
||||
orphans.Count == 0,
|
||||
$"Strings.{culture}.resx defines {orphans.Count} key(s) the neutral resx does not: {string.Join(", ", orphans)}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TranslatedCultures))]
|
||||
public void Placeholders_survive_translation(string culture)
|
||||
{
|
||||
var translated = ResourcesFor(CultureInfo.GetCultureInfo(culture));
|
||||
|
||||
// Loc.F feeds these to string.Format, so a placeholder dropped or invented in translation is
|
||||
// a FormatException or a silently missing number at runtime, in that language only.
|
||||
var broken = new List<string>();
|
||||
foreach (var (key, english) in Neutral)
|
||||
{
|
||||
if (!translated.TryGetValue(key, out var other))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var expected = PlaceholderIndexes(english);
|
||||
var actual = PlaceholderIndexes(other);
|
||||
if (!expected.SetEquals(actual))
|
||||
{
|
||||
broken.Add($"{key} (en: {{{string.Join(",", expected.Order())}}}, {culture}: {{{string.Join(",", actual.Order())}}})");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(broken.Count == 0, $"Placeholder mismatch in Strings.{culture}.resx: {string.Join("; ", broken)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Supported_cultures_all_resolve()
|
||||
{
|
||||
foreach (var culture in Loc.SupportedCultures)
|
||||
{
|
||||
Assert.True(Loc.TryResolve(culture, out var resolved));
|
||||
Assert.Equal(culture, resolved);
|
||||
|
||||
// The picker labels itself with these, so an unnamed culture would render blank.
|
||||
Assert.False(string.IsNullOrWhiteSpace(Loc.DisplayName(culture)));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("de-DE", "de")]
|
||||
[InlineData("de-AT", "de")]
|
||||
[InlineData("de_CH", "de")]
|
||||
[InlineData("EN-gb", "en")]
|
||||
public void Regional_variants_resolve_to_the_language_we_ship(string requested, string expected)
|
||||
{
|
||||
Assert.True(Loc.TryResolve(requested, out var resolved));
|
||||
Assert.Equal(expected, resolved);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("fr")]
|
||||
[InlineData("klingon")]
|
||||
public void Untranslated_languages_fall_back_to_the_neutral_culture(string? requested)
|
||||
{
|
||||
Assert.False(Loc.TryResolve(requested, out var resolved));
|
||||
Assert.Equal(Loc.SupportedCultures[0], resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_string_is_defined_but_never_used()
|
||||
{
|
||||
// The compiler catches the other direction — S.Foo against a deleted key is a build error —
|
||||
// but a key nothing references compiles perfectly and quietly costs a translator work on
|
||||
// every language we ever add. Enum_* is exempt: those are reached by name from
|
||||
// DisplayNames' switch arms, which EnumDisplayNameTests covers instead.
|
||||
var root = FindRepositoryRoot();
|
||||
if (root is null)
|
||||
{
|
||||
return; // Running detached from the source tree; the other tests still cover the catalogue.
|
||||
}
|
||||
|
||||
var sources = new[] { Path.Combine(root, "src") }
|
||||
.SelectMany(dir => Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories))
|
||||
.Where(f => (f.EndsWith(".cs", StringComparison.Ordinal) || f.EndsWith(".razor", StringComparison.Ordinal))
|
||||
&& !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
|
||||
.Select(File.ReadAllText)
|
||||
.ToList();
|
||||
|
||||
Assert.NotEmpty(sources);
|
||||
var all = string.Join('\n', sources);
|
||||
|
||||
var unused = Neutral.Keys
|
||||
.Where(k => !k.StartsWith("Enum_", StringComparison.Ordinal))
|
||||
.Where(k => !Regex.IsMatch(all, $@"\b{Regex.Escape(k)}\b"))
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
Assert.True(unused.Count == 0, $"{unused.Count} unused string(s): {string.Join(", ", unused)}");
|
||||
}
|
||||
|
||||
/// <summary>Walks up from the test binaries to the checkout, identified by the solution file.</summary>
|
||||
private static string? FindRepositoryRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeterVault.slnx")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
return dir?.FullName;
|
||||
}
|
||||
|
||||
/// <summary>Every shipped language except the neutral one, which is the baseline being compared against.</summary>
|
||||
public static TheoryData<string> TranslatedCultures()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
foreach (var culture in Loc.SupportedCultures.Skip(1))
|
||||
{
|
||||
data.Add(culture);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private static HashSet<int> PlaceholderIndexes(string value) =>
|
||||
[.. PlaceholderPattern.Matches(value).Select(m => int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture))];
|
||||
|
||||
/// <summary>
|
||||
/// The entries one culture's resource set actually defines. <c>tryParents: false</c> is the
|
||||
/// whole point: with fallback on, a missing German string is indistinguishable from a present one.
|
||||
/// </summary>
|
||||
private static IReadOnlyDictionary<string, string> ResourcesFor(CultureInfo culture)
|
||||
{
|
||||
var set = Strings.ResourceManager.GetResourceSet(culture, createIfNotExists: true, tryParents: false);
|
||||
var entries = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (set is null)
|
||||
{
|
||||
return entries;
|
||||
}
|
||||
|
||||
foreach (DictionaryEntry entry in set)
|
||||
{
|
||||
if (entry.Key is string key && entry.Value is string value)
|
||||
{
|
||||
entries[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ public sealed class MeterPeriodServiceTests(TimescaleFixture fx)
|
||||
var view = await NewService().GetAsync(meterId);
|
||||
|
||||
Assert.NotNull(view);
|
||||
Assert.Equal("Consumption", view!.Label);
|
||||
Assert.Equal(ConsumptionKind.Consumption, view!.Kind);
|
||||
Assert.Equal(30d, view.MonthToDate, 3);
|
||||
Assert.Equal(100d, view.LastMonth, 3);
|
||||
Assert.Equal(130d, view.YearToDate, 3);
|
||||
@@ -55,7 +55,7 @@ public sealed class MeterPeriodServiceTests(TimescaleFixture fx)
|
||||
var view = await NewService().GetAsync(meterId);
|
||||
|
||||
Assert.NotNull(view);
|
||||
Assert.Equal("Generation", view!.Label);
|
||||
Assert.Equal(ConsumptionKind.Generation, view!.Kind);
|
||||
Assert.Equal(42d, view.MonthToDate, 3);
|
||||
|
||||
await CleanupAsync(db, meterId);
|
||||
|
||||
@@ -11,8 +11,16 @@ public sealed class MeterVaultAppFactory(string connectionString, bool configure
|
||||
{
|
||||
public const string ApiKey = "test-api-key";
|
||||
|
||||
/// <summary>Overrides <c>MeterVault__Locale</c>, the instance's default UI language.</summary>
|
||||
public string? Locale { get; init; }
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
if (Locale is not null)
|
||||
{
|
||||
builder.UseSetting("MeterVault:Locale", Locale);
|
||||
}
|
||||
|
||||
builder.UseEnvironment("Testing");
|
||||
builder.UseSetting("ConnectionStrings:Default", connectionString);
|
||||
builder.UseSetting("MeterVault:RunMigrationsAtStartup", "false");
|
||||
|
||||
Reference in New Issue
Block a user