New top-level portal/ project, peer to console/ and firmware/. Delivers a .NET 10 + React 18 + TimescaleDB + Grafana stack, one container set per customer behind Traefik. Built in 12 phases per FrontEndPrompt spec; no changes to existing console or firmware. Backend (src/Tau.Acuvim.Portal/): - .NET 10 minimal API, Serilog, ASP.NET Identity (cookie auth, lockout). - Single AppDbContext with identity / app / monitoring schemas. - MigrateAsync + TimescaleBootstrapper (idempotent hypertable creation) + IdentityBootstrapper (seeded admin + branding) on startup. - Pure CostCalculator + DB-backed RateService for tariffs (effective-dated, TOU periods, VAT, fixed charges, per-municipality timezone). - BrandingService with logo upload to mounted volume. - Time-series ingest + bucketed query services (time_bucket aggregates, ON CONFLICT for idempotent re-delivery). - ConfigOverviewService with redaction-by-construction (passwords never in payload). - DataProtection keys persisted to /data/keys volume for cookie survival across container restarts. Frontend (frontend/): - React 18 + TypeScript + Vite + Ant Design 5 + TanStack Query. - BrandingProvider + ThemedRoot for live re-themed white-labelling. - RequireAuth / RequireRole guards. - Pages: Login, Dashboard, Dashboards (embedded Grafana), Sites (admin), Settings tabs (Branding / Rates / Users / Grafana / App config). Infra: - Dev (docker-compose.yml) and prod (docker-compose.prod.yml) compose files. Three services per customer; Traefik subdomain + same-origin /grafana path-prefix routing wired with labels. - Grafana 11 with provisioned timescaledb datasource (uid pinned) and starter power-overview.json dashboard with device template variable. - Compose project name documented as lowercase (Compose v2 requirement). Tests (tests/Tau.Acuvim.Portal.Tests/): - xUnit, 40 tests. Covers CostCalculator (period match, TZ, overlap, VAT, fixed), ConnectionStringResolver (all 4 precedence branches incl. Production refusal), TariffValidator, DayOfWeekFlag. - All passing locally against .NET 10. Docs: - README.md (onboarding + 11 spec sections), OPERATIONS.md (per-customer provisioning, secret rotation, backup, troubleshooting), TESTING.md (manual integration scenarios, frontend test scaffolding recipe). Production safety guards: - Refuses to start if Authentication:DefaultAdminPassword is unchanged default in Production. - Refuses to start if Database:AutoProvisionLocalTimescaleDb=true in Production. - Prod Grafana ships with anonymous off and auth mode unset (three options documented in README Security) so iframe refuses to load until a deliberate prod auth choice is made. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
151 lines
4.5 KiB
JSON
151 lines
4.5 KiB
JSON
{
|
|
"annotations": {
|
|
"list": [
|
|
{
|
|
"builtIn": 1,
|
|
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
|
|
"enable": true,
|
|
"hide": true,
|
|
"iconColor": "rgba(0, 211, 255, 1)",
|
|
"name": "Annotations & Alerts",
|
|
"type": "dashboard"
|
|
}
|
|
]
|
|
},
|
|
"editable": true,
|
|
"fiscalYearStartMonth": 0,
|
|
"graphTooltip": 0,
|
|
"id": null,
|
|
"links": [],
|
|
"liveNow": false,
|
|
"panels": [
|
|
{
|
|
"id": 1,
|
|
"type": "timeseries",
|
|
"title": "Active power (kW)",
|
|
"datasource": { "type": "postgres", "uid": "timescaledb" },
|
|
"gridPos": { "x": 0, "y": 0, "w": 24, "h": 9 },
|
|
"fieldConfig": {
|
|
"defaults": {
|
|
"unit": "kwatt",
|
|
"color": { "mode": "palette-classic" },
|
|
"custom": {
|
|
"drawStyle": "line",
|
|
"lineWidth": 2,
|
|
"fillOpacity": 10,
|
|
"showPoints": "never",
|
|
"spanNulls": false
|
|
}
|
|
},
|
|
"overrides": []
|
|
},
|
|
"options": {
|
|
"tooltip": { "mode": "multi", "sort": "none" },
|
|
"legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }
|
|
},
|
|
"targets": [
|
|
{
|
|
"refId": "A",
|
|
"datasource": { "type": "postgres", "uid": "timescaledb" },
|
|
"format": "time_series",
|
|
"rawSql": "SELECT time_bucket($__interval, \"Time\") AS time, avg(\"ActivePowerKw\") AS \"kW\" FROM monitoring.\"PowerMeasurements\" WHERE \"DeviceId\" = '${device}' AND $__timeFilter(\"Time\") GROUP BY 1 ORDER BY 1"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": 2,
|
|
"type": "timeseries",
|
|
"title": "Cumulative energy imported (kWh)",
|
|
"datasource": { "type": "postgres", "uid": "timescaledb" },
|
|
"gridPos": { "x": 0, "y": 9, "w": 12, "h": 9 },
|
|
"fieldConfig": {
|
|
"defaults": {
|
|
"unit": "kwatth",
|
|
"color": { "mode": "palette-classic" },
|
|
"custom": {
|
|
"drawStyle": "line",
|
|
"lineWidth": 2,
|
|
"fillOpacity": 5,
|
|
"showPoints": "never"
|
|
}
|
|
},
|
|
"overrides": []
|
|
},
|
|
"options": {
|
|
"tooltip": { "mode": "single", "sort": "none" },
|
|
"legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }
|
|
},
|
|
"targets": [
|
|
{
|
|
"refId": "A",
|
|
"datasource": { "type": "postgres", "uid": "timescaledb" },
|
|
"format": "time_series",
|
|
"rawSql": "SELECT \"Time\" AS time, \"EnergyImportedKwh\" AS \"kWh imported\" FROM monitoring.\"PowerMeasurements\" WHERE \"DeviceId\" = '${device}' AND $__timeFilter(\"Time\") AND \"EnergyImportedKwh\" IS NOT NULL ORDER BY \"Time\""
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": 3,
|
|
"type": "stat",
|
|
"title": "Latest active power",
|
|
"datasource": { "type": "postgres", "uid": "timescaledb" },
|
|
"gridPos": { "x": 12, "y": 9, "w": 12, "h": 9 },
|
|
"fieldConfig": {
|
|
"defaults": {
|
|
"unit": "kwatt",
|
|
"color": { "mode": "thresholds" },
|
|
"thresholds": {
|
|
"mode": "absolute",
|
|
"steps": [
|
|
{ "color": "green", "value": null },
|
|
{ "color": "orange", "value": 50 },
|
|
{ "color": "red", "value": 100 }
|
|
]
|
|
}
|
|
},
|
|
"overrides": []
|
|
},
|
|
"options": {
|
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
|
"textMode": "auto",
|
|
"colorMode": "value",
|
|
"graphMode": "area",
|
|
"justifyMode": "auto"
|
|
},
|
|
"targets": [
|
|
{
|
|
"refId": "A",
|
|
"datasource": { "type": "postgres", "uid": "timescaledb" },
|
|
"format": "time_series",
|
|
"rawSql": "SELECT \"Time\" AS time, \"ActivePowerKw\" AS \"kW\" FROM monitoring.\"PowerMeasurements\" WHERE \"DeviceId\" = '${device}' ORDER BY \"Time\" DESC LIMIT 1"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"refresh": "30s",
|
|
"schemaVersion": 38,
|
|
"tags": ["power"],
|
|
"templating": {
|
|
"list": [
|
|
{
|
|
"name": "device",
|
|
"label": "Device",
|
|
"type": "query",
|
|
"datasource": { "type": "postgres", "uid": "timescaledb" },
|
|
"query": "SELECT \"Name\" AS __text, \"Id\"::text AS __value FROM monitoring.\"Devices\" WHERE \"IsActive\" = true ORDER BY \"Name\"",
|
|
"refresh": 1,
|
|
"multi": false,
|
|
"includeAll": false,
|
|
"current": {}
|
|
}
|
|
]
|
|
},
|
|
"time": { "from": "now-24h", "to": "now" },
|
|
"timepicker": {},
|
|
"timezone": "",
|
|
"title": "Power Overview",
|
|
"uid": "power-overview",
|
|
"version": 1,
|
|
"weekStart": ""
|
|
}
|