Files
MeterVault/src/App/Program.cs
T
Florian Schmidt bfa0b537ee
ci / build-test (push) Failing after 35s
i18n: ship the UI in English and German
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
2026-08-13 16:36:25 +02:00

176 lines
7.0 KiB
C#

using MeterVault.App.Api;
using MeterVault.App.Components;
using MeterVault.App.Localization;
using MeterVault.Infrastructure;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
using Serilog;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.WriteTo.Console());
builder.Services.Configure<MeterVaultOptions>(
builder.Configuration.GetSection(MeterVaultOptions.SectionName));
var connectionString = builder.Configuration.GetConnectionString("Default")
?? "Host=localhost;Port=5432;Database=metervault;Username=metervault;Password=metervault";
builder.Services.AddMeterVaultInfrastructure(connectionString);
// Key ring for connector secrets typed into the admin UI. It must outlive the app directory:
// the LXC updater republishes /opt/metervault on every update, so keys stored beside the
// binaries would be destroyed and every saved token would need re-entering. Override with
// MeterVault__DataProtectionKeyPath (Docker: point it at a mounted volume).
var keyPath = builder.Configuration["MeterVault:DataProtectionKeyPath"];
if (string.IsNullOrWhiteSpace(keyPath))
{
keyPath = OperatingSystem.IsWindows()
? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MeterVault", "keys")
: "/var/lib/metervault/keys";
}
try
{
Directory.CreateDirectory(keyPath);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Falling back beats refusing to boot, but say so plainly: on the fallback path an update
// that replaces the content root loses the keys, and stored secrets stop decrypting.
var fallback = Path.Combine(builder.Environment.ContentRootPath, "keys");
Log.Warning(ex,
"Cannot create data-protection key ring at {KeyPath}; falling back to {Fallback}. "
+ "Secrets entered in the admin UI will not survive a redeploy that replaces the content "
+ "root — set MeterVault__DataProtectionKeyPath to a writable persistent directory",
keyPath, fallback);
keyPath = fallback;
Directory.CreateDirectory(keyPath);
}
builder.Services.AddDataProtection()
.SetApplicationName("MeterVault")
.PersistKeysToFileSystem(new DirectoryInfo(keyPath));
var options = builder.Configuration.GetSection(MeterVaultOptions.SectionName).Get<MeterVaultOptions>()
?? new MeterVaultOptions();
if (options.EnableLiveIngestion)
{
builder.Services.AddMeterVaultIngestion();
}
builder.Services.AddLocalization();
builder.Services.AddMudServices();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// Serialize/accept enums as strings on the REST API (e.g. event Type "Delivery").
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
c.SwaggerDoc("v1", new() { Title = "MeterVault API", Version = "v1" }));
var app = builder.Build();
await MigrateDatabaseAsync(app).ConfigureAwait(false);
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseSerilogRequestLogging();
app.UseReverseProxyTrust();
// No HTTPS redirection: the app serves plain HTTP (port 8760) behind a reverse proxy
// 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"));
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.MapMeterVaultApi();
app.MapCultureEndpoints();
// Liveness/readiness probe for Gatus/Compose healthchecks (SDD §9).
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
await app.RunAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Log.Fatal(ex, "MeterVault terminated unexpectedly");
throw;
}
finally
{
await Log.CloseAndFlushAsync().ConfigureAwait(false);
}
static async Task MigrateDatabaseAsync(WebApplication app)
{
var options = app.Configuration
.GetSection(MeterVaultOptions.SectionName)
.Get<MeterVaultOptions>() ?? new MeterVaultOptions();
if (!options.RunMigrationsAtStartup)
{
return;
}
await using var scope = app.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
await db.Database.MigrateAsync().ConfigureAwait(false);
await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false);
Log.Information("Database migrations applied and defaults seeded");
if (options.SeedReferenceData)
{
var importer = scope.ServiceProvider.GetRequiredService<MeterVault.Infrastructure.Import.ReferenceDataImporter>();
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
await importer.LoadAsync(dir).ConfigureAwait(false);
Log.Information("Reference dataset ensured (SeedReferenceData=true)");
}
}
/// <summary>Exposed for WebApplicationFactory-based integration tests.</summary>
public partial class Program;