From e2cbb83397c24b7c9298692f2a7dfe1b8716da5d Mon Sep 17 00:00:00 2001 From: Diseri Pearson Date: Tue, 19 May 2026 09:15:44 +0200 Subject: [PATCH] Portal: Client Dashboard, Measurements page, Excel exports, Grafana auth, RLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- portal/.env.example | 65 +- portal/OPERATIONS.md | 21 +- portal/README.md | 18 +- portal/docker-compose.prod.yml | 42 +- portal/docker-compose.yml | 14 +- portal/frontend/package-lock.json | 54 ++ portal/frontend/package.json | 3 + portal/frontend/src/App.tsx | 2 + portal/frontend/src/api/customerAccess.ts | 18 + portal/frontend/src/api/dashboard.ts | 54 ++ portal/frontend/src/api/measurements.ts | 70 ++ portal/frontend/src/api/users.ts | 2 + .../src/components/layout/AppLayout.tsx | 9 +- .../components/users/CustomerAccessModal.tsx | 123 +++ .../src/components/users/UserFormDrawer.tsx | 46 +- .../src/pages/AdminCustomerDetailPage.tsx | 42 +- .../src/pages/AdminFleetDashboardPage.tsx | 19 +- portal/frontend/src/pages/DashboardPage.tsx | 233 ++++- portal/frontend/src/pages/DashboardsPage.tsx | 14 +- .../frontend/src/pages/MeasurementsPage.tsx | 213 +++++ portal/frontend/src/pages/UsersPage.tsx | 21 +- .../src/Tau.Acuvim.Portal/Constants/Roles.cs | 6 + .../DTOs/AdminCustomerAccessDtos.cs | 10 + .../DTOs/DashboardSummaryDtos.cs | 39 + .../Tau.Acuvim.Portal/DTOs/MonitoringDtos.cs | 8 + portal/src/Tau.Acuvim.Portal/DTOs/UserDtos.cs | 6 +- .../Tau.Acuvim.Portal/Data/AdminDbContext.cs | 41 +- .../Domain/Identity/AdminCustomerAccess.cs | 11 + .../Endpoints/AdminCustomerAccessEndpoints.cs | 75 ++ .../Endpoints/AdminCustomersEndpoints.cs | 8 +- .../Endpoints/AdminUserEndpoints.cs | 17 + .../Endpoints/AuthEndpoints.cs | 12 + .../Endpoints/DashboardEndpoints.cs | 63 ++ .../Endpoints/FleetDashboardEndpoints.cs | 44 +- .../Endpoints/MeasurementsEndpoints.cs | 63 ++ .../Endpoints/SitesEndpoints.cs | 12 + .../Middleware/CustomerFilterMiddleware.cs | 65 ++ ...8095759_AddAdminCustomerAccess.Designer.cs | 797 ++++++++++++++++++ .../20260518095759_AddAdminCustomerAccess.cs | 50 ++ .../Admin/AdminDbContextModelSnapshot.cs | 27 + portal/src/Tau.Acuvim.Portal/Program.cs | 22 +- .../Services/DashboardSummaryService.cs | 393 +++++++++ .../Services/ExcelExportService.cs | 286 +++++++ .../Services/FleetTimescaleBootstrapper.cs | 16 +- .../Services/IdentityBootstrapper.cs | 11 + .../Tau.Acuvim.Portal/Services/RlsContext.cs | 58 ++ .../Tau.Acuvim.Portal.csproj | 1 + .../BrandingSeedFromOptionsTests.cs | 130 +++ .../CustomerTokenGraceTests.cs | 4 +- 49 files changed, 3271 insertions(+), 87 deletions(-) create mode 100644 portal/frontend/src/api/customerAccess.ts create mode 100644 portal/frontend/src/api/dashboard.ts create mode 100644 portal/frontend/src/api/measurements.ts create mode 100644 portal/frontend/src/components/users/CustomerAccessModal.tsx create mode 100644 portal/frontend/src/pages/MeasurementsPage.tsx create mode 100644 portal/src/Tau.Acuvim.Portal/DTOs/AdminCustomerAccessDtos.cs create mode 100644 portal/src/Tau.Acuvim.Portal/DTOs/DashboardSummaryDtos.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Domain/Identity/AdminCustomerAccess.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Endpoints/AdminCustomerAccessEndpoints.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Endpoints/DashboardEndpoints.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Middleware/CustomerFilterMiddleware.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518095759_AddAdminCustomerAccess.Designer.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518095759_AddAdminCustomerAccess.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Services/DashboardSummaryService.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Services/ExcelExportService.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Services/RlsContext.cs create mode 100644 portal/tests/Tau.Acuvim.Portal.Tests/BrandingSeedFromOptionsTests.cs diff --git a/portal/.env.example b/portal/.env.example index 7d82ed0..07fe1ab 100644 --- a/portal/.env.example +++ b/portal/.env.example @@ -1,24 +1,38 @@ -# Copy to .env for local docker compose use. -# DO NOT commit .env. +# Copy to .env for docker compose use. +# 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 ─────────────────────────────────────────────────────────────── # 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. -# 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 -# ── Production-only ────────────────────────────────────────────────────── -# Public hostname Traefik routes to this customer's portal. +# Production-only: public hostname Traefik routes to this stack. # Required by docker-compose.prod.yml. Ignored by the dev compose. 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 ───────────────────────────────────────────────────────────── +# 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_USER=power_user POSTGRES_PASSWORD=change_me_for_local_only -# ── Portal authentication ─────────────────────────────────────────────── -# In Production, DefaultAdminPassword MUST be changed or the app refuses to start. +# ── Portal authentication ──────────────────────────────────────────────── +# 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__DefaultAdminPassword=ChangeMe123! @@ -26,17 +40,40 @@ Authentication__DefaultAdminPassword=ChangeMe123! GRAFANA_ADMIN_PASSWORD=admin # Path prefix Grafana is mounted at behind Traefik. Same-origin embed in the SPA. 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) ───────────────────────────────────────────── -# Client (default) = per-customer stack. Admin = fleet aggregator. -# Application__RunMode=Client +# ── White-label (seed values applied only on first boot) ──────────────── +# These pre-brand a fresh stack so the customer admin never sees the generic +# 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) ─────────────────────────── -# When Enabled=true, URL and Token are REQUIRED (RunModeGuards refuse otherwise). -# Get the token from the Admin Customers page; it is shown once at creation/rotation. -# FleetIngest__Enabled=false +# ── Fleet ingest (Client mode only — push to an Admin stack) ──────────── +# Enable per-customer AFTER registering them on the Admin Customers page; the +# token is shown once at creation. URL + Token are REQUIRED when Enabled=true +# (RunModeGuards refuses startup otherwise). +# FleetIngest__Enabled=true # FleetIngest__Url=https://admin.portal.example.com/api/fleet/ingest # FleetIngest__Token= # FleetIngest__IntervalSeconds=60 # FleetIngest__BatchSize=5000 # 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= diff --git a/portal/OPERATIONS.md b/portal/OPERATIONS.md index b6af902..4f9e8ce 100644 --- a/portal/OPERATIONS.md +++ b/portal/OPERATIONS.md @@ -63,9 +63,12 @@ openssl rand -base64 32 # Authentication__DefaultAdminPassword ### 3. Fill in `.env` +Copy `.env.example` to the customer's directory and fill in: + ```ini COMPOSE_PROJECT_NAME=abc0001 CUSTOMER_HOST=abc0001.portal.example.com +Application__RunMode=Client POSTGRES_DB=power_monitoring POSTGRES_USER=power_user @@ -76,17 +79,23 @@ Authentication__DefaultAdminPassword= GRAFANA_ADMIN_PASSWORD= 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. -- (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. +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. -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 diff --git a/portal/README.md b/portal/README.md index f419573..ff8426b 100644 --- a/portal/README.md +++ b/portal/README.md @@ -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 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 | |---|---|---| -| **(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." | -| **(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. | +| **(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. 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. | -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 diff --git a/portal/docker-compose.prod.yml b/portal/docker-compose.prod.yml index ad72263..1a19e37 100644 --- a/portal/docker-compose.prod.yml +++ b/portal/docker-compose.prod.yml @@ -18,6 +18,9 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Production - Application__PublicUrl=https://${CUSTOMER_HOST} + # RunMode: Client (per-customer, default) or Admin (fleet aggregator). + # Falls back to Client for normal customer stacks. + - Application__RunMode=${Application__RunMode:-Client} - Database__ConnectionString=Host=timescaledb;Port=5432;Database=${POSTGRES_DB:-power_monitoring};Username=${POSTGRES_USER:-power_user};Password=${POSTGRES_PASSWORD} - Database__AutoProvisionLocalTimescaleDb=false - Authentication__DefaultAdminEmail=${Authentication__DefaultAdminEmail} @@ -25,6 +28,27 @@ services: - Grafana__BaseUrl=https://${CUSTOMER_HOST}${Grafana__EmbedPathPrefix:-/grafana} - Grafana__InternalUrl=http://grafana:3000 - Grafana__EmbedPathPrefix=${Grafana__EmbedPathPrefix:-/grafana} + # White-label seed values. Applied ONLY on first boot (when the branding row + # doesn't exist yet); after that the row in the DB is authoritative and the + # operator edits via Settings → Branding. Pre-set these to spin up a new + # customer already-branded so the customer admin never sees "Power Monitoring + # Portal" on their first sign-in. Empty → template defaults from + # appsettings.template.json. + - WhiteLabel__ApplicationName=${WhiteLabel__ApplicationName:-Power Monitoring Portal} + - WhiteLabel__LogoUrl=${WhiteLabel__LogoUrl:-} + - WhiteLabel__PrimaryColor=${WhiteLabel__PrimaryColor:-#1f2937} + - WhiteLabel__SecondaryColor=${WhiteLabel__SecondaryColor:-#374151} + - WhiteLabel__AccentColor=${WhiteLabel__AccentColor:-#2563eb} + - WhiteLabel__FooterText=${WhiteLabel__FooterText:-} + # Fleet ingest (Client mode → push to Admin stack). Disabled by default; + # enable per-customer in their .env after registering them on the Admin + # Customers page. URL + Token are required when Enabled=true (startup guard). + - FleetIngest__Enabled=${FleetIngest__Enabled:-false} + - FleetIngest__Url=${FleetIngest__Url:-} + - FleetIngest__Token=${FleetIngest__Token:-} + - FleetIngest__IntervalSeconds=${FleetIngest__IntervalSeconds:-60} + - FleetIngest__BatchSize=${FleetIngest__BatchSize:-5000} + - FleetIngest__BatchMaxBytes=${FleetIngest__BatchMaxBytes:-1048576} depends_on: timescaledb: condition: service_healthy @@ -69,12 +93,10 @@ services: - GF_SECURITY_ALLOW_EMBEDDING=true - GF_SERVER_ROOT_URL=https://${CUSTOMER_HOST}${Grafana__EmbedPathPrefix:-/grafana} - GF_SERVER_SERVE_FROM_SUB_PATH=true - # PROD AUTH IS NOT WIRED YET (Phase 9 risk). - # Anonymous is OFF so dashboards do NOT serve until you choose: - # (a) Traefik forwardAuth middleware → /api/auth/check - # (b) Grafana auth.proxy (GF_AUTH_PROXY_*) with X-WEBAUTH-USER from portal/Traefik - # (c) Service-account API key + portal-minted render tokens - # See README "Embedding Grafana — production auth options". + # Prod auth: option (a) — Traefik forwardAuth → portal /api/auth/check. + # Anonymous stays OFF; Traefik gates every Grafana request on a valid portal + # cookie via the middleware labels below. Switch modes (auth.proxy / render + # tokens) → see README "Embedding Grafana — production auth options". - GF_AUTH_ANONYMOUS_ENABLED=false - GF_USERS_ALLOW_SIGN_UP=false - POSTGRES_DB=${POSTGRES_DB:-power_monitoring} @@ -98,7 +120,15 @@ services: - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.tls=true" - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.tls.certresolver=le" - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.priority=20" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.middlewares=${COMPOSE_PROJECT_NAME}-portal-auth@docker" - "traefik.http.services.${COMPOSE_PROJECT_NAME}-grafana.loadbalancer.server.port=3000" + # forwardAuth middleware: every Grafana request gates on a sub-request to + # the portal's /api/auth/check. 2xx → forward to Grafana; 401 → block. + # Cookie is forwarded by default. trustForwardHeader so the portal sees the + # real client IP if it ever wants to log it. authResponseHeaders is empty — + # we don't need to surface anything back from the portal to Grafana. + - "traefik.http.middlewares.${COMPOSE_PROJECT_NAME}-portal-auth.forwardauth.address=http://${COMPOSE_PROJECT_NAME}_portal:8080/api/auth/check" + - "traefik.http.middlewares.${COMPOSE_PROJECT_NAME}-portal-auth.forwardauth.trustForwardHeader=true" volumes: portal-keys: diff --git a/portal/docker-compose.yml b/portal/docker-compose.yml index 1afa2d7..8a6fe3a 100644 --- a/portal/docker-compose.yml +++ b/portal/docker-compose.yml @@ -10,15 +10,23 @@ services: - 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__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__DefaultAdminPassword=${Authentication__DefaultAdminPassword:-ChangeMe123!} - Grafana__BaseUrl=http://localhost:3001 - Grafana__InternalUrl=http://grafana:3000 # RunMode: Client (default) or Admin. Override in .env to test fleet aggregation locally. - 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. - FleetIngest__Enabled=${FleetIngest__Enabled:-false} - FleetIngest__Url=${FleetIngest__Url:-} diff --git a/portal/frontend/package-lock.json b/portal/frontend/package-lock.json index 94b083f..2919292 100644 --- a/portal/frontend/package-lock.json +++ b/portal/frontend/package-lock.json @@ -12,6 +12,9 @@ "@tanstack/react-query": "^5.62.0", "antd": "^5.22.0", "axios": "^1.7.9", + "dayjs": "^1.11.20", + "echarts": "^5.6.0", + "echarts-for-react": "^3.0.6", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.28.0" @@ -1863,6 +1866,30 @@ "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": { "version": "1.5.357", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", @@ -1967,6 +1994,12 @@ "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": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3097,6 +3130,12 @@ "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": { "version": "1.2.1", "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==", "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": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -3277,6 +3322,15 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "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" + } } } } diff --git a/portal/frontend/package.json b/portal/frontend/package.json index 0469f02..15479a9 100644 --- a/portal/frontend/package.json +++ b/portal/frontend/package.json @@ -13,6 +13,9 @@ "@tanstack/react-query": "^5.62.0", "antd": "^5.22.0", "axios": "^1.7.9", + "dayjs": "^1.11.20", + "echarts": "^5.6.0", + "echarts-for-react": "^3.0.6", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.28.0" diff --git a/portal/frontend/src/App.tsx b/portal/frontend/src/App.tsx index abd7eb9..13d6206 100644 --- a/portal/frontend/src/App.tsx +++ b/portal/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { AppLayout } from './components/layout/AppLayout'; import { LoginPage } from './pages/LoginPage'; import { DashboardPage } from './pages/DashboardPage'; import { DashboardsPage } from './pages/DashboardsPage'; +import { MeasurementsPage } from './pages/MeasurementsPage'; import { AdminSitesPage } from './pages/AdminSitesPage'; import { AdminCustomersPage } from './pages/AdminCustomersPage'; import { AdminCustomerDetailPage } from './pages/AdminCustomerDetailPage'; @@ -39,6 +40,7 @@ export default function App() { > } /> } /> + } /> { + const { data } = await api.get(`/admin/users/${userId}/customer-access`); + return data; +} + +export async function setUserCustomerAccess(userId: string, customerIds: string[]): Promise { + await api.put(`/admin/users/${userId}/customer-access`, { customerIds }); +} diff --git a/portal/frontend/src/api/dashboard.ts b/portal/frontend/src/api/dashboard.ts new file mode 100644 index 0000000..3886982 --- /dev/null +++ b/portal/frontend/src/api/dashboard.ts @@ -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 { + const { data } = await api.get('/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()}`; +} diff --git a/portal/frontend/src/api/measurements.ts b/portal/frontend/src/api/measurements.ts new file mode 100644 index 0000000..9efcfbe --- /dev/null +++ b/portal/frontend/src/api/measurements.ts @@ -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 { + const { data } = await api.get('/sites/devices'); + return data; +} + +export async function fetchRawMeasurements(params: { + fromUtc: string; + toUtc: string; + deviceIds?: string[]; + limit?: number; + offset?: number; +}): Promise { + const { data } = await api.get('/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()}`; +} diff --git a/portal/frontend/src/api/users.ts b/portal/frontend/src/api/users.ts index ddd72bd..d5ace3c 100644 --- a/portal/frontend/src/api/users.ts +++ b/portal/frontend/src/api/users.ts @@ -14,12 +14,14 @@ export interface CreateUserPayload { displayName: string; password: string; isAdmin: boolean; + isRestrictedAdmin?: boolean; } export interface UpdateUserPayload { displayName: string; isActive: boolean; isAdmin: boolean; + isRestrictedAdmin?: boolean; } export async function listUsers(): Promise { diff --git a/portal/frontend/src/components/layout/AppLayout.tsx b/portal/frontend/src/components/layout/AppLayout.tsx index 659c69f..bc1df5c 100644 --- a/portal/frontend/src/components/layout/AppLayout.tsx +++ b/portal/frontend/src/components/layout/AppLayout.tsx @@ -1,7 +1,7 @@ import { Layout, Menu, Button, Typography, Space, Tag } from 'antd'; import { DashboardOutlined, SettingOutlined, LogoutOutlined, - LineChartOutlined, ApartmentOutlined, TeamOutlined, + LineChartOutlined, ApartmentOutlined, TeamOutlined, TableOutlined, } from '@ant-design/icons'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useAuth } from '../../hooks/useAuth'; @@ -28,9 +28,16 @@ export function AppLayout() { ? [{ key: '/admin/customers', icon: , label: 'Customers' }] : [{ key: '/admin/sites', icon: , 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: , label: 'Measurements' }]; + const items = [ { key: '/', icon: , label: 'Dashboard' }, { key: '/dashboards', icon: , label: 'Dashboards' }, + ...measurementsItem, ...(userIsAdmin ? [...adminItems, { key: '/settings', icon: , label: 'Settings' }] : []), diff --git a/portal/frontend/src/components/users/CustomerAccessModal.tsx b/portal/frontend/src/components/users/CustomerAccessModal.tsx new file mode 100644 index 0000000..0171f51 --- /dev/null +++ b/portal/frontend/src/components/users/CustomerAccessModal.tsx @@ -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>(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 ( + saveMut.mutate(Array.from(selected))} + okText="Save" + okButtonProps={{ disabled: isFullAdmin, loading: saveMut.isPending }} + width={560} + > + {loadingAccess || loadingCustomers ? ( + + ) : ( + <> + {isFullAdmin && ( + + )} + {!isFullAdmin && !access?.isRestrictedAdmin && ( + + )} + {!isFullAdmin && access?.isRestrictedAdmin && selected.size === 0 && ( + + )} + + {customers.length === 0 ? ( + + ) : ( + + {customers.map((c: CustomerListItem) => ( + toggle(c.id)} + > + {c.code} — {c.name} + {!c.isActive && Disabled} + + ))} + + )} + + )} + + ); +} diff --git a/portal/frontend/src/components/users/UserFormDrawer.tsx b/portal/frontend/src/components/users/UserFormDrawer.tsx index b8e5128..9ec2690 100644 --- a/portal/frontend/src/components/users/UserFormDrawer.tsx +++ b/portal/frontend/src/components/users/UserFormDrawer.tsx @@ -1,7 +1,9 @@ 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'; +const { Text } = Typography; + type Mode = | { kind: 'create' } | { kind: 'edit'; user: UserListItem }; @@ -15,6 +17,14 @@ interface Props { onSubmit: (values: CreateUserPayload | UpdateUserPayload) => Promise; } +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) { const [form] = Form.useForm(); @@ -25,29 +35,34 @@ export function UserFormDrawer({ open, mode, submitting, error, onClose, onSubmi email: mode.user.email, displayName: mode.user.displayName, isActive: mode.user.isActive, - isAdmin: mode.user.roles.includes('Admin'), + role: inferRole(mode.user), }); } else { form.resetFields(); - form.setFieldsValue({ isActive: true, isAdmin: false }); + form.setFieldsValue({ isActive: true, role: 'none' as RoleChoice }); } }, [open, mode, form]); const isEdit = mode?.kind === 'edit'; const handleFinish = async (values: Record) => { + const role = values.role as RoleChoice; + const flags = { + isAdmin: role === 'admin', + isRestrictedAdmin: role === 'restricted', + }; if (isEdit) { await onSubmit({ displayName: String(values.displayName ?? ''), isActive: Boolean(values.isActive), - isAdmin: Boolean(values.isAdmin), + ...flags, }); } else { await onSubmit({ email: String(values.email ?? '').trim(), displayName: String(values.displayName ?? ''), password: String(values.password ?? ''), - isAdmin: Boolean(values.isAdmin), + ...flags, }); } }; @@ -55,7 +70,7 @@ export function UserFormDrawer({ open, mode, submitting, error, onClose, onSubmi return ( )} - - + + + + + None + Regular user — Dashboard + Dashboards only. + + + Administrator + Full access to all customers, settings, user mgmt. + + + Restricted admin (fleet-scoped) + Same pages as Admin but Postgres RLS limits which customers they see. Grant per-customer access from the Users page. + + + {isEdit && ( diff --git a/portal/frontend/src/pages/AdminCustomerDetailPage.tsx b/portal/frontend/src/pages/AdminCustomerDetailPage.tsx index d8f2aaa..6ae6db4 100644 --- a/portal/frontend/src/pages/AdminCustomerDetailPage.tsx +++ b/portal/frontend/src/pages/AdminCustomerDetailPage.tsx @@ -4,7 +4,9 @@ import { DatePicker, Statistic, Row, Col, Alert, } from 'antd'; 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 { useNavigate, useParams } from 'react-router-dom'; import dayjs, { type Dayjs } from 'dayjs'; @@ -15,6 +17,7 @@ import { type FleetTariffView, type FleetTariffPeriodView, type FleetCostDay, type FleetCostDeviceRow, } from '../api/fleet'; +import { downloadFleetCustomerCostXlsx } from '../api/dashboard'; import { fetchGrafanaConfig } from '../api/grafana'; const { Text } = Typography; @@ -260,20 +263,29 @@ function CostTab({ customerId }: { customerId: string }) { return ( - - Range (UTC): - 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')], - }} - /> + + + Range (UTC): + 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')], + }} + /> + + {isLoading && } diff --git a/portal/frontend/src/pages/AdminFleetDashboardPage.tsx b/portal/frontend/src/pages/AdminFleetDashboardPage.tsx index 94c544b..c3c9f44 100644 --- a/portal/frontend/src/pages/AdminFleetDashboardPage.tsx +++ b/portal/frontend/src/pages/AdminFleetDashboardPage.tsx @@ -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 { ApartmentOutlined, ThunderboltOutlined, TeamOutlined, CheckCircleOutlined, DollarOutlined, + DownloadOutlined, } from '@ant-design/icons'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { fetchFleetDashboard, type FleetCustomerSummary } from '../api/fleet'; +import { downloadFleetDashboardXlsx } from '../api/dashboard'; const { Text } = Typography; @@ -98,7 +100,20 @@ export function AdminFleetDashboardPage() { - + + + + } + > {data && data.customers.length === 0 ? ( ) : ( diff --git a/portal/frontend/src/pages/DashboardPage.tsx b/portal/frontend/src/pages/DashboardPage.tsx index 389ce66..f074f03 100644 --- a/portal/frontend/src/pages/DashboardPage.tsx +++ b/portal/frontend/src/pages/DashboardPage.tsx @@ -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 { 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() { const { isAdmin } = useAppInfo(); if (isAdmin) return ; + return ; +} + +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 ( - - Dashboard - - This is the authenticated landing page. Real telemetry, cost summaries, and embedded - Grafana dashboards land in later phases. - - + + + + + Range (UTC): + 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')], + }} + /> + + + + + + + + + {error && ( + + )} + + + + + + + + + + + ); } + +function KpiRow({ data, loading }: { data: DashboardSummary | undefined; loading: boolean }) { + return ( + + + + } + loading={loading} + /> + + + + + } + loading={loading} + valueStyle={{ color: data?.currentActivePowerKw == null ? '#bbb' : undefined }} + /> + + + + + } + loading={loading} + /> + + + + + } + loading={loading} + valueStyle={{ + color: data?.totalCost == null ? '#bbb' : '#3f8600', + }} + /> + {data && data.totalCost == null && ( + + No active tariff for any site — set one in Settings → Rates. + + )} + + + + ); +} + +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
+ +
; + } + + if (!data || data.chart.length === 0) { + return
+ + + No measurements yet in this window. + +
; + } + + return ; +} + +function DeviceTable({ rows, loading }: { rows: DashboardDeviceRow[]; loading: boolean }) { + const columns: ColumnsType = [ + { title: 'Device', dataIndex: 'deviceName', key: 'd', render: (v: string) => {v} }, + { title: 'Site', dataIndex: 'siteName', key: 's', render: (v: string | null) => v ?? }, + { 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) : never }, + { title: 'Cost', dataIndex: 'cost', key: 'c', align: 'right' as const, + render: (v: number | null) => v == null ? : {v.toFixed(2)} }, + ]; + + return ( + + 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 just now; + if (ageMin < 5) return {ageMin}m ago; + if (ageMin < 60) return {ageMin}m ago; + const ageHr = Math.floor(ageMin / 60); + if (ageHr < 24) return {ageHr}h ago; + return {Math.floor(ageHr / 24)}d ago; +} diff --git a/portal/frontend/src/pages/DashboardsPage.tsx b/portal/frontend/src/pages/DashboardsPage.tsx index ac3b758..37d020e 100644 --- a/portal/frontend/src/pages/DashboardsPage.tsx +++ b/portal/frontend/src/pages/DashboardsPage.tsx @@ -56,12 +56,18 @@ export function DashboardsPage() { onClick={(e) => setSelected(e.key)} items={dashboards.map((d: GrafanaDashboard) => ({ key: d.uid, - icon: , + icon: , + // 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: ( -
-
{d.title}
+
+
{d.title}
{d.description && ( - {d.description} + + {d.description} + )}
), diff --git a/portal/frontend/src/pages/MeasurementsPage.tsx b/portal/frontend/src/pages/MeasurementsPage.tsx new file mode 100644 index 0000000..9bd0827 --- /dev/null +++ b/portal/frontend/src/pages/MeasurementsPage.tsx @@ -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([]); + const [limit, setLimit] = useState(200); + const [offset, setOffset] = useState(0); + const [exportRowCap, setExportRowCap] = useState(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 = [ + { + 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) => {v} }, + { title: 'Site', dataIndex: 'siteName', key: 's', render: (v: string | null) => v ?? }, + { 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 ( + + + + + Range (UTC): + 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')], + }} + /> + + + + Meters: + { setLimit(v); setOffset(0); }} + options={PREVIEW_LIMIT_OPTIONS.map(n => ({ value: n, label: n.toString() }))} + style={{ width: 96 }} + /> + · + Export row cap: + setExportRowCap(typeof v === 'number' ? v : 100_000)} + style={{ width: 130 }} + /> + + max 250 000 — shrink the range if you exceed this + + + + + + {error && ( + + )} + + + Measurements + {totalCount.toLocaleString()} rows match + {selectedDeviceIds.length > 0 && ( + {selectedDeviceIds.length} meter{selectedDeviceIds.length === 1 ? '' : 's'} selected + )} + + } + extra={ + + + + + } + > + + rowKey={(r) => `${r.time}-${r.deviceName}`} + size="small" + columns={columns} + dataSource={data?.rows ?? []} + loading={isLoading} + pagination={false} + scroll={{ x: 'max-content' }} + /> + + + + {totalCount === 0 + ? 'No measurements in this window.' + : `Showing ${showingFrom.toLocaleString()}–${showingTo.toLocaleString()} of ${totalCount.toLocaleString()}`} + + + + + + + + + ); +} diff --git a/portal/frontend/src/pages/UsersPage.tsx b/portal/frontend/src/pages/UsersPage.tsx index f37737a..fa543ba 100644 --- a/portal/frontend/src/pages/UsersPage.tsx +++ b/portal/frontend/src/pages/UsersPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Card, Button, Table, Tag, Popconfirm, Modal, Input, Space, message } from 'antd'; 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 { listUsers, @@ -14,15 +14,19 @@ import { type UpdateUserPayload, } from '../api/users'; 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 }; export function UsersPage() { const qc = useQueryClient(); + const { isAdmin: isAdminMode } = useAppInfo(); const [drawerMode, setDrawerMode] = useState(null); const [drawerError, setDrawerError] = useState(null); const [resetTarget, setResetTarget] = useState(null); const [resetValue, setResetValue] = useState(''); + const [accessTarget, setAccessTarget] = useState(null); const { data: users = [], isLoading } = useQuery({ queryKey: ['admin', 'users'], @@ -118,6 +122,15 @@ export function UsersPage() { > Edit + {isAdminMode && user.roles.includes('RestrictedAdmin') && ( + + )}