A bundle of related portal work — picked up while ensuring per-customer
isolation actually works end-to-end and replacing the placeholder Client
landing page. Build green, full test suite 66/66.
Frontend — Client surface
- DashboardPage: replace placeholder with 4 KPI cards (kWh, current kW,
active devices, estimated cost), 24h active-power ECharts mini-chart,
per-device "today/range" table, and a date-range picker with shortcuts
(Today / 7d / 30d / This month / Custom). 30s auto-refresh.
- New Measurements page (/measurements, Client mode, any authenticated
user) with multi-select device filter, full date range incl. an
"All time" shortcut, server-paginated preview, and Excel export.
- "Export to Excel" buttons on: Client Dashboard summary, Client Dashboard
raw measurements, Admin fleet dashboard, Admin customer-detail Cost tab.
- DashboardsPage sidebar items: let the menu item grow and reset
line-height so the two-line title+description doesn't crush.
Frontend — Admin / user mgmt
- RestrictedAdmin role: admin who only sees their assigned customers.
New UserFormDrawer choice + CustomerAccessModal for granting/revoking
per-customer access; surfaced from the Users page.
Backend
- ClosedXML 0.104.2 + ExcelExportService (pure formatter; frozen header,
currency/kWh/kW/date number formats, AdjustToContents).
- DashboardSummaryService computes per-device totals + estimated cost
(hourly bucketing × site's municipality's active tariff, mirroring
FleetCostService for the Admin side).
- New endpoints:
GET /api/dashboard/summary[+/export.xlsx]
GET /api/measurements/raw[+/export.xlsx] (deviceIds, paginated)
GET /api/sites/devices (flat list w/ site name)
GET /api/fleet/dashboard/export.xlsx
GET /api/fleet/customers/{id}/cost/export.xlsx
GET /api/auth/check (cookie-only liveness)
- AdminCustomerAccess: per-user customer scoping for RestrictedAdmin via
Postgres-row-level filter — RlsContext (per-DI-scope state) +
CustomerFilterMiddleware (populates from claims after auth) +
fleet.* DbSets gain HasQueryFilter expressions. Bootstrappers
Elevate() to bypass the filter for trusted system code.
- Migration: 20260518095759_AddAdminCustomerAccess (mapping table,
composite PK on UserId+CustomerId).
Infra / templating (the "spin it up via the template" piece)
- docker-compose.prod.yml + docker-compose.yml: pass WhiteLabel__*,
Application__RunMode, FleetIngest__* through to the container as
${VAR:-default} substitutions. Previously these were silently dropped
in prod — a customer's .env settings for branding/fleet-push never
reached the running process. Latent bug, fixed.
- docker-compose.prod.yml: forwardAuth middleware labels on the
Grafana router pointing at /api/auth/check. Option (a) from the
README's three prod-auth modes — every Grafana request now gates on
a valid portal cookie. Anonymous stays off.
- .env.example rewritten with a Client section, optional FleetIngest
block, and an Admin variant block — annotated on what's required vs.
optional and where the seed-only-on-first-boot caveat applies.
- README "Grafana embedding" table: option (a) now marked active with
an inline note on how to switch modes later.
- OPERATIONS.md step 3 includes the white-label pre-brand .env snippet;
step 4 (formerly "decide Grafana auth mode") updated to reflect
that auth is wired by default.
Tests
- New BrandingSeedFromOptionsTests (5 tests) pins the env-var → IOptions
→ DB seed contract: first read seeds from options; subsequent reads
return the DB row (UI edits survive restarts); EnsureSeededAsync is
idempotent; UpdateAsync falls back to options for blanked fields.
- CustomerTokenGraceTests helper: pass the new RlsContext to
AdminDbContext (SetAll() so existing semantics hold).
Verified end-to-end
- Real Docker spin-up with WhiteLabel__* in a throwaway .env →
/api/branding returned all six fields verbatim (ApplicationName,
LogoUrl, three colors, FooterText).
- curl login → /api/dashboard/summary returned valid JSON →
/api/dashboard/summary/export.xlsx returned a 6.9 KB file the
`file` command identifies as "Microsoft Excel 2007+".
- /api/measurements/raw with and without deviceIds filter returned
correct paginated rows; /export.xlsx with filter produced a valid
7.1 KB xlsx with the meter count in the filename.
- Frontend tsc -b clean; backend dotnet build 0/0; xunit 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 KiB
Tau Acuvim Portal
Customer-facing, white-labeled power monitoring portal. One stack per customer, deployed behind Traefik with the customer ID (lowercased — Docker Compose v2 requirement) as the container prefix: customer ABC0001 produces abc0001_portal, abc0001_grafana, abc0001_timescale.
This project lives next to console/ (internal management interface) and firmware/ (ESP32) in the same repo. The three projects share no code; the portal stands alone.
Contents
- Overview
- Architecture
- Configuration template
- Local setup
- Docker Compose
- Database migrations
- Accessing the app
- Accessing Grafana
- Default local test credentials
- Production deployment notes
- Security notes
- Testing
- Operations
Overview
A customer signs in to their own branded portal, sees their meters' live + historical power readings via embedded Grafana dashboards, and (for admins) configures branding, municipality tariffs, users, sites, and devices. Each customer gets a fully isolated stack: their own database, their own Grafana, their own branding. Traefik routes <customer>.portal.example.com to the right containers.
Tech stack
| Layer | Technology |
|---|---|
| Run modes | RunMode=Client (per-customer, default) or RunMode=Admin (fleet aggregation) — same binary, config-selected. See docs/FLEET-DESIGN.md. |
| Backend | .NET 10 minimal API, EF Core 10, Npgsql, ASP.NET Core Identity, Serilog |
| Frontend | React 18 + TypeScript + Vite, Ant Design 5, TanStack Query, react-router |
| Database | TimescaleDB 2.17 on PostgreSQL 16 |
| Graphing | Grafana 11 (provisioned datasource + dashboards) |
| Container | Docker / Docker Compose; Traefik for routing |
| Auth | Cookie-based via ASP.NET Identity (SPA-friendly, 401/403 not redirects) |
Architecture
Containers, per customer
┌────────────────────────────────────────────────────┐
│ Traefik │
│ host: <customer>.portal.example.com │
└────────────────────────────────────────────────────┘
┌─────────────────────────┬─────────────────────────┐
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ <PREFIX>_portal │ │ <PREFIX>_grafana │ │<PREFIX>_timescale │
│ .NET API + SPA │──▶│ Grafana 11 │──▶│ TimescaleDB + Pg16│
│ :8080 │ │ :3000 │ │ :5432 │
└───────────────────┘ └───────────────────┘ └───────────────────┘
<PREFIX> = the customer's 7-digit ID lowercased (e.g. abc0001 for customer ABC0001), set via COMPOSE_PROJECT_NAME. Compose v2 rejects uppercase project names.
Backend layout
- Combined container — Dockerfile builds the React SPA, then a multi-stage .NET build, then copies the SPA into
wwwroot. One image, one process per customer. - Single
AppDbContextwith three schemas:identity— ASP.NET Identity tables.app— branding, municipalities, tariffs, periods.monitoring— sites, devices, power measurements (hypertable).
- Minimal API with endpoints grouped under
Endpoints/*.cs. Services inServices/*.cs. Typed options inConfiguration/.
Frontend layout
src/pages/— page-level components (DashboardsPage,SettingsPage, etc.).src/components/— shared + feature components.src/api/— typed axios calls.src/hooks/useAuth,useBranding— global state via Context.RequireAuth/RequireRole— route guards.- AntD
ConfigProvideris themed dynamically fromBrandingProvider(white-labelling).
Configuration template
All configurable values are declared in src/Tau.Acuvim.Portal/appsettings.template.json — checked in, no secrets. It's the lowest-priority configuration source: everything overrides it.
Precedence (lowest → highest)
appsettings.template.json— shippable defaults (always loaded).appsettings.json— runtime infra (Serilog, AllowedHosts).appsettings.{Environment}.json— per-environment overrides.appsettings.Local.json— gitignored, your local overrides.- Environment variables — use
__as section separator (e.g.Authentication__DefaultAdminPassword=...). Production secrets go here.
Sections
| Section | Purpose |
|---|---|
Application |
Name, environment, public URL |
Database |
Provider, connection string, MigrateOnStartup, AutoProvisionLocalTimescaleDb |
TimescaleDb |
Host/port/db/user/password for auto-provision in dev |
Grafana |
Base URL, internal URL, path prefix, embed mode, dashboard list |
WhiteLabel |
App name, logo URL, colours, footer, logo storage path |
Authentication |
Cookie name, lockout, default admin email/password |
Monitoring |
Hypertable chunk interval, aggregate flag |
Database connection resolution
- If
Database:ConnectionStringis non-empty → use it. - Else if
Database:AutoProvisionLocalTimescaleDb=trueAND env is notProduction→ build from theTimescaleDb:*block (Host/Port/Database/Username/Password). - Otherwise → app refuses to start with a clear error.
AutoProvisionLocalTimescaleDb=true in Production is a hard failure — production must supply its own connection string via env var or secret.
Local setup
Prerequisites
- .NET 10 SDK
- Node 22+ / npm
- Docker Desktop
dotnet-eftool:dotnet tool install --global dotnet-ef
First-time: generate the initial migrations
Two DbContext classes — one per RunMode — each with its own migration folder.
cd C:\AcuvimDev\Tau.Acuvim\portal
# Client (RunMode=Client, default) — identity + branding + monitoring + rates
dotnet ef migrations add InitialCreate `
--context AppDbContext `
--project src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj `
--output-dir Migrations
# Admin (RunMode=Admin) — identity + branding + fleet
$env:Application__RunMode='Admin'
$env:Database__ConnectionString='Host=localhost;Database=stub;Username=u;Password=p' # parsed only
dotnet ef migrations add InitialFleet `
--context AdminDbContext `
--project src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj `
--output-dir Migrations/Admin
Remove-Item Env:Application__RunMode
Remove-Item Env:Database__ConnectionString
Commit both Migrations/ and Migrations/Admin/. MigrateAsync on startup applies whatever exists for the active context.
Option A: full stack in Docker (recommended)
cd C:\AcuvimDev\Tau.Acuvim\portal
Copy-Item .env.example .env
docker compose up --build -d
docker compose ps
Then:
- Portal: http://localhost:8080
- Grafana: http://localhost:3001 (anonymous Viewer in dev)
- TimescaleDB:
localhost:5433(user/db from.env)
Stop + wipe:
docker compose down -v
Option B: backend + DB in Docker, frontend via Vite
Same docker compose up, then in another terminal:
cd C:\AcuvimDev\Tau.Acuvim\portal\frontend
npm install
npm run dev
Vite serves at http://localhost:5174 and proxies /api + /health to the .NET container on :8080.
Option C: everything local (no Docker for the app)
Postgres still needs Docker (or a local install):
docker compose up -d timescaledb
cd C:\AcuvimDev\Tau.Acuvim\portal\src\Tau.Acuvim.Portal
dotnet run # listens on :8080
# in another terminal
cd C:\AcuvimDev\Tau.Acuvim\portal\frontend
npm run dev # :5174
Docker Compose
Dev — docker-compose.yml
Three services: portal, timescaledb, grafana. Persistent named volumes (timescale-data, grafana-data, portal-keys, portal-branding). Healthcheck on Postgres; portal waits for healthy. Grafana ships with anonymous Viewer for easy local access; provisioned TimescaleDB datasource (uid: timescaledb) and any JSON dashboards under grafana/dashboards/.
Host port mappings: 8080→portal, 5433→timescaledb, 3001→grafana. Chosen to coexist with the console stack.
Prod — docker-compose.prod.yml
Same services, no host port mappings. Joins external traefik-public network. Per-customer Traefik labels (subdomain routing for portal, same-origin path-prefix routing for Grafana at /grafana). Grafana sub-path + GF_SERVER_ROOT_URL configured. All secrets via env vars.
Run:
docker network create traefik-public # once on the host
docker compose -f docker-compose.prod.yml --env-file .env up -d
See OPERATIONS.md for the full per-customer deployment loop.
Database migrations
MigrateAsync runs on startup (controlled by Database:MigrateOnStartup, default true). Immediately after, TimescaleBootstrapper runs an idempotent block:
CREATE EXTENSION IF NOT EXISTS timescaledb(defensive).SELECT create_hypertable('monitoring."PowerMeasurements"', 'Time', if_not_exists => TRUE, migrate_data => TRUE).SELECT set_chunk_time_interval('monitoring."PowerMeasurements"', INTERVAL '<MonitoringOptions.ChunkTimeInterval>').
Safe to re-run on every start.
Adding a new migration
When you change the entity model:
cd C:\AcuvimDev\Tau.Acuvim\portal
dotnet ef migrations add <DescriptiveName> `
--project src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj `
--output-dir Migrations
Commit. Next deploy applies it automatically.
Accessing the app
| Environment | URL |
|---|---|
| Local (Docker combined) | http://localhost:8080 |
| Local (Vite dev) | http://localhost:5174 |
| Production | https://<customer-host> (e.g. https://abc0001.portal.example.com) |
API base path: /api. Swagger UI in dev: /swagger.
Health endpoints
GET /health— liveness (process alive). ReturnsHealthyif the app responds.GET /health/ready— readiness. ReturnsHealthyonly if TimescaleDB answers.
Nav surface
| Page | Who sees it |
|---|---|
| Dashboard | Any authenticated user |
| Dashboards (embedded Grafana) | Any authenticated user |
| Sites | Admin only |
| Settings (Branding / Rates / Users / Grafana / App config) | Admin only |
Accessing Grafana
Local dev
- Direct: http://localhost:3001 — anonymous Viewer can browse provisioned dashboards. Admin/
GRAFANA_ADMIN_PASSWORDfor editing. - Embedded: portal Dashboards page — iframe
srcpoints at the local Grafana base URL.
Production
- Direct browser access to
<customer-host>/grafana/*is gated by your chosen auth mode (see Security notes). Anonymous is off in the prod compose. - Embedded: portal Dashboards page — same-origin iframe via Traefik path prefix
/grafana.
Provisioning
- Datasource:
grafana/provisioning/datasources/timescaledb.yml(uid:timescaledb). - Dashboard provider:
grafana/provisioning/dashboards/dashboards.yml(auto-discovers JSON ingrafana/dashboards/, refresh 30s). - Starter dashboard:
grafana/dashboards/power-overview.json— active power + cumulative energy + latest-power stat, parameterised by adevicetemplate variable.
To add a dashboard:
- Drop the JSON into
grafana/dashboards/. - Add an entry to
Grafana.Dashboardsinappsettings.template.json(or override inappsettings.Local.json) with the sameUid. The portal's Dashboards page picks it up after a refresh.
Default local test credentials
Generated locally — change before publishing the stack to anyone.
- Email:
admin@example.com - Password:
ChangeMe123!
Defined in appsettings.template.json → Authentication. The bootstrapper seeds this account only if no account with that email exists, and never overwrites a changed password.
Production guard: if ASPNETCORE_ENVIRONMENT=Production and the default password is still ChangeMe123!, the app refuses to start with an explicit error. Override Authentication__DefaultAdminPassword via env var before deploying.
Production deployment notes
For the per-customer deployment loop see OPERATIONS.md. The short version:
- One Compose project per customer. Set
COMPOSE_PROJECT_NAME=abc0001(lowercase form of the customer ID — Compose v2 rejects uppercase). Containers are namedabc0001_portal,abc0001_grafana,abc0001_timescale. - One subdomain per customer. Set
CUSTOMER_HOST=abc0001.portal.example.com. Wildcard DNS + wildcard TLS cert via Traefik's resolver (certresolver=le). - Decide your Grafana auth mode (see Security notes). The prod compose deliberately leaves Grafana auth off so the iframe refuses to load until you pick.
- Set all secrets via env vars (not files):
POSTGRES_PASSWORDGRAFANA_ADMIN_PASSWORDAuthentication__DefaultAdminPassword
- External
traefik-publicDocker network must exist (created once on the host running Traefik). - Up the stack:
docker compose -f docker-compose.prod.yml --env-file .env up -d - Verify the three containers report healthy and
https://<customer-host>/health/readyreturnsHealthy.
Security notes
What's protected by default
- ASP.NET Core Identity with lockout (5 failed attempts → 15 min) and strong password requirements (8+ chars, upper + lower + digit).
- Cookies are HttpOnly + SameSite=Lax + Secure in prod, scoped to the portal subdomain.
- Admin-only endpoints are gated by an
AdminOnlypolicy (RequireRole("Admin")). Confirmed at backend; nav hidden on frontend. - Cannot delete your own account — backend block, not just UI.
GET /api/admin/config-overviewis admin-only; the DTO never includes the connection string or any password. Redaction by construction, not filtering.- Branding logo upload rejects files >2 MB and extensions outside
{png, jpg, jpeg, svg, webp}. - Anti-forgery is left on by default on cookie-authenticated endpoints; the logo upload explicitly opts out (multipart needs it disabled). Other admin endpoints accept JSON over
same-site Laxcookies, which is CSRF-safe for state-changing same-origin SPA requests. - Security headers (
X-Content-Type-Options,X-Frame-Options: SAMEORIGIN,Referrer-Policy: strict-origin-when-cross-origin) on every response. HSTS in prod.
Production refuse-to-start guards
- App refuses to start in
ProductionifAuthentication:DefaultAdminPasswordis stillChangeMe123!. - App refuses to start in
ProductionifDatabase:AutoProvisionLocalTimescaleDb=true(you must supply an explicit connection string). - App refuses to start if no connection string can be resolved at all.
Grafana embedding — production auth
The dev compose runs Grafana with anonymous Viewer (safe on localhost). The prod compose uses option (a) — Traefik forwardAuth → portal /api/auth/check, wired via middleware labels on the Grafana router. Anonymous stays off; every Grafana request gates on a valid portal cookie.
| Option | What it does | Trade-off |
|---|---|---|
(a) Traefik forwardAuth (active) |
Traefik middleware calls portal /api/auth/check on every Grafana request; 2xx → forward, 401 → block |
Zero changes to Grafana. Best when "any portal user = same dashboards." Cheap endpoint, cookie-only (no DB hit per request). |
(b) Grafana auth.proxy |
GF_AUTH_PROXY_ENABLED=true; trust an X-WEBAUTH-USER header set by Traefik |
Maps portal user → Grafana user, gets per-user folders/perms. Sanitise the header — never let a client set it directly. Switch to this when you need per-user permissions inside Grafana. |
| (c) Service-account API key + render tokens | Portal mints short-lived render tokens; SPA embeds via ?auth_token=... |
Most moving parts. Right when dashboards are stitched into custom UI per-panel rather than full Grafana. |
How the wiring works. The Grafana router in docker-compose.prod.yml carries middlewares=${COMPOSE_PROJECT_NAME}-portal-auth@docker, which is defined inline on the same service:
traefik.http.middlewares.${COMPOSE_PROJECT_NAME}-portal-auth.forwardauth.address=http://${COMPOSE_PROJECT_NAME}_portal:8080/api/auth/check
Per-customer scoped by COMPOSE_PROJECT_NAME, so customer A's Grafana checks customer A's portal — Traefik can't cross-route a verdict.
To switch modes (e.g. to auth.proxy for per-user Grafana folders): drop the forwardAuth middleware label, add GF_AUTH_PROXY_* env vars on Grafana, and add authResponseHeaders=X-WEBAUTH-USER on the middleware plus a portal endpoint that emits that header from the cookie identity.
Other considerations
- Same-origin embed (prod path-prefix routing through Traefik) sidesteps third-party-cookie blockers that increasingly break cross-origin Grafana iframes.
- Provisioned datasource is
editable: false— admins cannot accidentally rewire Grafana from its UI. - Default password complexity is tunable in
Program.cs→IdentityOptions. Lockout is tunable in the same block. - TimescaleDB licensing — we use Apache-licensed
timescale/timescaledb:*-pg16. Stay on community features (hypertables, continuous aggregates) if you ever sell this as managed DBaaS.
Testing
Backend unit tests under tests/Tau.Acuvim.Portal.Tests/ cover cost calculation, rate validation, connection-string resolution, day-of-week math:
cd C:\AcuvimDev\Tau.Acuvim\portal\tests\Tau.Acuvim.Portal.Tests
dotnet test
See TESTING.md for the full manual integration scenario, frontend test scaffolding recipe, and edge-case checklist.
Operations
For per-customer provisioning, secret rotation, backups, and health monitoring see OPERATIONS.md.
Admin / Fleet mode
A second deployment of the same image — RunMode=Admin, separate DB — aggregates data from all customer stacks for a fleet-wide operator view. See docs/FLEET-DESIGN.md for the full design.
Phase 15 (this release): the operator surface is live. Sign in to the Admin stack and:
- Dashboard shows fleet headline — customer / active counts, today's measurement count and kWh imported, per-customer summary table with lag indicators. Auto-refreshes every 30s.
- Customers lists registered customers; click a row to drill into one.
- Customer detail page shows mirrored sites, mirrored devices, the 50 most recent measurements, and the last 20 ingest events (with rejection counts, batch sizes, time-spreads — useful when a firmware replay arrives and you want to see the wave).
grafana/dashboards-admin/ships Fleet Overview + Customer Drilldown dashboards reading from the realtimefleet.hourly_per_devicecontinuous aggregate. Mount this folder instead ofgrafana/dashboards/on the Admin's Grafana container — see OPERATIONS.md.
Phase 14: the full push pipeline. Customer stacks with FleetIngest__Enabled=true run a FleetPushService background loop that batches sites, devices, and measurements (cursor by ReceivedAt — firmware buffer-and-replay back-fills get picked up automatically) and POSTs them to FleetIngest__Url with X-Customer-Token. Admin's /api/fleet/ingest upserts and writes an IngestEvents audit row per batch. Admin's FleetTimescaleBootstrapper makes fleet.PowerMeasurements a hypertable with compression-after-7-days and a realtime fleet.hourly_per_device continuous aggregate.
Spin up an Admin stack:
docker exec <client-timescale> createdb -U power_user admin_fleet # one-time
docker run -d --name admin-portal --network <existing-network> `
-e Application__RunMode=Admin `
-e Database__ConnectionString='Host=<host>;Port=5432;Database=admin_fleet;Username=power_user;Password=<secret>' `
-e Authentication__DefaultAdminPassword=<rotate-from-template-default> `
-p 8090:8080 `
portal-dev-portal
Then sign in at http://localhost:8090 → Customers → register the customer → token shown once.
Enable push on the customer stack: add to that customer's .env:
Application__RunMode=Client
FleetIngest__Enabled=true
FleetIngest__Url=http://admin-portal:8080/api/fleet/ingest # container DNS in-network, or https://admin-host
FleetIngest__Token=<token from Customers page>
FleetIngest__IntervalSeconds=60 # default
FleetIngest__BatchSize=5000 # default
Restart the customer's portal container — the FleetPushService starts on its own. Verify the audit trail on the Admin side:
docker exec <admin-timescale> psql -U power_user -d admin_fleet -c `
'SELECT \"BatchType\",\"RowsAccepted\",\"ReceivedAt\" FROM fleet.\"IngestEvents\" ORDER BY \"ReceivedAt\" DESC LIMIT 10;'