Tau.Acuvim/portal/docker-compose.prod.yml
Diseri Pearson e2cbb83397 Portal: Client Dashboard, Measurements page, Excel exports, Grafana auth, RLS
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>
2026-05-19 09:15:44 +02:00

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