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>
112 lines
4.7 KiB
YAML
112 lines
4.7 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}
|
|
- 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}
|
|
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 IS NOT WIRED YET (Phase 9 risk).
|
|
# Anonymous is OFF so dashboards do NOT serve until you choose:
|
|
# (a) Traefik forwardAuth middleware → /api/auth/check
|
|
# (b) Grafana auth.proxy (GF_AUTH_PROXY_*) with X-WEBAUTH-USER from portal/Traefik
|
|
# (c) Service-account API key + portal-minted 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.services.${COMPOSE_PROJECT_NAME}-grafana.loadbalancer.server.port=3000"
|
|
|
|
volumes:
|
|
portal-keys:
|
|
portal-branding:
|
|
timescale-data:
|
|
grafana-data:
|
|
|
|
networks:
|
|
traefik-public:
|
|
external: true
|