Tau.Acuvim/portal/grafana/dashboards/power-overview.json
Diseri Pearson e17921a122 Add portal: customer-facing white-labeled monitoring stack
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>
2026-05-18 09:30:30 +02:00

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": ""
}