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:
parent
66660364ec
commit
e2cbb83397
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:-}
|
||||||
|
|||||||
54
portal/frontend/package-lock.json
generated
54
portal/frontend/package-lock.json
generated
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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={
|
||||||
|
|||||||
18
portal/frontend/src/api/customerAccess.ts
Normal file
18
portal/frontend/src/api/customerAccess.ts
Normal 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 });
|
||||||
|
}
|
||||||
54
portal/frontend/src/api/dashboard.ts
Normal file
54
portal/frontend/src/api/dashboard.ts
Normal 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()}`;
|
||||||
|
}
|
||||||
70
portal/frontend/src/api/measurements.ts
Normal file
70
portal/frontend/src/api/measurements.ts
Normal 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()}`;
|
||||||
|
}
|
||||||
@ -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[]> {
|
||||||
|
|||||||
@ -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' }]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
123
portal/frontend/src/components/users/CustomerAccessModal.tsx
Normal file
123
portal/frontend/src/components/users/CustomerAccessModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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 />}
|
||||||
|
|||||||
@ -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." />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -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>;
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
213
portal/frontend/src/pages/MeasurementsPage.tsx
Normal file
213
portal/frontend/src/pages/MeasurementsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
10
portal/src/Tau.Acuvim.Portal/DTOs/AdminCustomerAccessDtos.cs
Normal file
10
portal/src/Tau.Acuvim.Portal/DTOs/AdminCustomerAccessDtos.cs
Normal 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);
|
||||||
39
portal/src/Tau.Acuvim.Portal/DTOs/DashboardSummaryDtos.cs
Normal file
39
portal/src/Tau.Acuvim.Portal/DTOs/DashboardSummaryDtos.cs
Normal 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);
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
portal/src/Tau.Acuvim.Portal/Endpoints/DashboardEndpoints.cs
Normal file
63
portal/src/Tau.Acuvim.Portal/Endpoints/DashboardEndpoints.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
797
portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518095759_AddAdminCustomerAccess.Designer.cs
generated
Normal file
797
portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518095759_AddAdminCustomerAccess.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
393
portal/src/Tau.Acuvim.Portal/Services/DashboardSummaryService.cs
Normal file
393
portal/src/Tau.Acuvim.Portal/Services/DashboardSummaryService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
286
portal/src/Tau.Acuvim.Portal/Services/ExcelExportService.cs
Normal file
286
portal/src/Tau.Acuvim.Portal/Services/ExcelExportService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
58
portal/src/Tau.Acuvim.Portal/Services/RlsContext.cs
Normal file
58
portal/src/Tau.Acuvim.Portal/Services/RlsContext.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user