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>
142 lines
7.0 KiB
YAML
142 lines
7.0 KiB
YAML
# Production stack: one per customer.
|
|
# Requires:
|
|
# - COMPOSE_PROJECT_NAME=abc0001 (LOWERCASE; Compose v2 rejects uppercase. Use the
|
|
# lowercase form of the customer ID — abc0001 for ABC0001.)
|
|
# - CUSTOMER_HOST=abc0001.portal.example.com (Traefik routes by this Host)
|
|
# - POSTGRES_PASSWORD, GRAFANA_ADMIN_PASSWORD set in .env
|
|
# - An external Docker network `traefik-public` created by your Traefik stack
|
|
#
|
|
# Run with:
|
|
# docker compose -f docker-compose.prod.yml --env-file .env up -d
|
|
|
|
services:
|
|
portal:
|
|
build: .
|
|
image: tau-acuvim-portal:latest
|
|
container_name: ${COMPOSE_PROJECT_NAME}_portal
|
|
restart: unless-stopped
|
|
environment:
|
|
- ASPNETCORE_ENVIRONMENT=Production
|
|
- Application__PublicUrl=https://${CUSTOMER_HOST}
|
|
# RunMode: Client (per-customer, default) or Admin (fleet aggregator).
|
|
# Falls back to Client for normal customer stacks.
|
|
- Application__RunMode=${Application__RunMode:-Client}
|
|
- Database__ConnectionString=Host=timescaledb;Port=5432;Database=${POSTGRES_DB:-power_monitoring};Username=${POSTGRES_USER:-power_user};Password=${POSTGRES_PASSWORD}
|
|
- Database__AutoProvisionLocalTimescaleDb=false
|
|
- Authentication__DefaultAdminEmail=${Authentication__DefaultAdminEmail}
|
|
- Authentication__DefaultAdminPassword=${Authentication__DefaultAdminPassword}
|
|
- Grafana__BaseUrl=https://${CUSTOMER_HOST}${Grafana__EmbedPathPrefix:-/grafana}
|
|
- Grafana__InternalUrl=http://grafana:3000
|
|
- Grafana__EmbedPathPrefix=${Grafana__EmbedPathPrefix:-/grafana}
|
|
# White-label seed values. Applied ONLY on first boot (when the branding row
|
|
# doesn't exist yet); after that the row in the DB is authoritative and the
|
|
# operator edits via Settings → Branding. Pre-set these to spin up a new
|
|
# customer already-branded so the customer admin never sees "Power Monitoring
|
|
# Portal" on their first sign-in. Empty → template defaults from
|
|
# appsettings.template.json.
|
|
- WhiteLabel__ApplicationName=${WhiteLabel__ApplicationName:-Power Monitoring Portal}
|
|
- WhiteLabel__LogoUrl=${WhiteLabel__LogoUrl:-}
|
|
- WhiteLabel__PrimaryColor=${WhiteLabel__PrimaryColor:-#1f2937}
|
|
- WhiteLabel__SecondaryColor=${WhiteLabel__SecondaryColor:-#374151}
|
|
- WhiteLabel__AccentColor=${WhiteLabel__AccentColor:-#2563eb}
|
|
- WhiteLabel__FooterText=${WhiteLabel__FooterText:-}
|
|
# Fleet ingest (Client mode → push to Admin stack). Disabled by default;
|
|
# enable per-customer in their .env after registering them on the Admin
|
|
# Customers page. URL + Token are required when Enabled=true (startup guard).
|
|
- FleetIngest__Enabled=${FleetIngest__Enabled:-false}
|
|
- FleetIngest__Url=${FleetIngest__Url:-}
|
|
- FleetIngest__Token=${FleetIngest__Token:-}
|
|
- FleetIngest__IntervalSeconds=${FleetIngest__IntervalSeconds:-60}
|
|
- FleetIngest__BatchSize=${FleetIngest__BatchSize:-5000}
|
|
- FleetIngest__BatchMaxBytes=${FleetIngest__BatchMaxBytes:-1048576}
|
|
depends_on:
|
|
timescaledb:
|
|
condition: service_healthy
|
|
volumes:
|
|
- portal-keys:/data/keys
|
|
- portal-branding:/data/branding
|
|
networks:
|
|
- default
|
|
- traefik-public
|
|
labels:
|
|
- "traefik.enable=true"
|
|
- "traefik.docker.network=traefik-public"
|
|
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.rule=Host(`${CUSTOMER_HOST}`)"
|
|
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.entrypoints=websecure"
|
|
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.tls=true"
|
|
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.tls.certresolver=le"
|
|
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.priority=10"
|
|
- "traefik.http.services.${COMPOSE_PROJECT_NAME}-portal.loadbalancer.server.port=8080"
|
|
|
|
timescaledb:
|
|
image: timescale/timescaledb:2.17.2-pg16
|
|
container_name: ${COMPOSE_PROJECT_NAME}_timescale
|
|
restart: unless-stopped
|
|
environment:
|
|
- POSTGRES_DB=${POSTGRES_DB:-power_monitoring}
|
|
- POSTGRES_USER=${POSTGRES_USER:-power_user}
|
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
|
volumes:
|
|
- timescale-data:/var/lib/postgresql/data
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-power_user} -d ${POSTGRES_DB:-power_monitoring}"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 10
|
|
|
|
grafana:
|
|
image: grafana/grafana:11.4.0
|
|
container_name: ${COMPOSE_PROJECT_NAME}_grafana
|
|
restart: unless-stopped
|
|
environment:
|
|
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
|
|
- GF_SECURITY_ALLOW_EMBEDDING=true
|
|
- GF_SERVER_ROOT_URL=https://${CUSTOMER_HOST}${Grafana__EmbedPathPrefix:-/grafana}
|
|
- GF_SERVER_SERVE_FROM_SUB_PATH=true
|
|
# Prod auth: option (a) — Traefik forwardAuth → portal /api/auth/check.
|
|
# Anonymous stays OFF; Traefik gates every Grafana request on a valid portal
|
|
# cookie via the middleware labels below. Switch modes (auth.proxy / render
|
|
# tokens) → see README "Embedding Grafana — production auth options".
|
|
- GF_AUTH_ANONYMOUS_ENABLED=false
|
|
- GF_USERS_ALLOW_SIGN_UP=false
|
|
- POSTGRES_DB=${POSTGRES_DB:-power_monitoring}
|
|
- POSTGRES_USER=${POSTGRES_USER:-power_user}
|
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
|
volumes:
|
|
- grafana-data:/var/lib/grafana
|
|
- ./grafana/provisioning:/etc/grafana/provisioning:ro
|
|
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
|
|
depends_on:
|
|
timescaledb:
|
|
condition: service_healthy
|
|
networks:
|
|
- default
|
|
- traefik-public
|
|
labels:
|
|
- "traefik.enable=true"
|
|
- "traefik.docker.network=traefik-public"
|
|
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.rule=Host(`${CUSTOMER_HOST}`) && PathPrefix(`${Grafana__EmbedPathPrefix:-/grafana}`)"
|
|
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.entrypoints=websecure"
|
|
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.tls=true"
|
|
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.tls.certresolver=le"
|
|
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.priority=20"
|
|
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.middlewares=${COMPOSE_PROJECT_NAME}-portal-auth@docker"
|
|
- "traefik.http.services.${COMPOSE_PROJECT_NAME}-grafana.loadbalancer.server.port=3000"
|
|
# forwardAuth middleware: every Grafana request gates on a sub-request to
|
|
# the portal's /api/auth/check. 2xx → forward to Grafana; 401 → block.
|
|
# Cookie is forwarded by default. trustForwardHeader so the portal sees the
|
|
# real client IP if it ever wants to log it. authResponseHeaders is empty —
|
|
# we don't need to surface anything back from the portal to Grafana.
|
|
- "traefik.http.middlewares.${COMPOSE_PROJECT_NAME}-portal-auth.forwardauth.address=http://${COMPOSE_PROJECT_NAME}_portal:8080/api/auth/check"
|
|
- "traefik.http.middlewares.${COMPOSE_PROJECT_NAME}-portal-auth.forwardauth.trustForwardHeader=true"
|
|
|
|
volumes:
|
|
portal-keys:
|
|
portal-branding:
|
|
timescale-data:
|
|
grafana-data:
|
|
|
|
networks:
|
|
traefik-public:
|
|
external: true
|