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>
This commit is contained in:
Diseri Pearson 2026-05-19 09:15:44 +02:00
parent 66660364ec
commit e2cbb83397
49 changed files with 3271 additions and 87 deletions

View File

@ -1,24 +1,38 @@
# Copy to .env for local docker compose use. # Copy to .env for docker compose use.
# DO NOT commit .env. # DO NOT commit .env (it carries secrets).
#
# This file documents BOTH the Client (per-customer) and Admin (fleet
# aggregator) configurations. For a customer stack: leave Application__RunMode
# at Client and ignore the Admin section. For the Admin stack: flip
# Application__RunMode=Admin, point Database__ConnectionString at the central
# fleet DB, and ignore the FleetIngest section.
# ── Common ─────────────────────────────────────────────────────────────── # ── Common ───────────────────────────────────────────────────────────────
# Compose project + container prefix. MUST be lowercase (Docker Compose v2 requirement). # Compose project + container prefix. MUST be lowercase (Docker Compose v2 requirement).
# For a customer ID like ABC0001, set the project name to its lowercase form: abc0001. # For a customer ID like ABC0001, set the project name to its lowercase form: abc0001.
# Containers then come out as abc0001_portal / abc0001_grafana / abc0001_timescale. # Containers come out as abc0001_portal / abc0001_grafana / abc0001_timescale.
# For the Admin stack a conventional name is `admin`.
COMPOSE_PROJECT_NAME=portal-dev COMPOSE_PROJECT_NAME=portal-dev
# ── Production-only ────────────────────────────────────────────────────── # Production-only: public hostname Traefik routes to this stack.
# Public hostname Traefik routes to this customer's portal.
# Required by docker-compose.prod.yml. Ignored by the dev compose. # Required by docker-compose.prod.yml. Ignored by the dev compose.
CUSTOMER_HOST=abc0001.portal.example.com CUSTOMER_HOST=abc0001.portal.example.com
# RunMode: Client (default, per-customer) or Admin (fleet aggregator).
# Single binary, config-selected; see portal/docs/FLEET-DESIGN.md.
Application__RunMode=Client
# ── Database ───────────────────────────────────────────────────────────── # ── Database ─────────────────────────────────────────────────────────────
# For Client stacks: each customer has its own POSTGRES_PASSWORD — do NOT reuse
# across customers. For the Admin stack, this points at admin_fleet (separate DB).
POSTGRES_DB=power_monitoring POSTGRES_DB=power_monitoring
POSTGRES_USER=power_user POSTGRES_USER=power_user
POSTGRES_PASSWORD=change_me_for_local_only POSTGRES_PASSWORD=change_me_for_local_only
# ── Portal authentication ─────────────────────────────────────────────── # ── Portal authentication ────────────────────────────────────────────────
# In Production, DefaultAdminPassword MUST be changed or the app refuses to start. # DefaultAdmin* seeds the first-ever account; ignored once any account with that
# email exists. In Production the password MUST be changed or the app refuses
# to start. Use `openssl rand -base64 32` to generate.
Authentication__DefaultAdminEmail=admin@example.com Authentication__DefaultAdminEmail=admin@example.com
Authentication__DefaultAdminPassword=ChangeMe123! Authentication__DefaultAdminPassword=ChangeMe123!
@ -26,17 +40,40 @@ Authentication__DefaultAdminPassword=ChangeMe123!
GRAFANA_ADMIN_PASSWORD=admin GRAFANA_ADMIN_PASSWORD=admin
# Path prefix Grafana is mounted at behind Traefik. Same-origin embed in the SPA. # Path prefix Grafana is mounted at behind Traefik. Same-origin embed in the SPA.
Grafana__EmbedPathPrefix=/grafana Grafana__EmbedPathPrefix=/grafana
# Prod auth is option (a) Traefik forwardAuth → portal /api/auth/check, wired
# via labels in docker-compose.prod.yml. Nothing to set here.
# ── RunMode (Client | Admin) ───────────────────────────────────────────── # ── White-label (seed values applied only on first boot) ────────────────
# Client (default) = per-customer stack. Admin = fleet aggregator. # These pre-brand a fresh stack so the customer admin never sees the generic
# Application__RunMode=Client # template on their first sign-in. After the first boot the row in the
# customer's DB is authoritative; changing these env vars + restarting does
# NOT re-apply them. To restyle later, use Settings → Branding in the UI.
# Leave any blank to fall back to the template default (appsettings.template.json).
# WhiteLabel__ApplicationName=ACME Power
# WhiteLabel__PrimaryColor=#0c4a6e
# WhiteLabel__SecondaryColor=#0e7490
# WhiteLabel__AccentColor=#06b6d4
# WhiteLabel__FooterText=© ACME Power 2026
# Logo upload is via Settings → Branding; URL form below is only useful if you
# host the logo externally and don't want to upload it.
# WhiteLabel__LogoUrl=https://cdn.example.com/acme/logo.png
# ── Fleet ingest (Client mode → push to Admin) ─────────────────────────── # ── Fleet ingest (Client mode only — push to an Admin stack) ────────────
# When Enabled=true, URL and Token are REQUIRED (RunModeGuards refuse otherwise). # Enable per-customer AFTER registering them on the Admin Customers page; the
# Get the token from the Admin Customers page; it is shown once at creation/rotation. # token is shown once at creation. URL + Token are REQUIRED when Enabled=true
# FleetIngest__Enabled=false # (RunModeGuards refuses startup otherwise).
# FleetIngest__Enabled=true
# FleetIngest__Url=https://admin.portal.example.com/api/fleet/ingest # FleetIngest__Url=https://admin.portal.example.com/api/fleet/ingest
# FleetIngest__Token= # FleetIngest__Token=
# FleetIngest__IntervalSeconds=60 # FleetIngest__IntervalSeconds=60
# FleetIngest__BatchSize=5000 # FleetIngest__BatchSize=5000
# FleetIngest__BatchMaxBytes=1048576 # FleetIngest__BatchMaxBytes=1048576
# ── Admin stack only ─────────────────────────────────────────────────────
# When Application__RunMode=Admin:
# - Database__ConnectionString MUST be set (no autoprovision for Admin).
# - Ignore the FleetIngest__* and WhiteLabel__* sections above unless you
# want to brand the Admin operator console differently from customer stacks.
# Example:
# Application__RunMode=Admin
# Database__ConnectionString=Host=timescaledb;Port=5432;Database=admin_fleet;Username=power_user;Password=<secret>

View File

@ -63,9 +63,12 @@ openssl rand -base64 32 # Authentication__DefaultAdminPassword
### 3. Fill in `.env` ### 3. Fill in `.env`
Copy `.env.example` to the customer's directory and fill in:
```ini ```ini
COMPOSE_PROJECT_NAME=abc0001 COMPOSE_PROJECT_NAME=abc0001
CUSTOMER_HOST=abc0001.portal.example.com CUSTOMER_HOST=abc0001.portal.example.com
Application__RunMode=Client
POSTGRES_DB=power_monitoring POSTGRES_DB=power_monitoring
POSTGRES_USER=power_user POSTGRES_USER=power_user
@ -76,17 +79,23 @@ Authentication__DefaultAdminPassword=<from step 2>
GRAFANA_ADMIN_PASSWORD=<from step 2> GRAFANA_ADMIN_PASSWORD=<from step 2>
Grafana__EmbedPathPrefix=/grafana Grafana__EmbedPathPrefix=/grafana
# Pre-brand the stack so the customer's first sign-in already shows their
# colours and name. Only applied on first boot; later changes are via the UI.
WhiteLabel__ApplicationName=Acme Corp Power Monitoring
WhiteLabel__PrimaryColor=#0c4a6e
WhiteLabel__SecondaryColor=#0e7490
WhiteLabel__AccentColor=#06b6d4
WhiteLabel__FooterText=© Acme Corp
``` ```
### 4. Decide Grafana auth mode See `.env.example` for the full annotated set including the optional `FleetIngest__*` block (added later, when you enable fleet aggregation for this customer).
Anonymous is **off** in the prod compose by default. Pick one of the three options from the README's Security notes and wire it before exposing the stack to anyone: ### 4. Grafana auth (already wired)
- (a) Traefik `forwardAuth` → add the middleware to the `traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana` labels and implement `/api/auth/check` on the portal. Production Grafana embedding uses Traefik `forwardAuth` → portal `/api/auth/check`, defined inline on the Grafana router in `docker-compose.prod.yml`. Every Grafana sub-request is gated on a valid portal cookie; anonymous is off. No per-customer action required.
- (b) Grafana `auth.proxy` → set `GF_AUTH_PROXY_ENABLED=true`, `GF_AUTH_PROXY_HEADER_NAME=X-WEBAUTH-USER` env vars; ensure Traefik (or the portal) sets the header and that no client can.
- (c) Render tokens → minted by a portal endpoint; SPA appends `?auth_token=...` to the iframe URL.
Without any of these, Grafana refuses anonymous access in prod (intended safe default — iframe will show a login page). To switch to a different mode (e.g. `auth.proxy` for per-user Grafana folders), see the README "Grafana embedding — production auth" section.
### 5. Bring it up ### 5. Bring it up

View File

@ -344,17 +344,25 @@ For the per-customer deployment loop see [OPERATIONS.md](./OPERATIONS.md). The s
- App refuses to start in `Production` if `Database:AutoProvisionLocalTimescaleDb=true` (you must supply an explicit connection string). - App refuses to start in `Production` if `Database:AutoProvisionLocalTimescaleDb=true` (you must supply an explicit connection string).
- App refuses to start if no connection string can be resolved at all. - App refuses to start if no connection string can be resolved at all.
### Grafana embedding — three production auth options ### Grafana embedding — production auth
The dev compose runs Grafana with anonymous Viewer (safe on `localhost`). The prod compose has anonymous **off** and leaves the auth mode unset on purpose — pick one before publishing: 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 | | Option | What it does | Trade-off |
|---|---|---| |---|---|---|
| **(a) Traefik `forwardAuth`** | Traefik middleware calls a portal `/api/auth/check` endpoint on every Grafana request; portal cookie required, else 401 | Zero changes to Grafana. Best when "any portal user = same dashboards." | | **(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. | | **(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. | | **(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. |
Until one is wired, prod-mode Grafana refuses anonymous access and the iframe shows a login page — the intended safe default. **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 ### Other considerations

View File

@ -18,6 +18,9 @@ services:
environment: environment:
- ASPNETCORE_ENVIRONMENT=Production - ASPNETCORE_ENVIRONMENT=Production
- Application__PublicUrl=https://${CUSTOMER_HOST} - 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__ConnectionString=Host=timescaledb;Port=5432;Database=${POSTGRES_DB:-power_monitoring};Username=${POSTGRES_USER:-power_user};Password=${POSTGRES_PASSWORD}
- Database__AutoProvisionLocalTimescaleDb=false - Database__AutoProvisionLocalTimescaleDb=false
- Authentication__DefaultAdminEmail=${Authentication__DefaultAdminEmail} - Authentication__DefaultAdminEmail=${Authentication__DefaultAdminEmail}
@ -25,6 +28,27 @@ services:
- Grafana__BaseUrl=https://${CUSTOMER_HOST}${Grafana__EmbedPathPrefix:-/grafana} - Grafana__BaseUrl=https://${CUSTOMER_HOST}${Grafana__EmbedPathPrefix:-/grafana}
- Grafana__InternalUrl=http://grafana:3000 - Grafana__InternalUrl=http://grafana:3000
- Grafana__EmbedPathPrefix=${Grafana__EmbedPathPrefix:-/grafana} - 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: depends_on:
timescaledb: timescaledb:
condition: service_healthy condition: service_healthy
@ -69,12 +93,10 @@ services:
- GF_SECURITY_ALLOW_EMBEDDING=true - GF_SECURITY_ALLOW_EMBEDDING=true
- GF_SERVER_ROOT_URL=https://${CUSTOMER_HOST}${Grafana__EmbedPathPrefix:-/grafana} - GF_SERVER_ROOT_URL=https://${CUSTOMER_HOST}${Grafana__EmbedPathPrefix:-/grafana}
- GF_SERVER_SERVE_FROM_SUB_PATH=true - GF_SERVER_SERVE_FROM_SUB_PATH=true
# PROD AUTH IS NOT WIRED YET (Phase 9 risk). # Prod auth: option (a) — Traefik forwardAuth → portal /api/auth/check.
# Anonymous is OFF so dashboards do NOT serve until you choose: # Anonymous stays OFF; Traefik gates every Grafana request on a valid portal
# (a) Traefik forwardAuth middleware → /api/auth/check # cookie via the middleware labels below. Switch modes (auth.proxy / render
# (b) Grafana auth.proxy (GF_AUTH_PROXY_*) with X-WEBAUTH-USER from portal/Traefik # tokens) → see README "Embedding Grafana — production auth options".
# (c) Service-account API key + portal-minted render tokens
# See README "Embedding Grafana — production auth options".
- GF_AUTH_ANONYMOUS_ENABLED=false - GF_AUTH_ANONYMOUS_ENABLED=false
- GF_USERS_ALLOW_SIGN_UP=false - GF_USERS_ALLOW_SIGN_UP=false
- POSTGRES_DB=${POSTGRES_DB:-power_monitoring} - POSTGRES_DB=${POSTGRES_DB:-power_monitoring}
@ -98,7 +120,15 @@ services:
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.tls=true" - "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.tls.certresolver=le"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.priority=20" - "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" - "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: volumes:
portal-keys: portal-keys:

View File

@ -10,15 +10,23 @@ services:
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development}
- Database__ConnectionString=Host=timescaledb;Port=5432;Database=${POSTGRES_DB:-power_monitoring};Username=${POSTGRES_USER:-power_user};Password=${POSTGRES_PASSWORD:-change_me_for_local_only} - Database__ConnectionString=Host=timescaledb;Port=5432;Database=${POSTGRES_DB:-power_monitoring};Username=${POSTGRES_USER:-power_user};Password=${POSTGRES_PASSWORD:-change_me_for_local_only}
- Database__AutoProvisionLocalTimescaleDb=false - Database__AutoProvisionLocalTimescaleDb=false
# In the container the writable volume is /data/branding (Dockerfile chowns it).
# The appsettings.Development.json override of LogoStoragePath is for local `dotnet run`, not Docker.
- WhiteLabel__LogoStoragePath=/data/branding
- Authentication__DefaultAdminEmail=${Authentication__DefaultAdminEmail:-admin@example.com} - Authentication__DefaultAdminEmail=${Authentication__DefaultAdminEmail:-admin@example.com}
- Authentication__DefaultAdminPassword=${Authentication__DefaultAdminPassword:-ChangeMe123!} - Authentication__DefaultAdminPassword=${Authentication__DefaultAdminPassword:-ChangeMe123!}
- Grafana__BaseUrl=http://localhost:3001 - Grafana__BaseUrl=http://localhost:3001
- Grafana__InternalUrl=http://grafana:3000 - Grafana__InternalUrl=http://grafana:3000
# RunMode: Client (default) or Admin. Override in .env to test fleet aggregation locally. # RunMode: Client (default) or Admin. Override in .env to test fleet aggregation locally.
- Application__RunMode=${Application__RunMode:-Client} - Application__RunMode=${Application__RunMode:-Client}
# White-label seed values (mirrors prod compose). Applied ONLY on first boot;
# subsequent restarts leave the DB row alone. In the container the writable
# volume is /data/branding (Dockerfile chowns it). The appsettings.Development.json
# override of LogoStoragePath is for local `dotnet run`, not Docker.
- WhiteLabel__LogoStoragePath=/data/branding
- 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): set Enabled=true + Url + Token to enable the push background service. # Fleet ingest (Client mode): set Enabled=true + Url + Token to enable the push background service.
- FleetIngest__Enabled=${FleetIngest__Enabled:-false} - FleetIngest__Enabled=${FleetIngest__Enabled:-false}
- FleetIngest__Url=${FleetIngest__Url:-} - FleetIngest__Url=${FleetIngest__Url:-}

View File

@ -12,6 +12,9 @@
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"antd": "^5.22.0", "antd": "^5.22.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"dayjs": "^1.11.20",
"echarts": "^5.6.0",
"echarts-for-react": "^3.0.6",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.28.0" "react-router-dom": "^6.28.0"
@ -1863,6 +1866,30 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/echarts": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
}
},
"node_modules/echarts-for-react": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz",
"integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"size-sensor": "^1.0.1"
},
"peerDependencies": {
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
"react": "^15.0.0 || >=16.0.0"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.357", "version": "1.5.357",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz",
@ -1967,6 +1994,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -3097,6 +3130,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/size-sensor": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
"integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==",
"license": "ISC"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -3151,6 +3190,12 @@
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.6.3", "version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
@ -3277,6 +3322,15 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
},
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
} }
} }
} }

View File

@ -13,6 +13,9 @@
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"antd": "^5.22.0", "antd": "^5.22.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"dayjs": "^1.11.20",
"echarts": "^5.6.0",
"echarts-for-react": "^3.0.6",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.28.0" "react-router-dom": "^6.28.0"

View File

@ -10,6 +10,7 @@ import { AppLayout } from './components/layout/AppLayout';
import { LoginPage } from './pages/LoginPage'; import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage'; import { DashboardPage } from './pages/DashboardPage';
import { DashboardsPage } from './pages/DashboardsPage'; import { DashboardsPage } from './pages/DashboardsPage';
import { MeasurementsPage } from './pages/MeasurementsPage';
import { AdminSitesPage } from './pages/AdminSitesPage'; import { AdminSitesPage } from './pages/AdminSitesPage';
import { AdminCustomersPage } from './pages/AdminCustomersPage'; import { AdminCustomersPage } from './pages/AdminCustomersPage';
import { AdminCustomerDetailPage } from './pages/AdminCustomerDetailPage'; import { AdminCustomerDetailPage } from './pages/AdminCustomerDetailPage';
@ -39,6 +40,7 @@ export default function App() {
> >
<Route index element={<DashboardPage />} /> <Route index element={<DashboardPage />} />
<Route path="dashboards" element={<DashboardsPage />} /> <Route path="dashboards" element={<DashboardsPage />} />
<Route path="measurements" element={<MeasurementsPage />} />
<Route <Route
path="admin/sites" path="admin/sites"
element={ element={

View File

@ -0,0 +1,18 @@
import { api } from './client';
export interface UserCustomerAccess {
userId: string;
email: string;
isFullAdmin: boolean;
isRestrictedAdmin: boolean;
allowedCustomerIds: string[];
}
export async function fetchUserCustomerAccess(userId: string): Promise<UserCustomerAccess> {
const { data } = await api.get<UserCustomerAccess>(`/admin/users/${userId}/customer-access`);
return data;
}
export async function setUserCustomerAccess(userId: string, customerIds: string[]): Promise<void> {
await api.put(`/admin/users/${userId}/customer-access`, { customerIds });
}

View File

@ -0,0 +1,54 @@
import { api } from './client';
export interface DashboardDeviceRow {
deviceId: string;
deviceName: string;
siteName: string | null;
kwh: number;
peakKw: number | null;
lastSeen: string | null;
cost: number | null;
}
export interface DashboardChartPoint {
time: string;
totalKw: number;
}
export interface DashboardSummary {
totalKwh: number;
currentActivePowerKw: number | null;
activeDeviceCount: number;
totalCost: number | null;
devices: DashboardDeviceRow[];
chart: DashboardChartPoint[];
}
export async function fetchDashboardSummary(fromUtc: string, toUtc: string): Promise<DashboardSummary> {
const { data } = await api.get<DashboardSummary>('/dashboard/summary', {
params: { from: fromUtc, to: toUtc },
});
return data;
}
// Trigger a browser download. Same-origin → cookie travels automatically.
// We use window.location rather than axios+blob since the file is the user's
// intent (a download), not an in-page resource we need to manipulate.
export function downloadDashboardSummaryXlsx(fromUtc: string, toUtc: string) {
const params = new URLSearchParams({ from: fromUtc, to: toUtc });
window.location.href = `/api/dashboard/summary/export.xlsx?${params.toString()}`;
}
export function downloadRawMeasurementsXlsx(fromUtc: string, toUtc: string, rowCap = 100_000) {
const params = new URLSearchParams({ from: fromUtc, to: toUtc, rowCap: String(rowCap) });
window.location.href = `/api/dashboard/measurements/export.xlsx?${params.toString()}`;
}
export function downloadFleetDashboardXlsx() {
window.location.href = `/api/fleet/dashboard/export.xlsx`;
}
export function downloadFleetCustomerCostXlsx(customerId: string, fromUtc: string, toUtc: string) {
const params = new URLSearchParams({ from: fromUtc, to: toUtc });
window.location.href = `/api/fleet/customers/${encodeURIComponent(customerId)}/cost/export.xlsx?${params.toString()}`;
}

View File

@ -0,0 +1,70 @@
import { api } from './client';
export interface DeviceWithSite {
id: string;
name: string;
externalId: string;
isActive: boolean;
siteId: string;
siteName: string;
}
export interface RawMeasurementRow {
time: string;
deviceName: string;
siteName: string | null;
activePowerKw: number;
reactivePowerKvar: number | null;
apparentPowerKva: number | null;
powerFactor: number | null;
voltageV: number | null;
frequencyHz: number | null;
energyImportedKwh: number | null;
energyExportedKwh: number | null;
}
export interface RawMeasurementsPage {
totalCount: number;
limit: number;
offset: number;
rows: RawMeasurementRow[];
}
export async function listAllDevices(): Promise<DeviceWithSite[]> {
const { data } = await api.get<DeviceWithSite[]>('/sites/devices');
return data;
}
export async function fetchRawMeasurements(params: {
fromUtc: string;
toUtc: string;
deviceIds?: string[];
limit?: number;
offset?: number;
}): Promise<RawMeasurementsPage> {
const { data } = await api.get<RawMeasurementsPage>('/measurements/raw', {
params: {
from: params.fromUtc,
to: params.toUtc,
deviceIds: params.deviceIds && params.deviceIds.length > 0 ? params.deviceIds.join(',') : undefined,
limit: params.limit,
offset: params.offset,
},
});
return data;
}
export function downloadRawMeasurementsExport(params: {
fromUtc: string;
toUtc: string;
deviceIds?: string[];
rowCap?: number;
}) {
const q = new URLSearchParams({
from: params.fromUtc,
to: params.toUtc,
});
if (params.deviceIds && params.deviceIds.length > 0) q.set('deviceIds', params.deviceIds.join(','));
if (params.rowCap) q.set('rowCap', String(params.rowCap));
window.location.href = `/api/measurements/raw/export.xlsx?${q.toString()}`;
}

View File

@ -14,12 +14,14 @@ export interface CreateUserPayload {
displayName: string; displayName: string;
password: string; password: string;
isAdmin: boolean; isAdmin: boolean;
isRestrictedAdmin?: boolean;
} }
export interface UpdateUserPayload { export interface UpdateUserPayload {
displayName: string; displayName: string;
isActive: boolean; isActive: boolean;
isAdmin: boolean; isAdmin: boolean;
isRestrictedAdmin?: boolean;
} }
export async function listUsers(): Promise<UserListItem[]> { export async function listUsers(): Promise<UserListItem[]> {

View File

@ -1,7 +1,7 @@
import { Layout, Menu, Button, Typography, Space, Tag } from 'antd'; import { Layout, Menu, Button, Typography, Space, Tag } from 'antd';
import { import {
DashboardOutlined, SettingOutlined, LogoutOutlined, DashboardOutlined, SettingOutlined, LogoutOutlined,
LineChartOutlined, ApartmentOutlined, TeamOutlined, LineChartOutlined, ApartmentOutlined, TeamOutlined, TableOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../../hooks/useAuth'; import { useAuth } from '../../hooks/useAuth';
@ -28,9 +28,16 @@ export function AppLayout() {
? [{ key: '/admin/customers', icon: <TeamOutlined />, label: 'Customers' }] ? [{ key: '/admin/customers', icon: <TeamOutlined />, label: 'Customers' }]
: [{ key: '/admin/sites', icon: <ApartmentOutlined />, label: 'Sites' }]; : [{ key: '/admin/sites', icon: <ApartmentOutlined />, label: 'Sites' }];
// Measurements page is per-customer data: only meaningful in Client mode.
// In Admin mode the equivalent is the Customer detail measurements tab.
const measurementsItem = adminMode
? []
: [{ key: '/measurements', icon: <TableOutlined />, label: 'Measurements' }];
const items = [ const items = [
{ key: '/', icon: <DashboardOutlined />, label: 'Dashboard' }, { key: '/', icon: <DashboardOutlined />, label: 'Dashboard' },
{ key: '/dashboards', icon: <LineChartOutlined />, label: 'Dashboards' }, { key: '/dashboards', icon: <LineChartOutlined />, label: 'Dashboards' },
...measurementsItem,
...(userIsAdmin ...(userIsAdmin
? [...adminItems, { key: '/settings', icon: <SettingOutlined />, label: 'Settings' }] ? [...adminItems, { key: '/settings', icon: <SettingOutlined />, label: 'Settings' }]
: []), : []),

View File

@ -0,0 +1,123 @@
import { useEffect, useState } from 'react';
import { Modal, Checkbox, Alert, Spin, Empty, Typography, Tag } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchUserCustomerAccess, setUserCustomerAccess } from '../../api/customerAccess';
import { listCustomers, type CustomerListItem } from '../../api/customers';
import type { UserListItem } from '../../api/users';
const { Text } = Typography;
interface Props {
open: boolean;
user: UserListItem | null;
onClose: () => void;
}
// Admin-only modal for granting / revoking a RestrictedAdmin user's access to
// specific customers. Only shown for users in the RestrictedAdmin role (full Admin
// users always see everything and don't need this).
export function CustomerAccessModal({ open, user, onClose }: Props) {
const qc = useQueryClient();
const [selected, setSelected] = useState<Set<string>>(new Set());
const { data: customers = [], isLoading: loadingCustomers } = useQuery({
queryKey: ['admin', 'customers'],
queryFn: listCustomers,
enabled: open,
});
const { data: access, isLoading: loadingAccess } = useQuery({
queryKey: ['admin', 'users', user?.id, 'customer-access'],
queryFn: () => fetchUserCustomerAccess(user!.id),
enabled: open && user !== null,
});
useEffect(() => {
if (access) setSelected(new Set(access.allowedCustomerIds));
}, [access]);
const saveMut = useMutation({
mutationFn: (ids: string[]) => setUserCustomerAccess(user!.id, ids),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'users', user!.id, 'customer-access'] });
onClose();
},
});
const toggle = (id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelected(next);
};
const isFullAdmin = access?.isFullAdmin ?? false;
return (
<Modal
title={user ? `Customer access for ${user.email}` : 'Customer access'}
open={open}
onCancel={onClose}
onOk={() => saveMut.mutate(Array.from(selected))}
okText="Save"
okButtonProps={{ disabled: isFullAdmin, loading: saveMut.isPending }}
width={560}
>
{loadingAccess || loadingCustomers ? (
<Spin />
) : (
<>
{isFullAdmin && (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="This user is a full Admin — they see all customers automatically."
description="To scope access to specific customers, change the user's role to RestrictedAdmin via the Users tab."
/>
)}
{!isFullAdmin && !access?.isRestrictedAdmin && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="This user is not in any admin role."
description="Assign the RestrictedAdmin role first if you want them to see scoped customers."
/>
)}
{!isFullAdmin && access?.isRestrictedAdmin && selected.size === 0 && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="No customers selected → user sees nothing."
description="Pick at least one customer below, or this RestrictedAdmin won't have any fleet data visible."
/>
)}
{customers.length === 0 ? (
<Empty description="No customers registered yet." />
) : (
<Checkbox.Group
style={{ display: 'flex', flexDirection: 'column', gap: 6 }}
value={Array.from(selected)}
>
{customers.map((c: CustomerListItem) => (
<Checkbox
key={c.id}
value={c.id}
disabled={isFullAdmin}
checked={selected.has(c.id)}
onChange={() => toggle(c.id)}
>
<Text strong>{c.code}</Text> <Text type="secondary"> {c.name}</Text>
{!c.isActive && <Tag color="red" style={{ marginLeft: 8 }}>Disabled</Tag>}
</Checkbox>
))}
</Checkbox.Group>
)}
</>
)}
</Modal>
);
}

View File

@ -1,7 +1,9 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Drawer, Form, Input, Switch, Button, Space, Alert } from 'antd'; import { Drawer, Form, Input, Switch, Button, Space, Alert, Radio, Typography } from 'antd';
import type { UserListItem, CreateUserPayload, UpdateUserPayload } from '../../api/users'; import type { UserListItem, CreateUserPayload, UpdateUserPayload } from '../../api/users';
const { Text } = Typography;
type Mode = type Mode =
| { kind: 'create' } | { kind: 'create' }
| { kind: 'edit'; user: UserListItem }; | { kind: 'edit'; user: UserListItem };
@ -15,6 +17,14 @@ interface Props {
onSubmit: (values: CreateUserPayload | UpdateUserPayload) => Promise<void>; onSubmit: (values: CreateUserPayload | UpdateUserPayload) => Promise<void>;
} }
type RoleChoice = 'none' | 'admin' | 'restricted';
function inferRole(user: UserListItem): RoleChoice {
if (user.roles.includes('Admin')) return 'admin';
if (user.roles.includes('RestrictedAdmin')) return 'restricted';
return 'none';
}
export function UserFormDrawer({ open, mode, submitting, error, onClose, onSubmit }: Props) { export function UserFormDrawer({ open, mode, submitting, error, onClose, onSubmit }: Props) {
const [form] = Form.useForm(); const [form] = Form.useForm();
@ -25,29 +35,34 @@ export function UserFormDrawer({ open, mode, submitting, error, onClose, onSubmi
email: mode.user.email, email: mode.user.email,
displayName: mode.user.displayName, displayName: mode.user.displayName,
isActive: mode.user.isActive, isActive: mode.user.isActive,
isAdmin: mode.user.roles.includes('Admin'), role: inferRole(mode.user),
}); });
} else { } else {
form.resetFields(); form.resetFields();
form.setFieldsValue({ isActive: true, isAdmin: false }); form.setFieldsValue({ isActive: true, role: 'none' as RoleChoice });
} }
}, [open, mode, form]); }, [open, mode, form]);
const isEdit = mode?.kind === 'edit'; const isEdit = mode?.kind === 'edit';
const handleFinish = async (values: Record<string, unknown>) => { const handleFinish = async (values: Record<string, unknown>) => {
const role = values.role as RoleChoice;
const flags = {
isAdmin: role === 'admin',
isRestrictedAdmin: role === 'restricted',
};
if (isEdit) { if (isEdit) {
await onSubmit({ await onSubmit({
displayName: String(values.displayName ?? ''), displayName: String(values.displayName ?? ''),
isActive: Boolean(values.isActive), isActive: Boolean(values.isActive),
isAdmin: Boolean(values.isAdmin), ...flags,
}); });
} else { } else {
await onSubmit({ await onSubmit({
email: String(values.email ?? '').trim(), email: String(values.email ?? '').trim(),
displayName: String(values.displayName ?? ''), displayName: String(values.displayName ?? ''),
password: String(values.password ?? ''), password: String(values.password ?? ''),
isAdmin: Boolean(values.isAdmin), ...flags,
}); });
} }
}; };
@ -55,7 +70,7 @@ export function UserFormDrawer({ open, mode, submitting, error, onClose, onSubmi
return ( return (
<Drawer <Drawer
title={isEdit ? `Edit ${mode?.user.email}` : 'Create user'} title={isEdit ? `Edit ${mode?.user.email}` : 'Create user'}
width={420} width={460}
open={open} open={open}
onClose={onClose} onClose={onClose}
destroyOnClose destroyOnClose
@ -89,8 +104,23 @@ export function UserFormDrawer({ open, mode, submitting, error, onClose, onSubmi
<Input.Password autoComplete="new-password" /> <Input.Password autoComplete="new-password" />
</Form.Item> </Form.Item>
)} )}
<Form.Item name="isAdmin" label="Administrator" valuePropName="checked"> <Form.Item name="role" label="Role">
<Switch /> <Radio.Group>
<Space direction="vertical">
<Radio value="none">
<Text>None</Text>
<Text type="secondary" style={{ display: 'block', fontSize: 12 }}>Regular user Dashboard + Dashboards only.</Text>
</Radio>
<Radio value="admin">
<Text>Administrator</Text>
<Text type="secondary" style={{ display: 'block', fontSize: 12 }}>Full access to all customers, settings, user mgmt.</Text>
</Radio>
<Radio value="restricted">
<Text>Restricted admin (fleet-scoped)</Text>
<Text type="secondary" style={{ display: 'block', fontSize: 12 }}>Same pages as Admin but Postgres RLS limits which customers they see. Grant per-customer access from the Users page.</Text>
</Radio>
</Space>
</Radio.Group>
</Form.Item> </Form.Item>
{isEdit && ( {isEdit && (
<Form.Item name="isActive" label="Active" valuePropName="checked"> <Form.Item name="isActive" label="Active" valuePropName="checked">

View File

@ -4,7 +4,9 @@ import {
DatePicker, Statistic, Row, Col, Alert, DatePicker, Statistic, Row, Col, Alert,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { ArrowLeftOutlined, LineChartOutlined, ThunderboltOutlined, DollarOutlined } from '@ant-design/icons'; import {
ArrowLeftOutlined, LineChartOutlined, ThunderboltOutlined, DollarOutlined, DownloadOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import dayjs, { type Dayjs } from 'dayjs'; import dayjs, { type Dayjs } from 'dayjs';
@ -15,6 +17,7 @@ import {
type FleetTariffView, type FleetTariffPeriodView, type FleetTariffView, type FleetTariffPeriodView,
type FleetCostDay, type FleetCostDeviceRow, type FleetCostDay, type FleetCostDeviceRow,
} from '../api/fleet'; } from '../api/fleet';
import { downloadFleetCustomerCostXlsx } from '../api/dashboard';
import { fetchGrafanaConfig } from '../api/grafana'; import { fetchGrafanaConfig } from '../api/grafana';
const { Text } = Typography; const { Text } = Typography;
@ -260,20 +263,29 @@ function CostTab({ customerId }: { customerId: string }) {
return ( return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space> <Space wrap style={{ width: '100%', justifyContent: 'space-between' }}>
<Text>Range (UTC):</Text> <Space>
<RangePicker <Text>Range (UTC):</Text>
allowClear={false} <RangePicker
value={range} allowClear={false}
showTime={false} value={range}
onChange={(v) => v && v[0] && v[1] && setRange([v[0].startOf('day'), v[1].endOf('day')])} showTime={false}
ranges={{ onChange={(v) => v && v[0] && v[1] && setRange([v[0].startOf('day'), v[1].endOf('day')])}
'Today': [dayjs().startOf('day'), dayjs().add(1, 'day').startOf('day')], ranges={{
'Last 7d': [dayjs().subtract(7, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')], 'Today': [dayjs().startOf('day'), dayjs().add(1, 'day').startOf('day')],
'Last 30d': [dayjs().subtract(30, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')], 'Last 7d': [dayjs().subtract(7, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')],
'This month': [dayjs().startOf('month'), dayjs().add(1, 'day').startOf('day')], 'Last 30d': [dayjs().subtract(30, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')],
}} 'This month': [dayjs().startOf('month'), dayjs().add(1, 'day').startOf('day')],
/> }}
/>
</Space>
<Button
icon={<DownloadOutlined />}
disabled={!data || isLoading}
onClick={() => downloadFleetCustomerCostXlsx(customerId, fromIso, toIso)}
>
Export to Excel
</Button>
</Space> </Space>
{isLoading && <Spin />} {isLoading && <Spin />}

View File

@ -1,11 +1,13 @@
import { Card, Col, Row, Statistic, Table, Tag, Typography, Empty } from 'antd'; import { Button, Card, Col, Row, Space, Statistic, Table, Tag, Typography, Empty } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
ApartmentOutlined, ThunderboltOutlined, TeamOutlined, CheckCircleOutlined, DollarOutlined, ApartmentOutlined, ThunderboltOutlined, TeamOutlined, CheckCircleOutlined, DollarOutlined,
DownloadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { fetchFleetDashboard, type FleetCustomerSummary } from '../api/fleet'; import { fetchFleetDashboard, type FleetCustomerSummary } from '../api/fleet';
import { downloadFleetDashboardXlsx } from '../api/dashboard';
const { Text } = Typography; const { Text } = Typography;
@ -98,7 +100,20 @@ export function AdminFleetDashboardPage() {
</Col> </Col>
</Row> </Row>
<Card title="Customers"> <Card
title="Customers"
extra={
<Space>
<Button
icon={<DownloadOutlined />}
onClick={() => downloadFleetDashboardXlsx()}
disabled={!data || data.customers.length === 0}
>
Export to Excel
</Button>
</Space>
}
>
{data && data.customers.length === 0 ? ( {data && data.customers.length === 0 ? (
<Empty description="No customers registered yet. Go to Customers to register the first one." /> <Empty description="No customers registered yet. Go to Customers to register the first one." />
) : ( ) : (

View File

@ -1,20 +1,235 @@
import { Card, Typography } from 'antd'; import { useMemo, useState } from 'react';
import {
Alert, Button, Card, Col, DatePicker, Row, Space, Spin, Statistic, Table, Tag, Typography,
} from 'antd';
import {
DollarOutlined, DownloadOutlined, ThunderboltOutlined, ApartmentOutlined, ClockCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { useQuery } from '@tanstack/react-query';
import ReactECharts from 'echarts-for-react';
import dayjs, { type Dayjs } from 'dayjs';
import { useAppInfo } from '../hooks/useAppInfo'; import { useAppInfo } from '../hooks/useAppInfo';
import { AdminFleetDashboardPage } from './AdminFleetDashboardPage'; import { AdminFleetDashboardPage } from './AdminFleetDashboardPage';
import {
fetchDashboardSummary, downloadDashboardSummaryXlsx, downloadRawMeasurementsXlsx,
type DashboardDeviceRow, type DashboardSummary,
} from '../api/dashboard';
const { Title, Paragraph } = Typography; const { Text } = Typography;
const { RangePicker } = DatePicker;
export function DashboardPage() { export function DashboardPage() {
const { isAdmin } = useAppInfo(); const { isAdmin } = useAppInfo();
if (isAdmin) return <AdminFleetDashboardPage />; if (isAdmin) return <AdminFleetDashboardPage />;
return <ClientDashboard />;
}
function ClientDashboard() {
const [range, setRange] = useState<[Dayjs, Dayjs]>(() => [
dayjs().startOf('day'),
dayjs().add(1, 'day').startOf('day'),
]);
const fromIso = range[0].toISOString();
const toIso = range[1].toISOString();
const { data, isLoading, error } = useQuery({
queryKey: ['dashboard-summary', fromIso, toIso],
queryFn: () => fetchDashboardSummary(fromIso, toIso),
refetchInterval: 30_000,
});
return ( return (
<Card> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Title level={3} style={{ marginTop: 0 }}>Dashboard</Title> <Card size="small">
<Paragraph> <Space wrap style={{ width: '100%', justifyContent: 'space-between' }}>
This is the authenticated landing page. Real telemetry, cost summaries, and embedded <Space wrap>
Grafana dashboards land in later phases. <Text strong>Range (UTC):</Text>
</Paragraph> <RangePicker
</Card> allowClear={false}
value={range}
onChange={(v) => v && v[0] && v[1] && setRange([v[0].startOf('day'), v[1].endOf('day')])}
ranges={{
'Today': [dayjs().startOf('day'), dayjs().add(1, 'day').startOf('day')],
'Last 7d': [dayjs().subtract(7, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')],
'Last 30d': [dayjs().subtract(30, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')],
'This month': [dayjs().startOf('month'), dayjs().add(1, 'day').startOf('day')],
}}
/>
</Space>
<Space>
<Button
icon={<DownloadOutlined />}
onClick={() => downloadDashboardSummaryXlsx(fromIso, toIso)}
>
Export summary
</Button>
<Button
icon={<DownloadOutlined />}
onClick={() => downloadRawMeasurementsXlsx(fromIso, toIso)}
>
Export raw
</Button>
</Space>
</Space>
</Card>
{error && (
<Alert
type="error" showIcon
message="Failed to load dashboard"
description={(error as Error).message}
/>
)}
<KpiRow data={data} loading={isLoading} />
<Card title="Active power — last 24h" size="small">
<ChartPanel data={data} loading={isLoading} />
</Card>
<Card title="Per device (selected range)" size="small">
<DeviceTable rows={data?.devices ?? []} loading={isLoading} />
</Card>
</Space>
); );
} }
function KpiRow({ data, loading }: { data: DashboardSummary | undefined; loading: boolean }) {
return (
<Row gutter={16}>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic
title="kWh imported (range)"
value={data?.totalKwh ?? 0}
precision={2}
prefix={<ThunderboltOutlined />}
loading={loading}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic
title="Current active power"
value={data?.currentActivePowerKw ?? 0}
precision={2}
suffix="kW"
prefix={<ThunderboltOutlined />}
loading={loading}
valueStyle={{ color: data?.currentActivePowerKw == null ? '#bbb' : undefined }}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic
title="Active devices (last 15m)"
value={data?.activeDeviceCount ?? 0}
prefix={<ApartmentOutlined />}
loading={loading}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic
title="Estimated cost (range)"
value={data?.totalCost ?? 0}
precision={2}
prefix={<DollarOutlined />}
loading={loading}
valueStyle={{
color: data?.totalCost == null ? '#bbb' : '#3f8600',
}}
/>
{data && data.totalCost == null && (
<Text type="secondary" style={{ fontSize: 11 }}>
No active tariff for any site set one in Settings Rates.
</Text>
)}
</Card>
</Col>
</Row>
);
}
function ChartPanel({ data, loading }: { data: DashboardSummary | undefined; loading: boolean }) {
const option = useMemo(() => {
const points = data?.chart ?? [];
return {
grid: { left: 50, right: 16, top: 16, bottom: 32 },
tooltip: { trigger: 'axis' },
xAxis: {
type: 'time',
},
yAxis: {
type: 'value',
name: 'kW',
nameTextStyle: { fontSize: 11 },
},
series: [{
name: 'Active power',
type: 'line',
smooth: true,
showSymbol: false,
areaStyle: { opacity: 0.15 },
data: points.map(p => [p.time, p.totalKw]),
}],
};
}, [data]);
if (loading) {
return <div style={{ height: 240, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin />
</div>;
}
if (!data || data.chart.length === 0) {
return <div style={{ height: 240, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>
<Space>
<ClockCircleOutlined />
<Text type="secondary">No measurements yet in this window.</Text>
</Space>
</div>;
}
return <ReactECharts option={option} style={{ height: 240, width: '100%' }} notMerge lazyUpdate />;
}
function DeviceTable({ rows, loading }: { rows: DashboardDeviceRow[]; loading: boolean }) {
const columns: ColumnsType<DashboardDeviceRow> = [
{ title: 'Device', dataIndex: 'deviceName', key: 'd', render: (v: string) => <Text strong>{v}</Text> },
{ title: 'Site', dataIndex: 'siteName', key: 's', render: (v: string | null) => v ?? <Text type="secondary"></Text> },
{ title: 'kWh', dataIndex: 'kwh', key: 'k', render: (v: number) => v.toFixed(3), align: 'right' as const },
{ title: 'Peak kW', dataIndex: 'peakKw', key: 'p',
render: (v: number | null) => v == null ? '—' : v.toFixed(3), align: 'right' as const },
{ title: 'Last seen (UTC)', dataIndex: 'lastSeen', key: 'l',
render: (v: string | null) => v ? lastSeenTag(v) : <Text type="secondary">never</Text> },
{ title: 'Cost', dataIndex: 'cost', key: 'c', align: 'right' as const,
render: (v: number | null) => v == null ? <Text type="secondary"></Text> : <Text strong>{v.toFixed(2)}</Text> },
];
return (
<Table<DashboardDeviceRow>
rowKey="deviceId"
size="small"
columns={columns}
dataSource={rows}
loading={loading}
pagination={false}
/>
);
}
function lastSeenTag(iso: string): React.ReactNode {
const ageMs = Date.now() - new Date(iso).getTime();
const ageMin = Math.floor(ageMs / 60_000);
if (ageMin < 1) return <Tag color="green">just now</Tag>;
if (ageMin < 5) return <Tag color="green">{ageMin}m ago</Tag>;
if (ageMin < 60) return <Tag color="orange">{ageMin}m ago</Tag>;
const ageHr = Math.floor(ageMin / 60);
if (ageHr < 24) return <Tag color="red">{ageHr}h ago</Tag>;
return <Tag color="red">{Math.floor(ageHr / 24)}d ago</Tag>;
}

View File

@ -56,12 +56,18 @@ export function DashboardsPage() {
onClick={(e) => setSelected(e.key)} onClick={(e) => setSelected(e.key)}
items={dashboards.map((d: GrafanaDashboard) => ({ items={dashboards.map((d: GrafanaDashboard) => ({
key: d.uid, key: d.uid,
icon: <LineChartOutlined />, icon: <LineChartOutlined style={{ marginTop: 6 }} />,
// AntD inline menu items default to height 40px + line-height 40px,
// which crushes a two-line label. Let the item grow and reset
// line-height so both title and description render at a readable size.
style: { height: 'auto', lineHeight: 1.4, padding: '10px 16px' },
label: ( label: (
<div> <div style={{ lineHeight: 1.4, whiteSpace: 'normal' }}>
<div>{d.title}</div> <div style={{ fontWeight: 500 }}>{d.title}</div>
{d.description && ( {d.description && (
<Text type="secondary" style={{ fontSize: 11 }}>{d.description}</Text> <Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 2 }}>
{d.description}
</Text>
)} )}
</div> </div>
), ),

View File

@ -0,0 +1,213 @@
import { useMemo, useState } from 'react';
import {
Alert, Button, Card, DatePicker, InputNumber, Select, Space, Table, Tag, Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import dayjs, { type Dayjs } from 'dayjs';
import {
fetchRawMeasurements, listAllDevices, downloadRawMeasurementsExport,
type RawMeasurementRow,
} from '../api/measurements';
const { Text } = Typography;
const { RangePicker } = DatePicker;
// Hard cap on the in-page preview to keep the table fast. Excel export has its
// own cap (250 000 backend-side) since users actually want the whole dataset.
const PREVIEW_LIMIT_OPTIONS = [100, 200, 500, 1000];
export function MeasurementsPage() {
const [range, setRange] = useState<[Dayjs, Dayjs]>(() => [
dayjs().subtract(1, 'day').startOf('day'),
dayjs().add(1, 'day').startOf('day'),
]);
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
const [limit, setLimit] = useState<number>(200);
const [offset, setOffset] = useState<number>(0);
const [exportRowCap, setExportRowCap] = useState<number>(100_000);
const fromIso = range[0].toISOString();
const toIso = range[1].toISOString();
const { data: devices = [], isLoading: loadingDevices } = useQuery({
queryKey: ['all-devices'],
queryFn: listAllDevices,
});
const { data, isLoading, isFetching, error, refetch } = useQuery({
queryKey: ['raw-measurements', fromIso, toIso, selectedDeviceIds.sort().join(','), limit, offset],
queryFn: () => fetchRawMeasurements({
fromUtc: fromIso,
toUtc: toIso,
deviceIds: selectedDeviceIds.length > 0 ? selectedDeviceIds : undefined,
limit,
offset,
}),
placeholderData: keepPreviousData,
});
const deviceOptions = useMemo(() => devices.map(d => ({
value: d.id,
label: `${d.name}${d.siteName}`,
disabled: !d.isActive,
})), [devices]);
const columns: ColumnsType<RawMeasurementRow> = [
{
title: 'Time (UTC)', dataIndex: 'time', key: 't', width: 180,
render: (v: string) => new Date(v).toISOString().replace('T', ' ').slice(0, 19),
},
{ title: 'Device', dataIndex: 'deviceName', key: 'd', render: (v: string) => <Text strong>{v}</Text> },
{ title: 'Site', dataIndex: 'siteName', key: 's', render: (v: string | null) => v ?? <Text type="secondary"></Text> },
{ title: 'Active kW', dataIndex: 'activePowerKw', key: 'kw', align: 'right' as const,
render: (v: number) => v.toFixed(3) },
{ title: 'kWh imported (cum.)', dataIndex: 'energyImportedKwh', key: 'imp', align: 'right' as const,
render: (v: number | null) => v == null ? '—' : v.toFixed(2) },
{ title: 'kWh exported (cum.)', dataIndex: 'energyExportedKwh', key: 'exp', align: 'right' as const,
render: (v: number | null) => v == null ? '—' : v.toFixed(2) },
{ title: 'PF', dataIndex: 'powerFactor', key: 'pf', align: 'right' as const,
render: (v: number | null) => v == null ? '—' : v.toFixed(3) },
{ title: 'V', dataIndex: 'voltageV', key: 'v', align: 'right' as const,
render: (v: number | null) => v == null ? '—' : v.toFixed(1) },
{ title: 'Hz', dataIndex: 'frequencyHz', key: 'hz', align: 'right' as const,
render: (v: number | null) => v == null ? '—' : v.toFixed(2) },
];
const totalCount = data?.totalCount ?? 0;
const showingFrom = totalCount === 0 ? 0 : offset + 1;
const showingTo = Math.min(offset + limit, totalCount);
const canPrev = offset > 0;
const canNext = offset + limit < totalCount;
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card size="small" title="Filters">
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Space wrap>
<Text strong>Range (UTC):</Text>
<RangePicker
allowClear={false}
value={range}
showTime={false}
onChange={(v) => v && v[0] && v[1] && (setRange([v[0].startOf('day'), v[1].endOf('day')]), setOffset(0))}
ranges={{
'Today': [dayjs().startOf('day'), dayjs().add(1, 'day').startOf('day')],
'Yesterday': [dayjs().subtract(1, 'day').startOf('day'), dayjs().startOf('day')],
'Last 7d': [dayjs().subtract(7, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')],
'Last 30d': [dayjs().subtract(30, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')],
'This month': [dayjs().startOf('month'), dayjs().add(1, 'day').startOf('day')],
'All time': [dayjs('2020-01-01'), dayjs().add(1, 'day').startOf('day')],
}}
/>
</Space>
<Space wrap style={{ width: '100%' }}>
<Text strong style={{ minWidth: 80 }}>Meters:</Text>
<Select
mode="multiple"
allowClear
placeholder={loadingDevices ? 'Loading devices…' : 'All meters (leave empty)'}
style={{ minWidth: 360, flex: 1 }}
value={selectedDeviceIds}
onChange={(v) => { setSelectedDeviceIds(v); setOffset(0); }}
options={deviceOptions}
maxTagCount="responsive"
showSearch
optionFilterProp="label"
/>
</Space>
<Space wrap>
<Text strong>Preview rows:</Text>
<Select
value={limit}
onChange={(v) => { setLimit(v); setOffset(0); }}
options={PREVIEW_LIMIT_OPTIONS.map(n => ({ value: n, label: n.toString() }))}
style={{ width: 96 }}
/>
<Text type="secondary">·</Text>
<Text strong>Export row cap:</Text>
<InputNumber
value={exportRowCap}
min={100}
max={250_000}
step={10_000}
onChange={(v) => setExportRowCap(typeof v === 'number' ? v : 100_000)}
style={{ width: 130 }}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
max 250 000 shrink the range if you exceed this
</Text>
</Space>
</Space>
</Card>
{error && (
<Alert
type="error" showIcon
message="Failed to load measurements"
description={(error as Error).message}
/>
)}
<Card
title={
<Space>
<Text strong>Measurements</Text>
<Tag color="blue">{totalCount.toLocaleString()} rows match</Tag>
{selectedDeviceIds.length > 0 && (
<Tag>{selectedDeviceIds.length} meter{selectedDeviceIds.length === 1 ? '' : 's'} selected</Tag>
)}
</Space>
}
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => refetch()} loading={isFetching}>
Refresh
</Button>
<Button
type="primary"
icon={<DownloadOutlined />}
disabled={totalCount === 0}
onClick={() => downloadRawMeasurementsExport({
fromUtc: fromIso, toUtc: toIso,
deviceIds: selectedDeviceIds.length > 0 ? selectedDeviceIds : undefined,
rowCap: exportRowCap,
})}
>
Export to Excel
</Button>
</Space>
}
>
<Table<RawMeasurementRow>
rowKey={(r) => `${r.time}-${r.deviceName}`}
size="small"
columns={columns}
dataSource={data?.rows ?? []}
loading={isLoading}
pagination={false}
scroll={{ x: 'max-content' }}
/>
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'space-between' }}>
<Text type="secondary">
{totalCount === 0
? 'No measurements in this window.'
: `Showing ${showingFrom.toLocaleString()}${showingTo.toLocaleString()} of ${totalCount.toLocaleString()}`}
</Text>
<Space>
<Button disabled={!canPrev || isFetching} onClick={() => setOffset(Math.max(0, offset - limit))}>
Previous
</Button>
<Button disabled={!canNext || isFetching} onClick={() => setOffset(offset + limit)}>
Next
</Button>
</Space>
</Space>
</Card>
</Space>
);
}

View File

@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Card, Button, Table, Tag, Popconfirm, Modal, Input, Space, message } from 'antd'; import { Card, Button, Table, Tag, Popconfirm, Modal, Input, Space, message } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined } from '@ant-design/icons'; import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, ApartmentOutlined } from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
listUsers, listUsers,
@ -14,15 +14,19 @@ import {
type UpdateUserPayload, type UpdateUserPayload,
} from '../api/users'; } from '../api/users';
import { UserFormDrawer } from '../components/users/UserFormDrawer'; import { UserFormDrawer } from '../components/users/UserFormDrawer';
import { CustomerAccessModal } from '../components/users/CustomerAccessModal';
import { useAppInfo } from '../hooks/useAppInfo';
type DrawerMode = { kind: 'create' } | { kind: 'edit'; user: UserListItem }; type DrawerMode = { kind: 'create' } | { kind: 'edit'; user: UserListItem };
export function UsersPage() { export function UsersPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const { isAdmin: isAdminMode } = useAppInfo();
const [drawerMode, setDrawerMode] = useState<DrawerMode | null>(null); const [drawerMode, setDrawerMode] = useState<DrawerMode | null>(null);
const [drawerError, setDrawerError] = useState<string | null>(null); const [drawerError, setDrawerError] = useState<string | null>(null);
const [resetTarget, setResetTarget] = useState<UserListItem | null>(null); const [resetTarget, setResetTarget] = useState<UserListItem | null>(null);
const [resetValue, setResetValue] = useState(''); const [resetValue, setResetValue] = useState('');
const [accessTarget, setAccessTarget] = useState<UserListItem | null>(null);
const { data: users = [], isLoading } = useQuery({ const { data: users = [], isLoading } = useQuery({
queryKey: ['admin', 'users'], queryKey: ['admin', 'users'],
@ -118,6 +122,15 @@ export function UsersPage() {
> >
Edit Edit
</Button> </Button>
{isAdminMode && user.roles.includes('RestrictedAdmin') && (
<Button
size="small"
icon={<ApartmentOutlined />}
onClick={() => setAccessTarget(user)}
>
Customer access
</Button>
)}
<Button <Button
size="small" size="small"
icon={<KeyOutlined />} icon={<KeyOutlined />}
@ -184,6 +197,12 @@ export function UsersPage() {
autoComplete="new-password" autoComplete="new-password"
/> />
</Modal> </Modal>
<CustomerAccessModal
open={accessTarget !== null}
user={accessTarget}
onClose={() => setAccessTarget(null)}
/>
</Card> </Card>
); );
} }

View File

@ -3,11 +3,17 @@ namespace Tau.Acuvim.Portal.Constants;
public static class Roles public static class Roles
{ {
public const string Admin = "Admin"; public const string Admin = "Admin";
// Admin scoped to specific customers via app.AdminCustomerAccess. Sees the same
// pages as Admin but RLS limits which customers' rows they can read.
public const string RestrictedAdmin = "RestrictedAdmin";
} }
public static class Policies public static class Policies
{ {
// Full administrator access — customer registry mgmt, user mgmt, settings, etc.
public const string AdminOnly = "AdminOnly"; public const string AdminOnly = "AdminOnly";
// Either full Admin or RestrictedAdmin — read-only fleet visibility (filtered by RLS).
public const string AnyAdmin = "AnyAdmin";
} }
public static class FleetSchema public static class FleetSchema

View File

@ -0,0 +1,10 @@
namespace Tau.Acuvim.Portal.DTOs;
public sealed record UserCustomerAccessDto(
string UserId,
string Email,
bool IsFullAdmin,
bool IsRestrictedAdmin,
IReadOnlyList<Guid> AllowedCustomerIds);
public sealed record SetCustomerAccessRequest(IReadOnlyList<Guid> CustomerIds);

View File

@ -0,0 +1,39 @@
namespace Tau.Acuvim.Portal.DTOs;
public sealed record DashboardSummaryDto(
double TotalKwh,
double? CurrentActivePowerKw,
int ActiveDeviceCount,
decimal? TotalCost,
IReadOnlyList<DashboardDeviceRowDto> Devices,
IReadOnlyList<DashboardChartPointDto> Chart);
public sealed record DashboardDeviceRowDto(
Guid DeviceId,
string DeviceName,
string? SiteName,
double Kwh,
double? PeakKw,
DateTime? LastSeen,
decimal? Cost);
public sealed record DashboardChartPointDto(DateTime Time, double TotalKw);
public sealed record RawMeasurementRow(
DateTime Time,
string DeviceName,
string? SiteName,
double ActivePowerKw,
double? ReactivePowerKvar,
double? ApparentPowerKva,
double? PowerFactor,
double? VoltageV,
double? FrequencyHz,
double? EnergyImportedKwh,
double? EnergyExportedKwh);
public sealed record RawMeasurementsPage(
long TotalCount,
int Limit,
int Offset,
IReadOnlyList<RawMeasurementRow> Rows);

View File

@ -18,6 +18,14 @@ public sealed record DeviceDto(
string? Description, string? Description,
bool IsActive); bool IsActive);
public sealed record DeviceWithSiteDto(
Guid Id,
string Name,
string ExternalId,
bool IsActive,
Guid SiteId,
string SiteName);
public sealed record UpsertDeviceRequest(string Name, string ExternalId, string? Description, bool IsActive); public sealed record UpsertDeviceRequest(string Name, string ExternalId, string? Description, bool IsActive);
public sealed record MeasurementIngestRow( public sealed record MeasurementIngestRow(

View File

@ -12,11 +12,13 @@ public sealed record CreateUserRequest(
string Email, string Email,
string DisplayName, string DisplayName,
string Password, string Password,
bool IsAdmin); bool IsAdmin,
bool IsRestrictedAdmin = false);
public sealed record UpdateUserRequest( public sealed record UpdateUserRequest(
string DisplayName, string DisplayName,
bool IsActive, bool IsActive,
bool IsAdmin); bool IsAdmin,
bool IsRestrictedAdmin = false);
public sealed record ResetPasswordRequest(string NewPassword); public sealed record ResetPasswordRequest(string NewPassword);

View File

@ -4,14 +4,27 @@ using Microsoft.EntityFrameworkCore;
using Tau.Acuvim.Portal.Domain.Branding; using Tau.Acuvim.Portal.Domain.Branding;
using Tau.Acuvim.Portal.Domain.Fleet; using Tau.Acuvim.Portal.Domain.Fleet;
using Tau.Acuvim.Portal.Domain.Identity; using Tau.Acuvim.Portal.Domain.Identity;
using Tau.Acuvim.Portal.Services;
namespace Tau.Acuvim.Portal.Data; namespace Tau.Acuvim.Portal.Data;
// Admin-mode DbContext: identity + branding (shared) + fleet schema. // Admin-mode DbContext: identity + branding (shared) + fleet schema.
// Used when RunMode=Admin against the central Postgres. // Used when RunMode=Admin against the central Postgres.
//
// Per-customer access is enforced via EF Core HasQueryFilter (application-level RLS).
// Every fleet.* DbSet has a filter that consults the scoped RlsContext at query time:
// AllowAll → no filter applied (all rows visible)
// else → row's CustomerId must be in AllowedCustomerIds
// Default RlsContext state is fail-closed (no rows). The middleware populates from
// the user's claims; the bootstrapper Elevate()'s for trusted system code.
public class AdminDbContext : IdentityDbContext<ApplicationUser, IdentityRole, string>, IWhiteLabelStore public class AdminDbContext : IdentityDbContext<ApplicationUser, IdentityRole, string>, IWhiteLabelStore
{ {
public AdminDbContext(DbContextOptions<AdminDbContext> options) : base(options) { } private readonly RlsContext _rls;
public AdminDbContext(DbContextOptions<AdminDbContext> options, RlsContext rls) : base(options)
{
_rls = rls;
}
public DbSet<WhiteLabelSettings> WhiteLabelSettings => Set<WhiteLabelSettings>(); public DbSet<WhiteLabelSettings> WhiteLabelSettings => Set<WhiteLabelSettings>();
public DbSet<Customer> Customers => Set<Customer>(); public DbSet<Customer> Customers => Set<Customer>();
@ -22,6 +35,7 @@ public class AdminDbContext : IdentityDbContext<ApplicationUser, IdentityRole, s
public DbSet<FleetTariff> FleetTariffs => Set<FleetTariff>(); public DbSet<FleetTariff> FleetTariffs => Set<FleetTariff>();
public DbSet<FleetTariffPeriod> FleetTariffPeriods => Set<FleetTariffPeriod>(); public DbSet<FleetTariffPeriod> FleetTariffPeriods => Set<FleetTariffPeriod>();
public DbSet<IngestEvent> IngestEvents => Set<IngestEvent>(); public DbSet<IngestEvent> IngestEvents => Set<IngestEvent>();
public DbSet<AdminCustomerAccess> AdminCustomerAccess => Set<AdminCustomerAccess>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
@ -35,11 +49,8 @@ public class AdminDbContext : IdentityDbContext<ApplicationUser, IdentityRole, s
entity.ToTable("Customers", schema: "fleet"); entity.ToTable("Customers", schema: "fleet");
entity.HasIndex(x => x.Code).IsUnique(); entity.HasIndex(x => x.Code).IsUnique();
entity.HasIndex(x => x.TokenHash).IsUnique(); entity.HasIndex(x => x.TokenHash).IsUnique();
// Non-unique: previous-hash slots get overwritten on subsequent rotations,
// and (mathematically) won't collide with current hashes since both are
// SHA-256 of 32 random bytes. Index just makes the OR-lookup in
// FindByTokenAsync cheap.
entity.HasIndex(x => x.PreviousTokenHash); entity.HasIndex(x => x.PreviousTokenHash);
entity.HasQueryFilter(c => _rls.AllowAll || _rls.AllowedCustomerIds.Contains(c.Id));
}); });
builder.Entity<FleetSite>(entity => builder.Entity<FleetSite>(entity =>
@ -50,6 +61,7 @@ public class AdminDbContext : IdentityDbContext<ApplicationUser, IdentityRole, s
.WithMany() .WithMany()
.HasForeignKey(x => x.CustomerId) .HasForeignKey(x => x.CustomerId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
entity.HasQueryFilter(s => _rls.AllowAll || _rls.AllowedCustomerIds.Contains(s.CustomerId));
}); });
builder.Entity<FleetDevice>(entity => builder.Entity<FleetDevice>(entity =>
@ -64,6 +76,7 @@ public class AdminDbContext : IdentityDbContext<ApplicationUser, IdentityRole, s
.WithMany() .WithMany()
.HasForeignKey(x => new { x.CustomerId, x.SiteId }) .HasForeignKey(x => new { x.CustomerId, x.SiteId })
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
entity.HasQueryFilter(d => _rls.AllowAll || _rls.AllowedCustomerIds.Contains(d.CustomerId));
}); });
builder.Entity<FleetPowerMeasurement>(entity => builder.Entity<FleetPowerMeasurement>(entity =>
@ -76,6 +89,7 @@ public class AdminDbContext : IdentityDbContext<ApplicationUser, IdentityRole, s
.WithMany() .WithMany()
.HasForeignKey(x => new { x.CustomerId, x.DeviceId }) .HasForeignKey(x => new { x.CustomerId, x.DeviceId })
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
entity.HasQueryFilter(m => _rls.AllowAll || _rls.AllowedCustomerIds.Contains(m.CustomerId));
}); });
builder.Entity<IngestEvent>(entity => builder.Entity<IngestEvent>(entity =>
@ -86,6 +100,7 @@ public class AdminDbContext : IdentityDbContext<ApplicationUser, IdentityRole, s
.WithMany() .WithMany()
.HasForeignKey(x => x.CustomerId) .HasForeignKey(x => x.CustomerId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
entity.HasQueryFilter(e => _rls.AllowAll || _rls.AllowedCustomerIds.Contains(e.CustomerId));
}); });
builder.Entity<FleetMunicipality>(entity => builder.Entity<FleetMunicipality>(entity =>
@ -96,6 +111,7 @@ public class AdminDbContext : IdentityDbContext<ApplicationUser, IdentityRole, s
.WithMany() .WithMany()
.HasForeignKey(x => x.CustomerId) .HasForeignKey(x => x.CustomerId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
entity.HasQueryFilter(m => _rls.AllowAll || _rls.AllowedCustomerIds.Contains(m.CustomerId));
}); });
builder.Entity<FleetTariff>(entity => builder.Entity<FleetTariff>(entity =>
@ -114,6 +130,7 @@ public class AdminDbContext : IdentityDbContext<ApplicationUser, IdentityRole, s
.WithMany() .WithMany()
.HasForeignKey(x => new { x.CustomerId, x.MunicipalityId }) .HasForeignKey(x => new { x.CustomerId, x.MunicipalityId })
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
entity.HasQueryFilter(t => _rls.AllowAll || _rls.AllowedCustomerIds.Contains(t.CustomerId));
}); });
builder.Entity<FleetTariffPeriod>(entity => builder.Entity<FleetTariffPeriod>(entity =>
@ -129,6 +146,20 @@ public class AdminDbContext : IdentityDbContext<ApplicationUser, IdentityRole, s
.WithMany(t => t.Periods) .WithMany(t => t.Periods)
.HasForeignKey(x => new { x.CustomerId, x.TariffId }) .HasForeignKey(x => new { x.CustomerId, x.TariffId })
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
entity.HasQueryFilter(p => _rls.AllowAll || _rls.AllowedCustomerIds.Contains(p.CustomerId));
});
builder.Entity<AdminCustomerAccess>(entity =>
{
entity.ToTable("AdminCustomerAccess", schema: "app");
entity.HasKey(x => new { x.UserId, x.CustomerId });
entity.HasOne<ApplicationUser>()
.WithMany()
.HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
// No FK to fleet.Customers — access row can outlive a deleted customer
// (harmless dead data; ops can prune if desired).
entity.HasIndex(x => x.CustomerId);
}); });
} }
} }

View File

@ -0,0 +1,11 @@
namespace Tau.Acuvim.Portal.Domain.Identity;
// Maps a RestrictedAdmin user to a Customer they're allowed to see.
// Composite PK (UserId, CustomerId). Full Admin users don't need any rows here —
// the middleware grants them ALL via role check.
public class AdminCustomerAccess
{
public string UserId { get; set; } = string.Empty;
public Guid CustomerId { get; set; }
public DateTime GrantedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,75 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Tau.Acuvim.Portal.Constants;
using Tau.Acuvim.Portal.Data;
using Tau.Acuvim.Portal.Domain.Identity;
using Tau.Acuvim.Portal.DTOs;
using Tau.Acuvim.Portal.Services;
namespace Tau.Acuvim.Portal.Endpoints;
public static class AdminCustomerAccessEndpoints
{
public static IEndpointRouteBuilder MapAdminCustomerAccessEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/admin/users")
.RequireAuthorization(Policies.AdminOnly)
.WithTags("Admin / Customer access");
group.MapGet("/{userId}/customer-access", async (
string userId, AdminDbContext db, UserManager<ApplicationUser> users) =>
{
var user = await users.FindByIdAsync(userId);
if (user is null) return Results.NotFound();
var roles = await users.GetRolesAsync(user);
var allowed = await db.AdminCustomerAccess.AsNoTracking()
.Where(a => a.UserId == userId)
.Select(a => a.CustomerId)
.ToListAsync();
return Results.Ok(new UserCustomerAccessDto(
UserId: user.Id,
Email: user.Email ?? "",
IsFullAdmin: roles.Contains(Roles.Admin),
IsRestrictedAdmin: roles.Contains(Roles.RestrictedAdmin),
AllowedCustomerIds: allowed));
});
group.MapPut("/{userId}/customer-access", async (
string userId, SetCustomerAccessRequest req,
AdminDbContext db, UserManager<ApplicationUser> users, RlsContext rls) =>
{
var user = await users.FindByIdAsync(userId);
if (user is null) return Results.NotFound();
// AdminOnly policy gates this endpoint, so middleware already set AllowAll
// for the caller. Re-elevate defensively so this works even if invoked from
// a future code path with a narrower scope.
using var _ = rls.Elevate();
var existingCustomerIds = await db.Customers.AsNoTracking()
.Select(c => c.Id).ToListAsync();
var validRequested = req.CustomerIds.Distinct()
.Where(id => existingCustomerIds.Contains(id)).ToList();
var current = await db.AdminCustomerAccess
.Where(a => a.UserId == userId).ToListAsync();
db.AdminCustomerAccess.RemoveRange(current);
foreach (var cid in validRequested)
{
db.AdminCustomerAccess.Add(new AdminCustomerAccess
{
UserId = userId,
CustomerId = cid,
GrantedAt = DateTime.UtcNow
});
}
await db.SaveChangesAsync();
return Results.NoContent();
});
return app;
}
}

View File

@ -12,8 +12,12 @@ public static class AdminCustomersEndpoints
.RequireAuthorization(Policies.AdminOnly) .RequireAuthorization(Policies.AdminOnly)
.WithTags("Admin / Customers"); .WithTags("Admin / Customers");
group.MapGet("/", async (CustomerService svc, CancellationToken ct) => // List is exposed with AnyAdmin so RestrictedAdmin users can also see their
Results.Ok(await svc.ListAsync(ct))); // (RLS-filtered) customer list. Create/update/rotate/delete stay AdminOnly.
app.MapGet("/api/admin/customers/", async (CustomerService svc, CancellationToken ct) =>
Results.Ok(await svc.ListAsync(ct)))
.RequireAuthorization(Policies.AnyAdmin)
.WithTags("Admin / Customers");
group.MapPost("/", async (CreateCustomerRequest req, CustomerService svc, CancellationToken ct) => group.MapPost("/", async (CreateCustomerRequest req, CustomerService svc, CancellationToken ct) =>
{ {

View File

@ -53,6 +53,11 @@ public static class AdminUserEndpoints
var role = await users.AddToRoleAsync(user, Roles.Admin); var role = await users.AddToRoleAsync(user, Roles.Admin);
if (!role.Succeeded) return IdentityProblem(role); if (!role.Succeeded) return IdentityProblem(role);
} }
if (req.IsRestrictedAdmin)
{
var role = await users.AddToRoleAsync(user, Roles.RestrictedAdmin);
if (!role.Succeeded) return IdentityProblem(role);
}
var roles = await users.GetRolesAsync(user); var roles = await users.GetRolesAsync(user);
return Results.Created($"/api/admin/users/{user.Id}", new UserListItemDto( return Results.Created($"/api/admin/users/{user.Id}", new UserListItemDto(
@ -81,6 +86,18 @@ public static class AdminUserEndpoints
if (!remove.Succeeded) return IdentityProblem(remove); if (!remove.Succeeded) return IdentityProblem(remove);
} }
var hasRestricted = await users.IsInRoleAsync(user, Roles.RestrictedAdmin);
if (req.IsRestrictedAdmin && !hasRestricted)
{
var add = await users.AddToRoleAsync(user, Roles.RestrictedAdmin);
if (!add.Succeeded) return IdentityProblem(add);
}
else if (!req.IsRestrictedAdmin && hasRestricted)
{
var remove = await users.RemoveFromRoleAsync(user, Roles.RestrictedAdmin);
if (!remove.Succeeded) return IdentityProblem(remove);
}
return Results.NoContent(); return Results.NoContent();
}); });

View File

@ -61,6 +61,18 @@ public static class AuthEndpoints
return Results.Ok(new CurrentUserResponse(user.Email!, user.DisplayName, roles.ToArray())); return Results.Ok(new CurrentUserResponse(user.Email!, user.DisplayName, roles.ToArray()));
}).RequireAuthorization(); }).RequireAuthorization();
// Cookie-only liveness check for Traefik forwardAuth on the Grafana embed.
// Must stay cheap — Traefik calls it on every Grafana sub-request (panel,
// CSS, JSON). No DB hit; the cookie's validity already implies a signed-in
// session. Disabled-account revocation rides on the cookie's expiry +
// SecurityStampValidator's periodic re-check.
group.MapGet("/check", (HttpContext ctx) =>
ctx.User?.Identity?.IsAuthenticated == true
? Results.NoContent()
: Results.Unauthorized())
.AllowAnonymous()
.WithName("AuthCheck");
return app; return app;
} }
} }

View File

@ -0,0 +1,63 @@
using Tau.Acuvim.Portal.Services;
namespace Tau.Acuvim.Portal.Endpoints;
// Client-mode landing page data + raw-measurement export. Both auth-required.
public static class DashboardEndpoints
{
private const string XlsxContentType =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
public static IEndpointRouteBuilder MapDashboardEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/dashboard")
.RequireAuthorization()
.WithTags("Dashboard");
group.MapGet("/summary", async (
DateTime? from, DateTime? to,
DashboardSummaryService svc, CancellationToken ct) =>
{
var (f, t) = ResolveRange(from, to);
if (t <= f) return Results.BadRequest(new { error = "'to' must be after 'from'." });
var summary = await svc.ComputeAsync(f, t, ct);
return Results.Ok(summary);
});
group.MapGet("/summary/export.xlsx", async (
DateTime? from, DateTime? to,
DashboardSummaryService svc, ExcelExportService excel, CancellationToken ct) =>
{
var (f, t) = ResolveRange(from, to);
if (t <= f) return Results.BadRequest(new { error = "'to' must be after 'from'." });
var summary = await svc.ComputeAsync(f, t, ct);
var bytes = excel.BuildDashboardSummary(summary, f, t);
var filename = $"dashboard-{f:yyyyMMdd}-{t:yyyyMMdd}.xlsx";
return Results.File(bytes, XlsxContentType, filename);
});
// Raw measurements export. Hard-capped row count to keep response sane;
// operator can shrink the date range or use Grafana for larger pulls.
group.MapGet("/measurements/export.xlsx", async (
DateTime? from, DateTime? to, int? rowCap,
DashboardSummaryService svc, ExcelExportService excel, CancellationToken ct) =>
{
var (f, t) = ResolveRange(from, to);
if (t <= f) return Results.BadRequest(new { error = "'to' must be after 'from'." });
var rows = await svc.ReadRawAsync(f, t, rowCap ?? 100_000, ct);
var bytes = excel.BuildRawMeasurements(rows, f, t);
var filename = $"measurements-{f:yyyyMMdd}-{t:yyyyMMdd}.xlsx";
return Results.File(bytes, XlsxContentType, filename);
});
return app;
}
private static (DateTime From, DateTime To) ResolveRange(DateTime? from, DateTime? to)
{
var now = DateTime.UtcNow;
var t = to ?? now;
var f = from ?? t.Date; // default = midnight UTC today
return (DateTime.SpecifyKind(f, DateTimeKind.Utc), DateTime.SpecifyKind(t, DateTimeKind.Utc));
}
}

View File

@ -1,19 +1,35 @@
using Microsoft.EntityFrameworkCore;
using Tau.Acuvim.Portal.Constants; using Tau.Acuvim.Portal.Constants;
using Tau.Acuvim.Portal.Data;
using Tau.Acuvim.Portal.Services; using Tau.Acuvim.Portal.Services;
namespace Tau.Acuvim.Portal.Endpoints; namespace Tau.Acuvim.Portal.Endpoints;
public static class FleetDashboardEndpoints public static class FleetDashboardEndpoints
{ {
private const string XlsxContentType =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
public static IEndpointRouteBuilder MapFleetDashboardEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapFleetDashboardEndpoints(this IEndpointRouteBuilder app)
{ {
// AnyAdmin (full Admin OR RestrictedAdmin). RLS filters which customers
// are visible — RestrictedAdmin only sees their assigned customers.
var group = app.MapGroup("/api/fleet") var group = app.MapGroup("/api/fleet")
.RequireAuthorization(Policies.AdminOnly) .RequireAuthorization(Policies.AnyAdmin)
.WithTags("Fleet"); .WithTags("Fleet");
group.MapGet("/dashboard", async (FleetQueryService svc, CancellationToken ct) => group.MapGet("/dashboard", async (FleetQueryService svc, CancellationToken ct) =>
Results.Ok(await svc.GetDashboardAsync(ct))); Results.Ok(await svc.GetDashboardAsync(ct)));
group.MapGet("/dashboard/export.xlsx", async (
FleetQueryService svc, ExcelExportService excel, CancellationToken ct) =>
{
var data = await svc.GetDashboardAsync(ct);
var bytes = excel.BuildFleetDashboard(data);
var filename = $"fleet-dashboard-{DateTime.UtcNow:yyyyMMddHHmmss}.xlsx";
return Results.File(bytes, XlsxContentType, filename);
});
group.MapGet("/customers/{id:guid}/detail", async (Guid id, FleetQueryService svc, CancellationToken ct) => group.MapGet("/customers/{id:guid}/detail", async (Guid id, FleetQueryService svc, CancellationToken ct) =>
{ {
var detail = await svc.GetCustomerDetailAsync(id, ct); var detail = await svc.GetCustomerDetailAsync(id, ct);
@ -34,6 +50,32 @@ public static class FleetDashboardEndpoints
} }
}); });
group.MapGet("/customers/{id:guid}/cost/export.xlsx", async (
Guid id, DateTime from, DateTime to,
FleetCostService cost, ExcelExportService excel,
AdminDbContext db, CancellationToken ct) =>
{
// Look up code/name through the RLS-filtered DbSet so RestrictedAdmins
// who can't see this customer get 404 (their AllowedCustomerIds filters them out).
var customer = await db.Customers.AsNoTracking()
.Where(c => c.Id == id)
.Select(c => new { c.Code, c.Name })
.FirstOrDefaultAsync(ct);
if (customer is null) return Results.NotFound();
try
{
var data = await cost.ComputeAsync(id, from, to, ct);
var bytes = excel.BuildFleetCustomerCost(data, customer.Code, customer.Name);
var filename = $"cost-{customer.Code}-{from:yyyyMMdd}-{to:yyyyMMdd}.xlsx";
return Results.File(bytes, XlsxContentType, filename);
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
});
return app; return app;
} }
} }

View File

@ -6,6 +6,9 @@ namespace Tau.Acuvim.Portal.Endpoints;
public static class MeasurementsEndpoints public static class MeasurementsEndpoints
{ {
private const string XlsxContentType =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
public static IEndpointRouteBuilder MapMeasurementsEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapMeasurementsEndpoints(this IEndpointRouteBuilder app)
{ {
var read = app.MapGroup("/api/measurements") var read = app.MapGroup("/api/measurements")
@ -25,6 +28,45 @@ public static class MeasurementsEndpoints
return Results.Ok(rows); return Results.Ok(rows);
}); });
// Raw measurement list (Measurements page). Optional comma-separated
// deviceIds. Paginated; preview-friendly limits.
read.MapGet("/raw", async (
DateTime from, DateTime to,
string? deviceIds, int? limit, int? offset,
DashboardSummaryService svc, CancellationToken ct) =>
{
if (to <= from) return Results.BadRequest(new { error = "'to' must be after 'from'." });
if (!TryParseDeviceIds(deviceIds, out var ids, out var parseError))
{
return Results.BadRequest(new { error = parseError });
}
var resolvedLimit = limit is > 0 and <= 1000 ? limit.Value : 200;
var resolvedOffset = offset is > 0 ? offset.Value : 0;
var rows = await svc.ReadRawAsync(from, to, ids, resolvedLimit, resolvedOffset, ct);
var total = await svc.CountRawAsync(from, to, ids, ct);
return Results.Ok(new RawMeasurementsPage(total, resolvedLimit, resolvedOffset, rows));
});
// Raw measurement export. Same filters as /raw but no pagination — capped
// at 250 000 rows for memory safety.
read.MapGet("/raw/export.xlsx", async (
DateTime from, DateTime to,
string? deviceIds, int? rowCap,
DashboardSummaryService svc, ExcelExportService excel, CancellationToken ct) =>
{
if (to <= from) return Results.BadRequest(new { error = "'to' must be after 'from'." });
if (!TryParseDeviceIds(deviceIds, out var ids, out var parseError))
{
return Results.BadRequest(new { error = parseError });
}
var cap = rowCap is > 0 and <= 250_000 ? rowCap.Value : 100_000;
var rows = await svc.ReadRawAsync(from, to, ids, cap, offset: 0, ct);
var bytes = excel.BuildRawMeasurements(rows, from, to);
var meterSuffix = ids is { Count: 1 } ? "-1meter" : ids is { Count: > 1 } ? $"-{ids.Count}meters" : "";
var filename = $"measurements{meterSuffix}-{from:yyyyMMdd}-{to:yyyyMMdd}.xlsx";
return Results.File(bytes, XlsxContentType, filename);
});
var ingest = app.MapGroup("/api/ingest") var ingest = app.MapGroup("/api/ingest")
.RequireAuthorization(Policies.AdminOnly) .RequireAuthorization(Policies.AdminOnly)
.WithTags("Ingest"); .WithTags("Ingest");
@ -42,4 +84,25 @@ public static class MeasurementsEndpoints
return app; return app;
} }
private static bool TryParseDeviceIds(string? raw, out List<Guid>? ids, out string? error)
{
ids = null;
error = null;
if (string.IsNullOrWhiteSpace(raw)) return true;
var parts = raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var parsed = new List<Guid>(parts.Length);
foreach (var p in parts)
{
if (!Guid.TryParse(p, out var g))
{
error = $"deviceIds contains an invalid GUID: '{p}'.";
return false;
}
parsed.Add(g);
}
ids = parsed;
return true;
}
} }

View File

@ -33,6 +33,18 @@ public static class SitesEndpoints
return Results.Ok(devices); return Results.Ok(devices);
}); });
// Flat list of every device with its site name. Drives the device filter
// on the Measurements page so the picker is populated in one round-trip.
read.MapGet("/devices", async (AppDbContext db, CancellationToken ct) =>
{
var rows = await (from d in db.Devices.AsNoTracking()
join s in db.Sites.AsNoTracking() on d.SiteId equals s.Id
orderby s.Name, d.Name
select new DeviceWithSiteDto(d.Id, d.Name, d.ExternalId, d.IsActive, s.Id, s.Name))
.ToListAsync(ct);
return Results.Ok(rows);
});
var admin = app.MapGroup("/api/admin/sites") var admin = app.MapGroup("/api/admin/sites")
.RequireAuthorization(Policies.AdminOnly) .RequireAuthorization(Policies.AdminOnly)
.WithTags("Admin / Sites"); .WithTags("Admin / Sites");

View File

@ -0,0 +1,65 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Tau.Acuvim.Portal.Constants;
using Tau.Acuvim.Portal.Data;
using Tau.Acuvim.Portal.Services;
namespace Tau.Acuvim.Portal.Middleware;
// Per-request: derive RLS state from the authenticated user's role + AdminCustomerAccess,
// and apply it to the request-scoped RlsContext. AdminDbContext's HasQueryFilter
// expressions consult this on every query against a fleet.* DbSet.
//
// Decision tree (hybrid fail-closed):
// 1. /api/fleet/ingest path → SetAll() (anonymous; trust the token-validated handler)
// 2. Authenticated Admin role → SetAll()
// 3. Authenticated RestrictedAdmin role → SetScoped() with their assigned CustomerIds
// (empty = SetNone → user sees no rows, correct fail-closed default)
// 4. Anything else (unauth, non-admin role) → SetNone() (no fleet rows visible)
public sealed class CustomerFilterMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, RlsContext rls, AdminDbContext db)
{
await ResolveFilterAsync(context, db, rls);
await next(context);
}
private static async Task ResolveFilterAsync(HttpContext ctx, AdminDbContext db, RlsContext rls)
{
if (ctx.Request.Path.StartsWithSegments("/api/fleet/ingest"))
{
rls.SetAll();
return;
}
var user = ctx.User;
if (user?.Identity?.IsAuthenticated != true)
{
rls.SetNone();
return;
}
if (user.IsInRole(Roles.Admin))
{
rls.SetAll();
return;
}
if (user.IsInRole(Roles.RestrictedAdmin))
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) { rls.SetNone(); return; }
// AdminCustomerAccess is the access-mapping table (not a fleet.* entity),
// so it has no query filter. Read it directly.
var allowed = await db.AdminCustomerAccess.AsNoTracking()
.Where(a => a.UserId == userId)
.Select(a => a.CustomerId)
.ToListAsync();
rls.SetScoped(allowed);
return;
}
rls.SetNone();
}
}

View File

@ -0,0 +1,797 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tau.Acuvim.Portal.Data;
#nullable disable
namespace Tau.Acuvim.Portal.Migrations.Admin
{
[DbContext(typeof(AdminDbContext))]
[Migration("20260518095759_AddAdminCustomerAccess")]
partial class AddAdminCustomerAccess
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("app")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", "identity");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Branding.WhiteLabelSettings", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<string>("AccentColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ApplicationName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("FooterText")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("LogoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("PrimaryColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("SecondaryColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("WhiteLabelSettings", "app");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.Customer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("FirstSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime?>("PreviousTokenExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PreviousTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("TokenIssuedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("TokenRotatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("PreviousTokenHash");
b.HasIndex("TokenHash")
.IsUnique();
b.ToTable("Customers", "fleet");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", b =>
{
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("SiteId")
.HasColumnType("uuid");
b.HasKey("CustomerId", "Id");
b.HasIndex("CustomerId", "SiteId");
b.ToTable("Devices", "fleet");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetMunicipality", b =>
{
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TimeZoneId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("CustomerId", "Id");
b.ToTable("Municipalities", "fleet");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetPowerMeasurement", b =>
{
b.Property<DateTime>("Time")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<Guid>("DeviceId")
.HasColumnType("uuid");
b.Property<double>("ActivePowerKw")
.HasColumnType("double precision");
b.Property<double?>("ApparentPowerKva")
.HasColumnType("double precision");
b.Property<double?>("EnergyExportedKwh")
.HasColumnType("double precision");
b.Property<double?>("EnergyImportedKwh")
.HasColumnType("double precision");
b.Property<double?>("FrequencyHz")
.HasColumnType("double precision");
b.Property<double?>("PowerFactor")
.HasColumnType("double precision");
b.Property<double?>("ReactivePowerKvar")
.HasColumnType("double precision");
b.Property<string>("Source")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<double?>("VoltageV")
.HasColumnType("double precision");
b.HasKey("Time", "CustomerId", "DeviceId");
b.HasIndex("CustomerId", "Time")
.IsDescending(false, true);
b.HasIndex("CustomerId", "DeviceId", "Time")
.IsDescending(false, false, true);
b.ToTable("PowerMeasurements", "fleet");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", b =>
{
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<int?>("LocalMunicipalityId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("CustomerId", "Id");
b.ToTable("Sites", "fleet");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariff", b =>
{
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<decimal>("DefaultRatePerKwh")
.HasPrecision(10, 4)
.HasColumnType("numeric(10,4)");
b.Property<DateOnly>("EffectiveFrom")
.HasColumnType("date");
b.Property<DateOnly?>("EffectiveTo")
.HasColumnType("date");
b.Property<decimal>("FixedMonthlyCharge")
.HasPrecision(10, 2)
.HasColumnType("numeric(10,2)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<int>("MunicipalityId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("VatPercentage")
.HasPrecision(5, 2)
.HasColumnType("numeric(5,2)");
b.HasKey("CustomerId", "Id");
b.HasIndex("CustomerId", "MunicipalityId", "EffectiveFrom");
b.ToTable("Tariffs", "fleet");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariffPeriod", b =>
{
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<int>("DaysOfWeek")
.HasColumnType("integer");
b.Property<TimeOnly>("EndTime")
.HasColumnType("time without time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<decimal>("RatePerKwh")
.HasPrecision(10, 4)
.HasColumnType("numeric(10,4)");
b.Property<TimeOnly>("StartTime")
.HasColumnType("time without time zone");
b.Property<int>("TariffId")
.HasColumnType("integer");
b.HasKey("CustomerId", "Id");
b.HasIndex("CustomerId", "TariffId");
b.ToTable("TariffPeriods", "fleet");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.IngestEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("BatchBytes")
.HasColumnType("integer");
b.Property<string>("BatchType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ClientHwm")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<string>("Error")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("RowsAccepted")
.HasColumnType("integer");
b.Property<int>("RowsRejected")
.HasColumnType("integer");
b.Property<TimeSpan?>("TimeSpread")
.HasColumnType("interval");
b.HasKey("Id");
b.HasIndex("CustomerId", "ReceivedAt")
.IsDescending(false, true);
b.ToTable("IngestEvents", "fleet");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Identity.AdminCustomerAccess", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<DateTime>("GrantedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("UserId", "CustomerId");
b.HasIndex("CustomerId");
b.ToTable("AdminCustomerAccess", "app");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", "Site")
.WithMany()
.HasForeignKey("CustomerId", "SiteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Customer");
b.Navigation("Site");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetMunicipality", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Customer");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetPowerMeasurement", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", null)
.WithMany()
.HasForeignKey("CustomerId", "DeviceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Customer");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariff", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetMunicipality", "Municipality")
.WithMany()
.HasForeignKey("CustomerId", "MunicipalityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Customer");
b.Navigation("Municipality");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariffPeriod", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetTariff", "Tariff")
.WithMany("Periods")
.HasForeignKey("CustomerId", "TariffId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Customer");
b.Navigation("Tariff");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.IngestEvent", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Customer");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Identity.AdminCustomerAccess", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariff", b =>
{
b.Navigation("Periods");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tau.Acuvim.Portal.Migrations.Admin
{
/// <inheritdoc />
public partial class AddAdminCustomerAccess : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AdminCustomerAccess",
schema: "app",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
CustomerId = table.Column<Guid>(type: "uuid", nullable: false),
GrantedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AdminCustomerAccess", x => new { x.UserId, x.CustomerId });
table.ForeignKey(
name: "FK_AdminCustomerAccess_AspNetUsers_UserId",
column: x => x.UserId,
principalSchema: "identity",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AdminCustomerAccess_CustomerId",
schema: "app",
table: "AdminCustomerAccess",
column: "CustomerId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AdminCustomerAccess",
schema: "app");
}
}
}

View File

@ -532,6 +532,24 @@ namespace Tau.Acuvim.Portal.Migrations.Admin
b.ToTable("IngestEvents", "fleet"); b.ToTable("IngestEvents", "fleet");
}); });
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Identity.AdminCustomerAccess", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<DateTime>("GrantedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("UserId", "CustomerId");
b.HasIndex("CustomerId");
b.ToTable("AdminCustomerAccess", "app");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", b => modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@ -757,6 +775,15 @@ namespace Tau.Acuvim.Portal.Migrations.Admin
b.Navigation("Customer"); b.Navigation("Customer");
}); });
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Identity.AdminCustomerAccess", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariff", b => modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetTariff", b =>
{ {
b.Navigation("Periods"); b.Navigation("Periods");

View File

@ -75,7 +75,13 @@ try
} }
else else
{ {
builder.Services.AddDbContext<AdminDbContext>(options => options.UseNpgsql(resolution.ConnectionString)); // RLS infrastructure — middleware + bootstrapper explicitly SET the Postgres
// session var on the AdminDbContext connection (Npgsql pooling makes a
// DbConnectionInterceptor unreliable; see Services/CustomerFilterExtensions.cs).
builder.Services.AddSingleton<RlsContext>();
builder.Services.AddDbContext<AdminDbContext>(options =>
options.UseNpgsql(resolution.ConnectionString));
builder.Services.AddScoped<IWhiteLabelStore>(sp => sp.GetRequiredService<AdminDbContext>()); builder.Services.AddScoped<IWhiteLabelStore>(sp => sp.GetRequiredService<AdminDbContext>());
builder.Services builder.Services
.AddIdentity<ApplicationUser, IdentityRole>(ConfigureIdentity(authOptions)) .AddIdentity<ApplicationUser, IdentityRole>(ConfigureIdentity(authOptions))
@ -108,6 +114,8 @@ try
builder.Services.AddAuthorization(options => builder.Services.AddAuthorization(options =>
{ {
options.AddPolicy(Policies.AdminOnly, policy => policy.RequireRole(Roles.Admin)); options.AddPolicy(Policies.AdminOnly, policy => policy.RequireRole(Roles.Admin));
options.AddPolicy(Policies.AnyAdmin, policy =>
policy.RequireRole(Roles.Admin, Roles.RestrictedAdmin));
}); });
var keyRingPath = builder.Configuration["DataProtection:KeyRing"] ?? "/data/keys"; var keyRingPath = builder.Configuration["DataProtection:KeyRing"] ?? "/data/keys";
@ -125,6 +133,7 @@ try
builder.Services.AddScoped<IdentityBootstrapper>(); builder.Services.AddScoped<IdentityBootstrapper>();
builder.Services.AddSingleton<GrafanaService>(); builder.Services.AddSingleton<GrafanaService>();
builder.Services.AddScoped<ConfigOverviewService>(); builder.Services.AddScoped<ConfigOverviewService>();
builder.Services.AddSingleton<ExcelExportService>();
if (applicationOptions.RunMode == RunMode.Client) if (applicationOptions.RunMode == RunMode.Client)
{ {
@ -133,6 +142,7 @@ try
builder.Services.AddScoped<TimescaleBootstrapper>(); builder.Services.AddScoped<TimescaleBootstrapper>();
builder.Services.AddScoped<MeasurementIngestService>(); builder.Services.AddScoped<MeasurementIngestService>();
builder.Services.AddScoped<MeasurementQueryService>(); builder.Services.AddScoped<MeasurementQueryService>();
builder.Services.AddScoped<DashboardSummaryService>();
if (fleetIngestOptions.Enabled) if (fleetIngestOptions.Enabled)
{ {
@ -245,6 +255,14 @@ try
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// RLS filter resolution — only when running as the Admin stack. Sits AFTER auth
// so HttpContext.User is populated. The CustomerFilterInterceptor reads from
// RlsContext when EF opens connections.
if (applicationOptions.RunMode == RunMode.Admin)
{
app.UseMiddleware<Tau.Acuvim.Portal.Middleware.CustomerFilterMiddleware>();
}
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
// Endpoint mapping — shared first, then mode-specific. // Endpoint mapping — shared first, then mode-specific.
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
@ -261,10 +279,12 @@ try
app.MapAdminRatesEndpoints(); app.MapAdminRatesEndpoints();
app.MapSitesEndpoints(); app.MapSitesEndpoints();
app.MapMeasurementsEndpoints(); app.MapMeasurementsEndpoints();
app.MapDashboardEndpoints();
} }
else else
{ {
app.MapAdminCustomersEndpoints(); app.MapAdminCustomersEndpoints();
app.MapAdminCustomerAccessEndpoints();
app.MapFleetIngestEndpoints(); app.MapFleetIngestEndpoints();
app.MapFleetDashboardEndpoints(); app.MapFleetDashboardEndpoints();
} }

View File

@ -0,0 +1,393 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using Tau.Acuvim.Portal.Data;
using Tau.Acuvim.Portal.Domain.Rates;
using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Services;
// Client-mode dashboard rollup. Reuses monitoring.PowerMeasurements (no continuous
// aggregate locally — one customer's volume is small enough). Cost is computed by
// hourly-bucketing kWh deltas and matching the site's municipality's active tariff
// per bucket — same shape as FleetCostService but against the local schema. Cost
// is best-effort: devices whose site has no municipality (or whose municipality has
// no active tariff for that bucket date) contribute kWh but not cost.
public sealed class DashboardSummaryService(AppDbContext db, ILogger<DashboardSummaryService> log)
{
public const int ChartBucketMinutes = 15;
private static readonly TimeSpan ActiveDeviceWindow = TimeSpan.FromMinutes(15);
public async Task<DashboardSummaryDto> ComputeAsync(
DateTime fromUtc, DateTime toUtc, CancellationToken ct = default)
{
if (toUtc <= fromUtc) throw new ArgumentException("'to' must be after 'from'.");
var from = DateTime.SpecifyKind(fromUtc, DateTimeKind.Utc);
var to = DateTime.SpecifyKind(toUtc, DateTimeKind.Utc);
// 1. Device → site → muni mapping (small, one query).
var devices = await (
from d in db.Devices.AsNoTracking()
join s in db.Sites.AsNoTracking() on d.SiteId equals s.Id
select new
{
d.Id,
d.Name,
SiteId = s.Id,
SiteName = s.Name,
s.MunicipalityId,
d.IsActive,
}).ToListAsync(ct);
if (devices.Count == 0)
{
return new DashboardSummaryDto(
TotalKwh: 0, CurrentActivePowerKw: null, ActiveDeviceCount: 0, TotalCost: null,
Devices: Array.Empty<DashboardDeviceRowDto>(),
Chart: Array.Empty<DashboardChartPointDto>());
}
// 2. Active tariffs grouped by municipality, used by the cost loop below.
var tariffs = await db.Tariffs.AsNoTracking()
.Include(t => t.Periods)
.Where(t => t.IsActive)
.ToListAsync(ct);
var tariffsByMuni = tariffs.GroupBy(t => t.MunicipalityId)
.ToDictionary(g => g.Key, g => g.OrderByDescending(t => t.EffectiveFrom).ToList());
var munis = await db.Municipalities.AsNoTracking().ToListAsync(ct);
var muniMap = munis.ToDictionary(m => m.Id);
// 3. Per-device kWh + peak kW + last seen (one query).
var perDevice = await ReadPerDeviceTotalsAsync(from, to, ct);
// 4. Hourly buckets for cost.
var hourly = await ReadHourlyKwhBucketsAsync(from, to, ct);
var deviceMuniMap = devices.ToDictionary(d => d.Id, d => (int?)d.MunicipalityId);
var (deviceCost, totalCost) = ComputeCost(hourly, deviceMuniMap, muniMap, tariffsByMuni);
// 5. Current active power + active device count.
var (currentKw, activeCount) = await ReadCurrentActivePowerAsync(ct);
// 6. Chart series (sum of avg per-device power per bucket, last 24h within range).
var chartFrom = to - TimeSpan.FromHours(24) > from ? to - TimeSpan.FromHours(24) : from;
var chart = await ReadChartAsync(chartFrom, to, ct);
// 7. Build per-device rows.
var rows = devices
.Select(d =>
{
perDevice.TryGetValue(d.Id, out var totals);
deviceCost.TryGetValue(d.Id, out var cost);
return new DashboardDeviceRowDto(
DeviceId: d.Id,
DeviceName: d.Name,
SiteName: d.SiteName,
Kwh: totals.Kwh,
PeakKw: totals.PeakKw,
LastSeen: totals.LastSeen,
Cost: cost == 0m ? null : cost);
})
.OrderByDescending(r => r.Kwh)
.ToList();
return new DashboardSummaryDto(
TotalKwh: rows.Sum(r => r.Kwh),
CurrentActivePowerKw: currentKw,
ActiveDeviceCount: activeCount,
TotalCost: totalCost == 0m ? null : totalCost,
Devices: rows,
Chart: chart);
}
// ── Raw measurement queries ──────────────────────────────────────────────
// Shared by the Dashboard "Export raw" button (no device filter) and the
// dedicated Measurements page (multi-select device filter + preview pagination).
//
// deviceIds=null OR empty → all devices. Specific list → WHERE "DeviceId" = ANY(...).
// offset is honoured for paginated preview; the export path passes 0.
public Task<List<RawMeasurementRow>> ReadRawAsync(
DateTime fromUtc, DateTime toUtc, int rowCap, CancellationToken ct = default) =>
ReadRawAsync(fromUtc, toUtc, deviceIds: null, limit: rowCap, offset: 0, ct);
public async Task<List<RawMeasurementRow>> ReadRawAsync(
DateTime fromUtc, DateTime toUtc,
IReadOnlyCollection<Guid>? deviceIds,
int limit, int offset, CancellationToken ct = default)
{
if (toUtc <= fromUtc) throw new ArgumentException("'to' must be after 'from'.");
if (limit <= 0 || limit > 250_000) limit = 100_000;
if (offset < 0) offset = 0;
var deviceLookup = await (
from d in db.Devices.AsNoTracking()
join s in db.Sites.AsNoTracking() on d.SiteId equals s.Id
select new { d.Id, d.Name, SiteName = s.Name }
).ToDictionaryAsync(x => x.Id, x => (x.Name, x.SiteName), ct);
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync(ct);
var filterById = deviceIds is { Count: > 0 };
await using var cmd = conn.CreateCommand();
cmd.CommandText = $"""
SELECT "Time", "DeviceId", "ActivePowerKw", "ReactivePowerKvar",
"ApparentPowerKva", "PowerFactor", "VoltageV", "FrequencyHz",
"EnergyImportedKwh", "EnergyExportedKwh"
FROM monitoring."PowerMeasurements"
WHERE "Time" >= @from AND "Time" < @to
{(filterById ? "AND \"DeviceId\" = ANY(@ids)" : "")}
ORDER BY "Time" DESC
LIMIT @limit OFFSET @offset;
""";
cmd.Parameters.Add(new NpgsqlParameter("@from", DateTime.SpecifyKind(fromUtc, DateTimeKind.Utc)));
cmd.Parameters.Add(new NpgsqlParameter("@to", DateTime.SpecifyKind(toUtc, DateTimeKind.Utc)));
cmd.Parameters.Add(new NpgsqlParameter("@limit", limit));
cmd.Parameters.Add(new NpgsqlParameter("@offset", offset));
if (filterById)
{
cmd.Parameters.Add(new NpgsqlParameter("@ids", NpgsqlTypes.NpgsqlDbType.Array | NpgsqlTypes.NpgsqlDbType.Uuid)
{
Value = deviceIds!.ToArray(),
});
}
var rows = new List<RawMeasurementRow>(capacity: 1024);
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
var deviceId = reader.GetGuid(1);
deviceLookup.TryGetValue(deviceId, out var info);
rows.Add(new RawMeasurementRow(
Time: reader.GetDateTime(0),
DeviceName: info.Name ?? deviceId.ToString(),
SiteName: info.SiteName,
ActivePowerKw: reader.GetDouble(2),
ReactivePowerKvar: reader.IsDBNull(3) ? null : reader.GetDouble(3),
ApparentPowerKva: reader.IsDBNull(4) ? null : reader.GetDouble(4),
PowerFactor: reader.IsDBNull(5) ? null : reader.GetDouble(5),
VoltageV: reader.IsDBNull(6) ? null : reader.GetDouble(6),
FrequencyHz: reader.IsDBNull(7) ? null : reader.GetDouble(7),
EnergyImportedKwh: reader.IsDBNull(8) ? null : reader.GetDouble(8),
EnergyExportedKwh: reader.IsDBNull(9) ? null : reader.GetDouble(9)));
}
return rows;
}
public async Task<long> CountRawAsync(
DateTime fromUtc, DateTime toUtc,
IReadOnlyCollection<Guid>? deviceIds, CancellationToken ct = default)
{
if (toUtc <= fromUtc) throw new ArgumentException("'to' must be after 'from'.");
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync(ct);
var filterById = deviceIds is { Count: > 0 };
await using var cmd = conn.CreateCommand();
cmd.CommandText = $"""
SELECT COUNT(*) FROM monitoring."PowerMeasurements"
WHERE "Time" >= @from AND "Time" < @to
{(filterById ? "AND \"DeviceId\" = ANY(@ids)" : "")};
""";
cmd.Parameters.Add(new NpgsqlParameter("@from", DateTime.SpecifyKind(fromUtc, DateTimeKind.Utc)));
cmd.Parameters.Add(new NpgsqlParameter("@to", DateTime.SpecifyKind(toUtc, DateTimeKind.Utc)));
if (filterById)
{
cmd.Parameters.Add(new NpgsqlParameter("@ids", NpgsqlTypes.NpgsqlDbType.Array | NpgsqlTypes.NpgsqlDbType.Uuid)
{
Value = deviceIds!.ToArray(),
});
}
var result = await cmd.ExecuteScalarAsync(ct);
return result is null or DBNull ? 0 : Convert.ToInt64(result);
}
// ── helpers ──────────────────────────────────────────────────────────────
private async Task<Dictionary<Guid, (double Kwh, double? PeakKw, DateTime? LastSeen)>>
ReadPerDeviceTotalsAsync(DateTime from, DateTime to, CancellationToken ct)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT
"DeviceId",
COALESCE(MAX("EnergyImportedKwh") - MIN("EnergyImportedKwh"), 0) AS kwh,
MAX("ActivePowerKw") AS peak_kw,
MAX("Time") AS last_seen
FROM monitoring."PowerMeasurements"
WHERE "Time" >= @from AND "Time" < @to
GROUP BY "DeviceId";
""";
cmd.Parameters.Add(new NpgsqlParameter("@from", from));
cmd.Parameters.Add(new NpgsqlParameter("@to", to));
var result = new Dictionary<Guid, (double, double?, DateTime?)>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
var id = reader.GetGuid(0);
var kwh = reader.IsDBNull(1) ? 0d : reader.GetDouble(1);
double? peak = reader.IsDBNull(2) ? null : reader.GetDouble(2);
DateTime? last = reader.IsDBNull(3) ? null : reader.GetDateTime(3);
result[id] = (kwh, peak, last);
}
return result;
}
private async Task<List<(Guid DeviceId, DateTime Bucket, double Kwh)>>
ReadHourlyKwhBucketsAsync(DateTime from, DateTime to, CancellationToken ct)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT "DeviceId",
time_bucket(INTERVAL '1 hour', "Time") AS bucket,
COALESCE(MAX("EnergyImportedKwh") - MIN("EnergyImportedKwh"), 0) AS kwh
FROM monitoring."PowerMeasurements"
WHERE "Time" >= @from AND "Time" < @to
AND "EnergyImportedKwh" IS NOT NULL
GROUP BY "DeviceId", bucket
ORDER BY bucket;
""";
cmd.Parameters.Add(new NpgsqlParameter("@from", from));
cmd.Parameters.Add(new NpgsqlParameter("@to", to));
var rows = new List<(Guid, DateTime, double)>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
rows.Add((reader.GetGuid(0), reader.GetDateTime(1), reader.GetDouble(2)));
}
return rows;
}
private async Task<(double? CurrentKw, int ActiveCount)> ReadCurrentActivePowerAsync(CancellationToken ct)
{
var since = DateTime.UtcNow - ActiveDeviceWindow;
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT COUNT(DISTINCT "DeviceId") AS active_devices,
COALESCE(SUM(latest_kw), 0)::double precision AS total_kw
FROM (
SELECT DISTINCT ON ("DeviceId") "DeviceId", "ActivePowerKw" AS latest_kw
FROM monitoring."PowerMeasurements"
WHERE "Time" >= @since
ORDER BY "DeviceId", "Time" DESC
) t;
""";
cmd.Parameters.Add(new NpgsqlParameter("@since", DateTime.SpecifyKind(since, DateTimeKind.Utc)));
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return (null, 0);
var activeCount = reader.GetInt64(0);
var totalKw = reader.GetDouble(1);
return (activeCount > 0 ? totalKw : null, (int)activeCount);
}
private async Task<List<DashboardChartPointDto>> ReadChartAsync(
DateTime from, DateTime to, CancellationToken ct)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = $"""
SELECT bucket, SUM(avg_kw)::double precision AS total_kw
FROM (
SELECT time_bucket(INTERVAL '{ChartBucketMinutes} minutes', "Time") AS bucket,
"DeviceId",
AVG("ActivePowerKw") AS avg_kw
FROM monitoring."PowerMeasurements"
WHERE "Time" >= @from AND "Time" < @to
GROUP BY bucket, "DeviceId"
) t
GROUP BY bucket
ORDER BY bucket;
""";
cmd.Parameters.Add(new NpgsqlParameter("@from", from));
cmd.Parameters.Add(new NpgsqlParameter("@to", to));
var points = new List<DashboardChartPointDto>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
points.Add(new DashboardChartPointDto(reader.GetDateTime(0), reader.GetDouble(1)));
}
return points;
}
private (Dictionary<Guid, decimal> PerDeviceCost, decimal Total) ComputeCost(
IReadOnlyList<(Guid DeviceId, DateTime Bucket, double Kwh)> hourly,
Dictionary<Guid, int?> deviceMuniMap,
Dictionary<int, Municipality> muniMap,
Dictionary<int, List<Tariff>> tariffsByMuni)
{
var byDevice = new Dictionary<Guid, decimal>();
decimal total = 0m;
foreach (var b in hourly)
{
if (!deviceMuniMap.TryGetValue(b.DeviceId, out var muniIdOrNull)) continue;
if (muniIdOrNull is not int mid || !tariffsByMuni.TryGetValue(mid, out var tariffsForMuni)) continue;
var bucketDate = DateOnly.FromDateTime(b.Bucket);
var tariff = tariffsForMuni.FirstOrDefault(t =>
t.EffectiveFrom <= bucketDate &&
(t.EffectiveTo == null || t.EffectiveTo >= bucketDate));
if (tariff is null) continue;
DateTime localBucket;
try
{
var tz = muniMap.TryGetValue(mid, out var m) && !string.IsNullOrWhiteSpace(m.TimeZoneId)
? TimeZoneInfo.FindSystemTimeZoneById(m.TimeZoneId)
: TimeZoneInfo.Utc;
localBucket = TimeZoneInfo.ConvertTimeFromUtc(
DateTime.SpecifyKind(b.Bucket, DateTimeKind.Utc), tz);
}
catch (TimeZoneNotFoundException)
{
log.LogWarning("Unknown TimeZoneId for muni {Muni}; falling back to UTC", mid);
localBucket = b.Bucket;
}
var rate = SelectRate(tariff.Periods, localBucket, tariff.DefaultRatePerKwh);
var baseCost = (decimal)b.Kwh * rate;
var vat = decimal.Round(baseCost * tariff.VatPercentage / 100m, 4, MidpointRounding.AwayFromZero);
var bucketTotal = baseCost + vat;
byDevice.TryGetValue(b.DeviceId, out var d);
byDevice[b.DeviceId] = d + bucketTotal;
total += bucketTotal;
}
return (byDevice, decimal.Round(total, 4, MidpointRounding.AwayFromZero));
}
private static decimal SelectRate(
IEnumerable<TariffPeriod> periods, DateTime localTime, decimal defaultRate)
{
var flag = localTime.DayOfWeek.ToFlag();
var t = TimeOnly.FromDateTime(localTime);
foreach (var p in periods.OrderBy(p => p.StartTime))
{
if ((p.DaysOfWeek & flag) == 0) continue;
if (t >= p.StartTime && t < p.EndTime) return p.RatePerKwh;
}
return defaultRate;
}
}

View File

@ -0,0 +1,286 @@
using ClosedXML.Excel;
using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Services;
// Pure helper — takes already-computed DTOs and produces an .xlsx byte stream.
// No DB / no DI. Each method targets one export surface; column shape is locked
// here so the frontend's "Export to Excel" button always produces the same file.
//
// Conventions:
// - Sheet name = short label of the data set.
// - Header row: bold + filled (XLColor.LightGray), frozen.
// - Numbers/currencies/dates get NumberFormat strings so Excel opens with the
// right type (avoids the "stored as text" warning).
// - AdjustToContents() called once at the end so column widths are sensible.
public sealed class ExcelExportService
{
private const string DateTimeFormat = "yyyy-mm-dd hh:mm:ss";
private const string DateFormat = "yyyy-mm-dd";
private const string KwhFormat = "#,##0.000";
private const string KwFormat = "#,##0.000";
private const string MoneyFormat = "#,##0.00";
public byte[] BuildDashboardSummary(DashboardSummaryDto data, DateTime fromUtc, DateTime toUtc)
{
using var wb = new XLWorkbook();
var ws = wb.Worksheets.Add("Dashboard summary");
WriteRange(ws, fromUtc, toUtc);
WriteTotalsBlock(ws, startRow: 4, new[]
{
("Total kWh imported", (decimal)data.TotalKwh, KwhFormat),
("Current active power (kW)", (decimal)(data.CurrentActivePowerKw ?? 0), KwFormat),
("Active devices", (decimal)data.ActiveDeviceCount, "0"),
("Estimated cost (total)", data.TotalCost ?? 0m, MoneyFormat),
});
var headerRow = 10;
var headers = new[] { "Device", "Site", "kWh in range", "Peak kW", "Last seen (UTC)", "Cost in range" };
WriteHeader(ws, headerRow, headers);
var row = headerRow + 1;
foreach (var d in data.Devices)
{
ws.Cell(row, 1).Value = d.DeviceName;
ws.Cell(row, 2).Value = d.SiteName ?? "";
ws.Cell(row, 3).Value = d.Kwh;
ws.Cell(row, 3).Style.NumberFormat.Format = KwhFormat;
ws.Cell(row, 4).Value = d.PeakKw ?? 0;
ws.Cell(row, 4).Style.NumberFormat.Format = KwFormat;
if (d.LastSeen is DateTime ls)
{
ws.Cell(row, 5).Value = DateTime.SpecifyKind(ls, DateTimeKind.Unspecified);
ws.Cell(row, 5).Style.NumberFormat.Format = DateTimeFormat;
}
if (d.Cost is decimal c)
{
ws.Cell(row, 6).Value = c;
ws.Cell(row, 6).Style.NumberFormat.Format = MoneyFormat;
}
row++;
}
ws.SheetView.FreezeRows(headerRow);
ws.Columns().AdjustToContents();
return ToBytes(wb);
}
public byte[] BuildRawMeasurements(IEnumerable<RawMeasurementRow> rows, DateTime fromUtc, DateTime toUtc)
{
using var wb = new XLWorkbook();
var ws = wb.Worksheets.Add("Measurements");
WriteRange(ws, fromUtc, toUtc);
var headerRow = 4;
var headers = new[]
{
"Time (UTC)", "Device", "Site",
"Active power (kW)", "Reactive power (kVAr)", "Apparent power (kVA)",
"Power factor", "Voltage (V)", "Frequency (Hz)",
"Energy imported (kWh, cum.)", "Energy exported (kWh, cum.)",
};
WriteHeader(ws, headerRow, headers);
var row = headerRow + 1;
foreach (var r in rows)
{
ws.Cell(row, 1).Value = DateTime.SpecifyKind(r.Time, DateTimeKind.Unspecified);
ws.Cell(row, 1).Style.NumberFormat.Format = DateTimeFormat;
ws.Cell(row, 2).Value = r.DeviceName;
ws.Cell(row, 3).Value = r.SiteName ?? "";
ws.Cell(row, 4).Value = r.ActivePowerKw;
ws.Cell(row, 4).Style.NumberFormat.Format = KwFormat;
SetNullable(ws.Cell(row, 5), r.ReactivePowerKvar, KwFormat);
SetNullable(ws.Cell(row, 6), r.ApparentPowerKva, KwFormat);
SetNullable(ws.Cell(row, 7), r.PowerFactor, "0.000");
SetNullable(ws.Cell(row, 8), r.VoltageV, "0.0");
SetNullable(ws.Cell(row, 9), r.FrequencyHz, "0.00");
SetNullable(ws.Cell(row, 10), r.EnergyImportedKwh, KwhFormat);
SetNullable(ws.Cell(row, 11), r.EnergyExportedKwh, KwhFormat);
row++;
}
ws.SheetView.FreezeRows(headerRow);
ws.Columns().AdjustToContents();
return ToBytes(wb);
}
public byte[] BuildFleetCustomerCost(FleetCostDto data, string customerCode, string customerName)
{
using var wb = new XLWorkbook();
WriteCostDailySheet(wb, data, customerCode, customerName);
WriteCostPerDeviceSheet(wb, data);
return ToBytes(wb);
}
public byte[] BuildFleetDashboard(FleetDashboardDto data)
{
using var wb = new XLWorkbook();
var ws = wb.Worksheets.Add("Fleet dashboard");
ws.Cell(1, 1).Value = "Snapshot (UTC)";
ws.Cell(1, 2).Value = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss");
WriteTotalsBlock(ws, startRow: 3, new[]
{
("Customers (total)", (decimal)data.TotalCustomers, "0"),
("Customers (active last hr)", (decimal)data.ActiveCustomers, "0"),
("Measurements today", (decimal)data.TotalMeasurementsToday, "#,##0"),
("kWh imported today", (decimal)data.TotalKwhImportedToday, KwhFormat),
("Cost today (estimated)", data.TotalCostToday, MoneyFormat),
});
var headerRow = 10;
var headers = new[]
{
"Code", "Name", "Active", "Last push (UTC)",
"Sites", "Devices", "Rows today", "kWh today", "Cost today"
};
WriteHeader(ws, headerRow, headers);
var row = headerRow + 1;
foreach (var c in data.Customers)
{
ws.Cell(row, 1).Value = c.Code;
ws.Cell(row, 2).Value = c.Name;
ws.Cell(row, 3).Value = c.IsActive ? "Active" : "Disabled";
if (c.LastSeenAt is DateTime ls)
{
ws.Cell(row, 4).Value = DateTime.SpecifyKind(ls, DateTimeKind.Unspecified);
ws.Cell(row, 4).Style.NumberFormat.Format = DateTimeFormat;
}
ws.Cell(row, 5).Value = c.Sites;
ws.Cell(row, 6).Value = c.Devices;
ws.Cell(row, 7).Value = c.MeasurementsToday;
ws.Cell(row, 7).Style.NumberFormat.Format = "#,##0";
if (c.KwhImportedToday is double kwh)
{
ws.Cell(row, 8).Value = kwh;
ws.Cell(row, 8).Style.NumberFormat.Format = KwhFormat;
}
if (c.CostToday is decimal cost)
{
ws.Cell(row, 9).Value = cost;
ws.Cell(row, 9).Style.NumberFormat.Format = MoneyFormat;
}
row++;
}
ws.SheetView.FreezeRows(headerRow);
ws.Columns().AdjustToContents();
return ToBytes(wb);
}
// ── helpers ──────────────────────────────────────────────────────────────
private static void WriteRange(IXLWorksheet ws, DateTime fromUtc, DateTime toUtc)
{
ws.Cell(1, 1).Value = "From (UTC)";
ws.Cell(1, 2).Value = fromUtc.ToString("yyyy-MM-dd HH:mm:ss");
ws.Cell(2, 1).Value = "To (UTC)";
ws.Cell(2, 2).Value = toUtc.ToString("yyyy-MM-dd HH:mm:ss");
}
private static void WriteTotalsBlock(IXLWorksheet ws, int startRow, (string Label, decimal Value, string Format)[] rows)
{
for (var i = 0; i < rows.Length; i++)
{
var r = startRow + i;
ws.Cell(r, 1).Value = rows[i].Label;
ws.Cell(r, 1).Style.Font.Bold = true;
ws.Cell(r, 2).Value = rows[i].Value;
ws.Cell(r, 2).Style.NumberFormat.Format = rows[i].Format;
}
}
private static void WriteHeader(IXLWorksheet ws, int row, IReadOnlyList<string> headers)
{
for (var i = 0; i < headers.Count; i++)
{
var cell = ws.Cell(row, i + 1);
cell.Value = headers[i];
cell.Style.Font.Bold = true;
cell.Style.Fill.BackgroundColor = XLColor.LightGray;
}
}
private static void SetNullable(IXLCell cell, double? value, string format)
{
if (value is not double v) return;
cell.Value = v;
cell.Style.NumberFormat.Format = format;
}
private void WriteCostDailySheet(XLWorkbook wb, FleetCostDto data, string code, string name)
{
var ws = wb.Worksheets.Add("Daily");
ws.Cell(1, 1).Value = "Customer";
ws.Cell(1, 2).Value = $"{code} — {name}";
WriteRange(ws, data.FromUtc, data.ToUtc);
WriteTotalsBlock(ws, startRow: 5, new[]
{
("Total kWh", (decimal)data.TotalKwh, KwhFormat),
("Total base cost", data.TotalBaseCost, MoneyFormat),
("Total VAT", data.TotalVatAmount, MoneyFormat),
("Total cost", data.TotalCost, MoneyFormat),
});
var headerRow = 11;
WriteHeader(ws, headerRow, new[] { "Date (UTC)", "kWh", "Base cost", "VAT", "Total" });
var row = headerRow + 1;
foreach (var d in data.Daily)
{
ws.Cell(row, 1).Value = d.Date.ToDateTime(TimeOnly.MinValue);
ws.Cell(row, 1).Style.NumberFormat.Format = DateFormat;
ws.Cell(row, 2).Value = d.Kwh;
ws.Cell(row, 2).Style.NumberFormat.Format = KwhFormat;
ws.Cell(row, 3).Value = d.BaseCost;
ws.Cell(row, 3).Style.NumberFormat.Format = MoneyFormat;
ws.Cell(row, 4).Value = d.VatAmount;
ws.Cell(row, 4).Style.NumberFormat.Format = MoneyFormat;
ws.Cell(row, 5).Value = d.TotalCost;
ws.Cell(row, 5).Style.NumberFormat.Format = MoneyFormat;
row++;
}
ws.SheetView.FreezeRows(headerRow);
ws.Columns().AdjustToContents();
}
private void WriteCostPerDeviceSheet(XLWorkbook wb, FleetCostDto data)
{
var ws = wb.Worksheets.Add("Per device");
var headerRow = 1;
WriteHeader(ws, headerRow, new[] { "Device", "Municipality", "kWh", "Base cost", "Total" });
var row = headerRow + 1;
foreach (var d in data.PerDevice)
{
ws.Cell(row, 1).Value = d.DeviceName;
ws.Cell(row, 2).Value = d.MunicipalityName ?? "";
ws.Cell(row, 3).Value = d.Kwh;
ws.Cell(row, 3).Style.NumberFormat.Format = KwhFormat;
ws.Cell(row, 4).Value = d.BaseCost;
ws.Cell(row, 4).Style.NumberFormat.Format = MoneyFormat;
ws.Cell(row, 5).Value = d.TotalCost;
ws.Cell(row, 5).Style.NumberFormat.Format = MoneyFormat;
row++;
}
ws.SheetView.FreezeRows(headerRow);
ws.Columns().AdjustToContents();
}
private static byte[] ToBytes(XLWorkbook wb)
{
using var ms = new MemoryStream();
wb.SaveAs(ms);
return ms.ToArray();
}
}

View File

@ -8,10 +8,17 @@ namespace Tau.Acuvim.Portal.Services;
// Idempotent — safe on every start. // Idempotent — safe on every start.
public sealed class FleetTimescaleBootstrapper( public sealed class FleetTimescaleBootstrapper(
AdminDbContext db, AdminDbContext db,
RlsContext rls,
ILogger<FleetTimescaleBootstrapper> log) ILogger<FleetTimescaleBootstrapper> log)
{ {
public async Task EnsureAsync(CancellationToken ct = default) public async Task EnsureAsync(CancellationToken ct = default)
{ {
// Elevate the per-scope RlsContext so any EF reads from this bootstrapper
// bypass the HasQueryFilter (the bootstrapper is trusted system code, not a
// user request). Most of the bootstrapper is raw SQL DDL which bypasses the
// filter anyway, but the elevation guards future additions.
using var _ = rls.Elevate();
await db.Database.ExecuteSqlRawAsync( await db.Database.ExecuteSqlRawAsync(
"CREATE EXTENSION IF NOT EXISTS timescaledb;", ct); "CREATE EXTENSION IF NOT EXISTS timescaledb;", ct);
@ -113,6 +120,13 @@ public sealed class FleetTimescaleBootstrapper(
); );
""", ct); """, ct);
log.LogInformation("Fleet hypertable + compression + hourly_per_device + daily_per_customer CAs ready."); // Per-customer access enforcement is done via EF Core HasQueryFilter on the
// AdminDbContext (see Data/AdminDbContext.cs). Database-level RLS was attempted
// but ran into two blockers: TimescaleDB rejects ALTER TABLE on compressed
// hypertables, and the Postgres docker bootstrap user can't drop its own
// SUPERUSER attribute (bypassing any RLS we'd configure). Application-level
// filters via EF cover the same threat model for our single-app-client setup.
log.LogInformation("Fleet hypertable + compression + CAs ready.");
} }
} }

View File

@ -28,6 +28,17 @@ public sealed class IdentityBootstrapper(
} }
} }
if (!await roles.RoleExistsAsync(Roles.RestrictedAdmin))
{
var roleResult = await roles.CreateAsync(new IdentityRole(Roles.RestrictedAdmin));
if (!roleResult.Succeeded)
{
log.LogError("Failed to seed {Role} role: {Errors}",
Roles.RestrictedAdmin, string.Join("; ", roleResult.Errors.Select(e => e.Description)));
// Not fatal — Admin role exists, restricted admin can be added later.
}
}
var adminEmail = auth.Value.DefaultAdminEmail; var adminEmail = auth.Value.DefaultAdminEmail;
var existing = await users.FindByEmailAsync(adminEmail); var existing = await users.FindByEmailAsync(adminEmail);
if (existing is not null) return; if (existing is not null) return;

View File

@ -0,0 +1,58 @@
namespace Tau.Acuvim.Portal.Services;
// Per-DI-scope RLS state. The AdminDbContext takes this in its constructor and uses
// it inside HasQueryFilter expressions to filter fleet.* DbSets on every query.
//
// Middleware sets the values from the authenticated user's role + AdminCustomerAccess.
// Bootstrappers / trusted system code call Elevate() to bypass the filter for their
// own scope.
//
// Hybrid fail-closed semantics:
// default state → AllowAll=false, AllowedCustomerIds empty → no rows visible
// Elevate() / Admin role → AllowAll=true → all rows visible
// RestrictedAdmin role → AllowAll=false, AllowedCustomerIds populated → scoped
public sealed class RlsContext
{
private static readonly HashSet<Guid> EmptySet = new();
public bool AllowAll { get; set; }
public HashSet<Guid> AllowedCustomerIds { get; set; } = EmptySet;
public void SetAll()
{
AllowAll = true;
AllowedCustomerIds = EmptySet;
}
public void SetScoped(IEnumerable<Guid> customerIds)
{
AllowAll = false;
AllowedCustomerIds = new HashSet<Guid>(customerIds);
}
public void SetNone()
{
AllowAll = false;
AllowedCustomerIds = EmptySet;
}
// Temporarily elevate the context to AllowAll for a using-block; restores prior
// state on dispose. Used by bootstrappers + any system code that needs to bypass
// the filter.
public IDisposable Elevate()
{
var prevAll = AllowAll;
var prevSet = AllowedCustomerIds;
SetAll();
return new Restorer(this, prevAll, prevSet);
}
private sealed class Restorer(RlsContext ctx, bool prevAll, HashSet<Guid> prevSet) : IDisposable
{
public void Dispose()
{
ctx.AllowAll = prevAll;
ctx.AllowedCustomerIds = prevSet;
}
}
}

View File

@ -10,6 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
<PackageReference Include="ClosedXML" Version="0.104.2" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">

View File

@ -0,0 +1,130 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Tau.Acuvim.Portal.Configuration;
using Tau.Acuvim.Portal.Data;
using Tau.Acuvim.Portal.DTOs;
using Tau.Acuvim.Portal.Services;
namespace Tau.Acuvim.Portal.Tests;
// Proves the "pre-brand at spin-up" flow end-to-end at the C# level:
// docker-compose WhiteLabel__* env var → IOptions<WhiteLabelOptions>
// → BrandingService.SeedAsync → WhiteLabelSettings row on first read.
//
// The Docker passthrough that bridges env-var → container is pure YAML and
// covered by the integration spin-up; this test pins the C# half so we'd
// catch a regression in BrandingService that broke the contract (e.g. someone
// changing SeedAsync to use a constructor literal instead of IOptions).
public class BrandingSeedFromOptionsTests
{
private static AppDbContext NewDb() =>
new(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase("brand-" + Guid.NewGuid())
.Options);
private static BrandingService NewService(AppDbContext db, WhiteLabelOptions opts) =>
new(db, Options.Create(opts));
private static WhiteLabelOptions AcmeBrand() => new()
{
ApplicationName = "Acme Power Monitoring",
LogoUrl = "https://cdn.acme.example/logo.png",
PrimaryColor = "#0c4a6e",
SecondaryColor = "#0e7490",
AccentColor = "#06b6d4",
FooterText = "© Acme Corp",
};
[Fact]
public async Task FirstGet_SeedsRowFromOptions()
{
using var db = NewDb();
var svc = NewService(db, AcmeBrand());
var result = await svc.GetAsync();
Assert.Equal("Acme Power Monitoring", result.ApplicationName);
Assert.Equal("https://cdn.acme.example/logo.png", result.LogoUrl);
Assert.Equal("#0c4a6e", result.PrimaryColor);
Assert.Equal("#0e7490", result.SecondaryColor);
Assert.Equal("#06b6d4", result.AccentColor);
Assert.Equal("© Acme Corp", result.FooterText);
}
[Fact]
public async Task SecondGet_DoesNotReseed_EvenIfOptionsChange()
{
using var db = NewDb();
var first = NewService(db, AcmeBrand());
await first.GetAsync();
// Operator "changes the template defaults" — but the row already exists
// in the DB, so the UI-authoritative value must win on subsequent gets.
// This is the contract that makes Settings → Branding actually durable.
var changedDefaults = new WhiteLabelOptions
{
ApplicationName = "Different Defaults",
PrimaryColor = "#ff0000",
SecondaryColor = "#00ff00",
AccentColor = "#0000ff",
FooterText = "different",
};
var second = NewService(db, changedDefaults);
var result = await second.GetAsync();
Assert.Equal("Acme Power Monitoring", result.ApplicationName);
Assert.Equal("#0c4a6e", result.PrimaryColor);
}
[Fact]
public async Task EnsureSeededAsync_WritesRowImmediately_NoGetNeeded()
{
using var db = NewDb();
var svc = NewService(db, AcmeBrand());
await svc.EnsureSeededAsync();
var row = await db.WhiteLabelSettings.AsNoTracking().SingleAsync();
Assert.Equal("Acme Power Monitoring", row.ApplicationName);
Assert.Equal("#06b6d4", row.AccentColor);
}
[Fact]
public async Task EnsureSeededAsync_IsIdempotent()
{
using var db = NewDb();
var svc = NewService(db, AcmeBrand());
await svc.EnsureSeededAsync();
await svc.EnsureSeededAsync();
await svc.EnsureSeededAsync();
var rowCount = await db.WhiteLabelSettings.CountAsync();
Assert.Equal(1, rowCount);
}
[Fact]
public async Task UpdateAsync_BlankFields_FallBackToOptionsDefaults()
{
using var db = NewDb();
var svc = NewService(db, AcmeBrand());
await svc.GetAsync(); // seed
// Operator clears the application name in the UI — service should
// fall back to the IOptions default rather than persist empty string.
var updated = await svc.UpdateAsync(new UpdateBrandingRequest(
ApplicationName: "",
PrimaryColor: "#abc123",
SecondaryColor: "",
AccentColor: "",
FooterText: "kept",
LogoUrl: null));
Assert.Equal("Acme Power Monitoring", updated.ApplicationName);
Assert.Equal("#abc123", updated.PrimaryColor);
Assert.Equal("#0e7490", updated.SecondaryColor);
Assert.Equal("#06b6d4", updated.AccentColor);
Assert.Equal("kept", updated.FooterText);
}
}

View File

@ -12,7 +12,9 @@ public class CustomerTokenGraceTests
var opts = new DbContextOptionsBuilder<AdminDbContext>() var opts = new DbContextOptionsBuilder<AdminDbContext>()
.UseInMemoryDatabase(databaseName: "grace-" + Guid.NewGuid()) .UseInMemoryDatabase(databaseName: "grace-" + Guid.NewGuid())
.Options; .Options;
return new AdminDbContext(opts); var rls = new RlsContext();
rls.SetAll(); // Tests bypass RLS scoping; CustomerService isn't customer-scoped anyway.
return new AdminDbContext(opts, rls);
} }
private static async Task<(CustomerService svc, Guid id, string originalToken)> SeedAsync() private static async Task<(CustomerService svc, Guid id, string originalToken)> SeedAsync()