M7: release polish — export/import, docs, easy docker push
- Easy docker push (MQTTower ergonomics + the image push it lacks): VERSION file → version-tag.yml (semver-guard auto-tag) → docker-publish.yml (buildx multi-arch → GHCR, registry centralized for one-line retarget to git.finalfactory.de) + ci.yml + build-and-push.ps1. - Verified end to end: deploy/Dockerfile builds; docker compose stack (app + timescaledb) comes up healthy; /healthz and the dashboard respond in-container. - JSON config export/import (ExportService) with id remapping on restore + GET /export, POST /import endpoints; round-trip test preserves meter→type, meter-scoped tariff, category links. - README, HA/Tasmota/MQTT wiring guide (docs/wiring.md), Unraid template. - CLAUDE.md updated to reflect the built codebase. - i18n: locale-aware number/currency formatting (de-DE); full de UI string localization deferred. 95 tests green (56 Core + 39 integration). Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
@@ -112,6 +112,17 @@ public static class ApiEndpoints
|
||||
.Select(s => new { s.Id, s.MeterId, Type = s.SourceType.ToString(), s.IsEnabled, s.LastSeenAt, s.LastValue, s.LastStatus })
|
||||
.ToListAsync(ct)));
|
||||
|
||||
api.MapGet("/export", async (MeterVault.Infrastructure.Backup.ExportService export, CancellationToken ct) =>
|
||||
Results.Text(await export.ExportJsonAsync(ct), "application/json"))
|
||||
.WithSummary("Export configuration + hand-entered data as portable JSON (SDD §10).");
|
||||
|
||||
api.MapPost("/import", async (HttpRequest request, MeterVault.Infrastructure.Backup.ExportService export, CancellationToken ct) =>
|
||||
{
|
||||
using var reader = new StreamReader(request.Body);
|
||||
await export.ImportJsonAsync(await reader.ReadToEndAsync(ct), ct);
|
||||
return Results.Ok();
|
||||
}).WithSummary("Restore a JSON export into an empty instance (remaps ids).");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using MeterVault.Core.Domain;
|
||||
|
||||
namespace MeterVault.Infrastructure.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// A portable JSON snapshot of MeterVault's configuration and hand-entered data (SDD §10). Covers
|
||||
/// everything except the bulk time-series (raw readings / consumption), which is restored from the
|
||||
/// live sources or a database dump — this keeps the export small and human-readable.
|
||||
/// </summary>
|
||||
public sealed class ExportDocument
|
||||
{
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
|
||||
public List<EnergyType> EnergyTypes { get; set; } = [];
|
||||
public List<CostCategory> CostCategories { get; set; } = [];
|
||||
public List<Meter> Meters { get; set; } = [];
|
||||
public List<IngestionEndpoint> IngestionEndpoints { get; set; } = [];
|
||||
public List<MeterSource> MeterSources { get; set; } = [];
|
||||
public List<Tank> Tanks { get; set; } = [];
|
||||
public List<Tariff> Tariffs { get; set; } = [];
|
||||
public List<CostCategoryMember> CostCategoryMembers { get; set; } = [];
|
||||
public List<ManualCost> ManualCosts { get; set; } = [];
|
||||
public List<MeterEvent> MeterEvents { get; set; } = [];
|
||||
public List<AppSetting> AppSettings { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Text.Json;
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeterVault.Infrastructure.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Exports/imports the configuration snapshot (SDD §10). Import restores into an empty instance,
|
||||
/// remapping surrogate ids so foreign keys stay consistent regardless of the original ids.
|
||||
/// </summary>
|
||||
public sealed class ExportService(MeterVaultDbContext db)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles,
|
||||
};
|
||||
|
||||
private readonly MeterVaultDbContext _db = db;
|
||||
|
||||
public async Task<string> ExportJsonAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Load without navigation includes so serialization is a clean, cycle-free tree.
|
||||
var document = new ExportDocument
|
||||
{
|
||||
EnergyTypes = await _db.EnergyTypes.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||
CostCategories = await _db.CostCategories.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||
Meters = await _db.Meters.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||
IngestionEndpoints = await _db.IngestionEndpoints.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||
MeterSources = await _db.MeterSources.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||
Tanks = await _db.Tanks.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||
Tariffs = await _db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||
CostCategoryMembers = await _db.CostCategoryMembers.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||
ManualCosts = await _db.ManualCosts.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||
MeterEvents = await _db.MeterEvents.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||
AppSettings = await _db.AppSettings.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(document, Json);
|
||||
}
|
||||
|
||||
public async Task ImportJsonAsync(string json, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = JsonSerializer.Deserialize<ExportDocument>(json, Json)
|
||||
?? throw new InvalidOperationException("Empty or invalid export document.");
|
||||
|
||||
await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var typeMap = new Dictionary<short, short>();
|
||||
foreach (var type in doc.EnergyTypes)
|
||||
{
|
||||
var old = type.Id;
|
||||
type.Id = 0;
|
||||
_db.EnergyTypes.Add(type);
|
||||
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
typeMap[old] = type.Id;
|
||||
}
|
||||
|
||||
var categoryMap = await InsertMappedAsync(doc.CostCategories, c => c.Id, (c, _) => c.Id = 0, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var meterMap = new Dictionary<int, int>();
|
||||
foreach (var meter in doc.Meters)
|
||||
{
|
||||
var old = meter.Id;
|
||||
meter.Id = 0;
|
||||
meter.EnergyTypeId = typeMap.GetValueOrDefault(meter.EnergyTypeId, meter.EnergyTypeId);
|
||||
meter.EnergyType = null;
|
||||
_db.Meters.Add(meter);
|
||||
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
meterMap[old] = meter.Id;
|
||||
}
|
||||
|
||||
var endpointMap = await InsertMappedAsync(doc.IngestionEndpoints, e => e.Id, (e, _) => e.Id = 0, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var source in doc.MeterSources)
|
||||
{
|
||||
source.Id = 0;
|
||||
source.MeterId = meterMap.GetValueOrDefault(source.MeterId, source.MeterId);
|
||||
source.EndpointId = source.EndpointId is { } eid ? endpointMap.GetValueOrDefault(eid, eid) : null;
|
||||
source.Meter = null;
|
||||
source.Endpoint = null;
|
||||
_db.MeterSources.Add(source);
|
||||
}
|
||||
|
||||
foreach (var tank in doc.Tanks)
|
||||
{
|
||||
tank.Id = 0;
|
||||
tank.MeterId = meterMap.GetValueOrDefault(tank.MeterId, tank.MeterId);
|
||||
tank.Meter = null;
|
||||
_db.Tanks.Add(tank);
|
||||
}
|
||||
|
||||
foreach (var tariff in doc.Tariffs)
|
||||
{
|
||||
tariff.Id = 0;
|
||||
tariff.ScopeId = tariff.ScopeType switch
|
||||
{
|
||||
TariffScope.Meter when tariff.ScopeId is { } id => meterMap.GetValueOrDefault(id, id),
|
||||
TariffScope.EnergyType when tariff.ScopeId is { } id => typeMap.GetValueOrDefault((short)id, (short)id),
|
||||
_ => tariff.ScopeId,
|
||||
};
|
||||
_db.Tariffs.Add(tariff);
|
||||
}
|
||||
|
||||
foreach (var member in doc.CostCategoryMembers)
|
||||
{
|
||||
member.Id = 0;
|
||||
member.CategoryId = categoryMap.GetValueOrDefault(member.CategoryId, member.CategoryId);
|
||||
member.MeterId = member.MeterId is { } mid ? meterMap.GetValueOrDefault(mid, mid) : null;
|
||||
member.EnergyTypeId = member.EnergyTypeId is { } tid ? typeMap.GetValueOrDefault(tid, tid) : null;
|
||||
member.Category = null;
|
||||
_db.CostCategoryMembers.Add(member);
|
||||
}
|
||||
|
||||
foreach (var cost in doc.ManualCosts)
|
||||
{
|
||||
cost.Id = 0;
|
||||
cost.CategoryId = cost.CategoryId is { } cid ? categoryMap.GetValueOrDefault(cid, cid) : null;
|
||||
cost.MeterId = cost.MeterId is { } mid ? meterMap.GetValueOrDefault(mid, mid) : null;
|
||||
_db.ManualCosts.Add(cost);
|
||||
}
|
||||
|
||||
foreach (var meterEvent in doc.MeterEvents)
|
||||
{
|
||||
meterEvent.Id = 0;
|
||||
meterEvent.MeterId = meterMap.GetValueOrDefault(meterEvent.MeterId, meterEvent.MeterId);
|
||||
_db.MeterEvents.Add(meterEvent);
|
||||
}
|
||||
|
||||
foreach (var setting in doc.AppSettings)
|
||||
{
|
||||
if (!await _db.AppSettings.AnyAsync(s => s.Key == setting.Key, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
_db.AppSettings.Add(setting);
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<int, int>> InsertMappedAsync<T>(
|
||||
List<T> items, Func<T, int> getId, Action<T, int> clearId, CancellationToken cancellationToken)
|
||||
where T : class
|
||||
{
|
||||
var map = new Dictionary<int, int>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var old = getId(item);
|
||||
clearId(item, 0);
|
||||
_db.Add(item);
|
||||
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
map[old] = getId(item);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<MqttMessageRouter>();
|
||||
services.AddScoped<Costing.CostService>();
|
||||
services.AddScoped<Dashboard.DashboardService>();
|
||||
services.AddScoped<Backup.ExportService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user