diff --git a/portal/OPERATIONS.md b/portal/OPERATIONS.md index c65fbed..b6af902 100644 --- a/portal/OPERATIONS.md +++ b/portal/OPERATIONS.md @@ -316,4 +316,98 @@ docker volume rm \ rm -rf /srv/portal/abc0001 # DNS record + cert (manual or via your DNS automation) + +# If using the fleet aggregator: also delete the customer from the Admin +# Customers page (UI Delete) or via psql against the central DB: +# DELETE FROM fleet."Customers" WHERE "Code" = 'ABC0001'; +# (cascades to Sites, Devices, PowerMeasurements, IngestEvents) ``` + +--- + +## Fleet aggregator (Admin stack) + +For background and the full design see [docs/FLEET-DESIGN.md](./docs/FLEET-DESIGN.md). This section covers the day-to-day ops. + +### One-time: provisioning the Admin stack + +```bash +# 1. Create a dedicated Postgres DB for the central fleet +docker exec createdb -U power_user admin_fleet + +# 2. Spin up the Admin portal (same image as a customer stack, different env) +docker run -d --name admin-portal --restart unless-stopped \ + --network \ + -e Application__RunMode=Admin \ + -e ASPNETCORE_ENVIRONMENT=Production \ + -e Application__PublicUrl=https://admin.portal.example.com \ + -e Database__ConnectionString='Host=;Port=5432;Database=admin_fleet;Username=power_user;Password=' \ + -e Authentication__DefaultAdminEmail=ops@yourco.example \ + -e Authentication__DefaultAdminPassword= \ + -v admin-portal-keys:/data/keys \ + -v admin-portal-branding:/data/branding \ + tau-acuvim-portal:latest + +# 3. (Optional) Spin up an Admin-side Grafana pointed at admin_fleet +docker run -d --name admin-grafana --restart unless-stopped \ + --network \ + -e GF_SECURITY_ADMIN_PASSWORD= \ + -e GF_SECURITY_ALLOW_EMBEDDING=true \ + -e GF_AUTH_ANONYMOUS_ENABLED=false \ + -e POSTGRES_DB=admin_fleet \ + -e POSTGRES_USER=power_user \ + -e POSTGRES_PASSWORD= \ + -v admin-grafana-data:/var/lib/grafana \ + -v /srv/portal/grafana/provisioning:/etc/grafana/provisioning:ro \ + -v /srv/portal/grafana/dashboards-admin:/var/lib/grafana/dashboards:ro \ + grafana/grafana:11.4.0 +``` + +Behind Traefik: add labels on `admin-portal` and `admin-grafana` mirroring the per-customer pattern, with `Host(admin.portal.example.com)` and (for Grafana) `&& PathPrefix(/grafana)`. Choose a Grafana auth mode from README Security (forwardAuth / auth.proxy / render tokens) before exposing. + +### Onboarding a new customer end-to-end + +```bash +# A. Admin side — register and capture token (one-time per customer) +# 1. Sign in to https://admin.portal.example.com +# 2. Customers → "Register customer" → Code=ABC0001, Name=Acme Corp +# 3. Copy the token shown ONCE. + +# B. Customer side — spin up their stack (per OPERATIONS "Provisioning a new customer") +# AND add to their .env: +cat >> /srv/portal/abc0001/.env < +FleetIngest__IntervalSeconds=60 +FleetIngest__BatchSize=5000 +EOF + +# 3. Restart the customer's portal so the push service starts. +docker compose -f /path/to/portal/docker-compose.prod.yml --env-file .env \ + up -d portal + +# C. Verify +# 1. In Admin UI → Customers, ABC0001 should show "Last push" advance within a minute. +# 2. Click the row → Customer detail → "Recent ingest" tab should list sites/devices +# batches (and measurements once any are ingested locally). +# 3. From the host: +docker exec psql -U power_user -d admin_fleet -c \ + 'SELECT "BatchType","RowsAccepted","ReceivedAt" FROM fleet."IngestEvents" ORDER BY "ReceivedAt" DESC LIMIT 10;' +``` + +### Common ops + +| What | Where | +|---|---| +| Rotate a customer's push token | Admin UI → Customers → row's "Rotate token" button. Update customer's `.env` and restart their portal. Brief push gap (until restart) is expected. | +| Disable a customer (stop accepting their data) | Admin UI → Customers → Edit → Active off. Ingest returns 401 immediately; data already in `fleet.*` is untouched. | +| Investigate "why hasn't ABC0001 shown up?" | Customer detail page → Recent ingest tab. Check for 401s, rejected rows, error messages. Or: `SELECT * FROM fleet."IngestEvents" WHERE "CustomerId" = '' ORDER BY "ReceivedAt" DESC;` | +| Inspect compression | `SELECT * FROM hypertable_compression_stats('fleet."PowerMeasurements"');` | +| Force a continuous aggregate refresh | `CALL refresh_continuous_aggregate('fleet.hourly_per_device', NULL, NULL);` | +| Decommission a customer from the fleet | Admin UI → Customers → Delete (cascades sites/devices/measurements/events). Customer's local stack is untouched; their portal will get 401s on push until they disable `FleetIngest__Enabled` or you re-register them. | + +### Backing up the central DB + +Same `pg_dump` pattern as a customer DB (see above), targeting `admin_fleet`. Includes hypertable chunks; restore with `pg_restore` then run the Admin portal once to re-bootstrap the continuous aggregate refresh policy (`FleetTimescaleBootstrapper` is idempotent). diff --git a/portal/README.md b/portal/README.md index c8d528e..f419573 100644 --- a/portal/README.md +++ b/portal/README.md @@ -386,7 +386,13 @@ For per-customer provisioning, secret rotation, backups, and health monitoring s A second deployment of the same image — `RunMode=Admin`, separate DB — aggregates data from all customer stacks for a fleet-wide operator view. See [docs/FLEET-DESIGN.md](./docs/FLEET-DESIGN.md) for the full design. -**Phase 14 (this release):** the full push pipeline is live. Customer stacks with `FleetIngest__Enabled=true` run a `FleetPushService` background loop that batches sites, devices, and measurements (cursor by `ReceivedAt` — firmware buffer-and-replay back-fills get picked up automatically) and POSTs them to `FleetIngest__Url` with `X-Customer-Token`. Admin's `/api/fleet/ingest` upserts and writes an `IngestEvents` audit row per batch. Admin's `FleetTimescaleBootstrapper` makes `fleet.PowerMeasurements` a hypertable with compression-after-7-days and a realtime `fleet.hourly_per_device` continuous aggregate. +**Phase 15 (this release):** the operator surface is live. Sign in to the Admin stack and: +- **Dashboard** shows fleet headline — customer / active counts, today's measurement count and kWh imported, per-customer summary table with lag indicators. Auto-refreshes every 30s. +- **Customers** lists registered customers; click a row to drill into one. +- **Customer detail** page shows mirrored sites, mirrored devices, the 50 most recent measurements, and the last 20 ingest events (with rejection counts, batch sizes, time-spreads — useful when a firmware replay arrives and you want to see the wave). +- `grafana/dashboards-admin/` ships **Fleet Overview** + **Customer Drilldown** dashboards reading from the realtime `fleet.hourly_per_device` continuous aggregate. Mount this folder instead of `grafana/dashboards/` on the Admin's Grafana container — see [OPERATIONS.md](./OPERATIONS.md). + +**Phase 14:** the full push pipeline. Customer stacks with `FleetIngest__Enabled=true` run a `FleetPushService` background loop that batches sites, devices, and measurements (cursor by `ReceivedAt` — firmware buffer-and-replay back-fills get picked up automatically) and POSTs them to `FleetIngest__Url` with `X-Customer-Token`. Admin's `/api/fleet/ingest` upserts and writes an `IngestEvents` audit row per batch. Admin's `FleetTimescaleBootstrapper` makes `fleet.PowerMeasurements` a hypertable with compression-after-7-days and a realtime `fleet.hourly_per_device` continuous aggregate. **Spin up an Admin stack:** ```powershell diff --git a/portal/frontend/src/App.tsx b/portal/frontend/src/App.tsx index 5eef3e9..abd7eb9 100644 --- a/portal/frontend/src/App.tsx +++ b/portal/frontend/src/App.tsx @@ -12,6 +12,7 @@ import { DashboardPage } from './pages/DashboardPage'; import { DashboardsPage } from './pages/DashboardsPage'; import { AdminSitesPage } from './pages/AdminSitesPage'; import { AdminCustomersPage } from './pages/AdminCustomersPage'; +import { AdminCustomerDetailPage } from './pages/AdminCustomerDetailPage'; import { SettingsPage } from './pages/SettingsPage'; const queryClient = new QueryClient({ @@ -54,6 +55,14 @@ export default function App() { } /> + + + + } + /> { + const { data } = await api.get('/fleet/dashboard'); + return data; +} + +export async function fetchFleetCustomerDetail(id: string): Promise { + const { data } = await api.get(`/fleet/customers/${id}/detail`); + return data; +} diff --git a/portal/frontend/src/pages/AdminCustomerDetailPage.tsx b/portal/frontend/src/pages/AdminCustomerDetailPage.tsx new file mode 100644 index 0000000..da9b9b0 --- /dev/null +++ b/portal/frontend/src/pages/AdminCustomerDetailPage.tsx @@ -0,0 +1,103 @@ +import { Card, Descriptions, Tabs, Table, Tag, Typography, Button, Space, Spin, Result } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { ArrowLeftOutlined } from '@ant-design/icons'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + fetchFleetCustomerDetail, type FleetSite, type FleetDevice, + type FleetRecentMeasurement, type FleetIngestEvent, +} from '../api/fleet'; + +const { Text } = Typography; + +export function AdminCustomerDetailPage() { + const { id = '' } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { data, isLoading, error } = useQuery({ + queryKey: ['fleet-customer', id], + queryFn: () => fetchFleetCustomerDetail(id), + refetchInterval: 30_000, + }); + + if (isLoading) return
; + if (error || !data) { + return navigate('/admin/customers')}>Back} />; + } + + const siteCols: ColumnsType = [ + { title: 'Name', dataIndex: 'name', key: 'name' }, + { title: 'Address', dataIndex: 'address', key: 'addr', render: v => v ?? }, + { title: 'Active', dataIndex: 'isActive', key: 'a', render: (v: boolean) => v ? Active : Inactive }, + ]; + + const deviceCols: ColumnsType = [ + { title: 'Name', dataIndex: 'name', key: 'name' }, + { title: 'External ID', dataIndex: 'externalId', key: 'ext', render: v => {v} }, + { title: 'Description', dataIndex: 'description', key: 'desc', render: v => v ?? }, + { title: 'Active', dataIndex: 'isActive', key: 'a', render: (v: boolean) => v ? Active : Inactive }, + ]; + + const measCols: ColumnsType = [ + { title: 'Time (UTC)', dataIndex: 'time', key: 't', render: (v: string) => new Date(v).toISOString().replace('T', ' ').slice(0, 19) }, + { title: 'Device', dataIndex: 'deviceName', key: 'd' }, + { title: 'Active power (kW)', dataIndex: 'activePowerKw', key: 'p', render: (v: number) => v.toFixed(3) }, + { title: 'kWh imported (cumulative)', dataIndex: 'energyImportedKwh', key: 'e', render: (v: number | null) => v == null ? '—' : v.toFixed(2) }, + ]; + + const eventCols: ColumnsType = [ + { title: 'Received (UTC)', dataIndex: 'receivedAt', key: 'r', render: (v: string) => new Date(v).toISOString().replace('T', ' ').slice(0, 19) }, + { title: 'Type', dataIndex: 'batchType', key: 'bt' }, + { title: 'Accepted', dataIndex: 'rowsAccepted', key: 'a' }, + { title: 'Rejected', dataIndex: 'rowsRejected', key: 'rj', render: (v: number) => v > 0 ? {v} : v }, + { title: 'Bytes', dataIndex: 'batchBytes', key: 'b' }, + { title: 'Time spread', dataIndex: 'timeSpread', key: 'ts', render: (v: string | null) => v ?? '—' }, + { title: 'Error', dataIndex: 'error', key: 'e', render: (v: string | null) => v ? {v} : '—' }, + ]; + + return ( + + + + {data.code} · {data.name} + {data.isActive ? Active : Disabled} + + + + + {data.id} + {new Date(data.createdAt).toLocaleString()} + {data.firstSeenAt ? new Date(data.firstSeenAt).toLocaleString() : Never} + {data.lastSeenAt ? new Date(data.lastSeenAt).toLocaleString() : Never} + + + + + rowKey="receivedAt" columns={eventCols} dataSource={data.recentIngestEvents} pagination={false} size="small" />, + }, + { + key: 'measurements', + label: `Recent measurements (${data.recentMeasurements.length})`, + children: rowKey={(r) => `${r.time}-${r.deviceId}`} columns={measCols} dataSource={data.recentMeasurements} pagination={false} size="small" />, + }, + { + key: 'sites', + label: `Sites (${data.sites.length})`, + children: rowKey="id" columns={siteCols} dataSource={data.sites} pagination={false} size="small" />, + }, + { + key: 'devices', + label: `Devices (${data.devices.length})`, + children: rowKey="id" columns={deviceCols} dataSource={data.devices} pagination={false} size="small" />, + }, + ]} + /> + + + ); +} diff --git a/portal/frontend/src/pages/AdminCustomersPage.tsx b/portal/frontend/src/pages/AdminCustomersPage.tsx index f61b172..7ec1572 100644 --- a/portal/frontend/src/pages/AdminCustomersPage.tsx +++ b/portal/frontend/src/pages/AdminCustomersPage.tsx @@ -3,6 +3,7 @@ import { Card, Table, Button, Space, Tag, Popconfirm, Tooltip, Typography, messa import type { ColumnsType } from 'antd/es/table'; import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; import { listCustomers, createCustomer, updateCustomer, rotateCustomerToken, deleteCustomer, type CustomerListItem, type CreateCustomerPayload, type UpdateCustomerPayload, @@ -16,6 +17,7 @@ type FormMode = { kind: 'create' } | { kind: 'edit'; customer: CustomerListItem export function AdminCustomersPage() { const qc = useQueryClient(); + const navigate = useNavigate(); const [formMode, setFormMode] = useState(null); const [formError, setFormError] = useState(null); const [tokenModal, setTokenModal] = useState<{ open: boolean; code: string | null; token: string | null }>({ @@ -153,6 +155,14 @@ export function AdminCustomersPage() { dataSource={customers} loading={isLoading} pagination={{ pageSize: 25 }} + onRow={(record) => ({ + onClick: (e) => { + const t = e.target as HTMLElement; + if (t.closest('button, .ant-popover')) return; + navigate(`/admin/customers/${record.id}`); + }, + style: { cursor: 'pointer' }, + })} /> = [ + { title: 'Code', dataIndex: 'code', key: 'code', render: (v) => {v} }, + { title: 'Name', dataIndex: 'name', key: 'name' }, + { + title: 'Status', dataIndex: 'isActive', key: 'active', + render: (v: boolean) => (v ? Active : Disabled) + }, + { + title: 'Last push', dataIndex: 'lastSeenAt', key: 'last', + render: (v: string | null) => v ? lagDescription(v) : Never + }, + { title: 'Sites', dataIndex: 'sites', key: 'sites' }, + { title: 'Devices', dataIndex: 'devices', key: 'devices' }, + { + title: 'Today (rows)', dataIndex: 'measurementsToday', key: 'mt', + render: (n: number) => n.toLocaleString() + }, + { + title: 'Today (kWh imp.)', dataIndex: 'kwhImportedToday', key: 'kwh', + render: (v: number | null) => v == null ? '—' : v.toFixed(2) + }, + ]; + + return ( +
+ + + + } loading={isLoading} /> + + + + + } + loading={isLoading} + /> + + + + + } + loading={isLoading} + /> + + + + + } + loading={isLoading} + /> + + + + + + {data && data.customers.length === 0 ? ( + + ) : ( + + rowKey="id" + columns={columns} + dataSource={data?.customers ?? []} + loading={isLoading} + pagination={{ pageSize: 25 }} + onRow={(record) => ({ + onClick: () => navigate(`/admin/customers/${record.id}`), + style: { cursor: 'pointer' }, + })} + /> + )} + +
+ ); +} + +function lagDescription(iso: string): React.ReactNode { + const ts = new Date(iso).getTime(); + const ageMs = Date.now() - ts; + 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/DashboardPage.tsx b/portal/frontend/src/pages/DashboardPage.tsx index e865d4f..389ce66 100644 --- a/portal/frontend/src/pages/DashboardPage.tsx +++ b/portal/frontend/src/pages/DashboardPage.tsx @@ -1,8 +1,13 @@ import { Card, Typography } from 'antd'; +import { useAppInfo } from '../hooks/useAppInfo'; +import { AdminFleetDashboardPage } from './AdminFleetDashboardPage'; const { Title, Paragraph } = Typography; export function DashboardPage() { + const { isAdmin } = useAppInfo(); + if (isAdmin) return ; + return ( Dashboard diff --git a/portal/grafana/dashboards-admin/.gitkeep b/portal/grafana/dashboards-admin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/portal/grafana/dashboards-admin/customer-drilldown.json b/portal/grafana/dashboards-admin/customer-drilldown.json new file mode 100644 index 0000000..077bac2 --- /dev/null +++ b/portal/grafana/dashboards-admin/customer-drilldown.json @@ -0,0 +1,108 @@ +{ + "annotations": { "list": [] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "id": 1, + "type": "timeseries", + "title": "Active power per device — $customer_code", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "gridPos": { "x": 0, "y": 0, "w": 24, "h": 9 }, + "fieldConfig": { + "defaults": { + "unit": "kwatt", + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false } + } + }, + "options": { "tooltip": { "mode": "multi", "sort": "desc" }, "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "format": "time_series", + "rawSql": "SELECT h.bucket AS time, d.\"Name\" AS metric, h.avg_kw AS value FROM fleet.hourly_per_device h JOIN fleet.\"Devices\" d ON d.\"CustomerId\" = h.\"CustomerId\" AND d.\"Id\" = h.\"DeviceId\" WHERE h.\"CustomerId\" = '$customer' AND $__timeFilter(h.bucket) ORDER BY 1" + } + ] + }, + { + "id": 2, + "type": "timeseries", + "title": "Cumulative kWh imported per device", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "gridPos": { "x": 0, "y": 9, "w": 24, "h": 9 }, + "fieldConfig": { + "defaults": { + "unit": "kwatth", + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 5, "showPoints": "never" } + } + }, + "options": { "tooltip": { "mode": "multi", "sort": "desc" }, "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "format": "time_series", + "rawSql": "SELECT m.\"Time\" AS time, d.\"Name\" AS metric, m.\"EnergyImportedKwh\" AS value FROM fleet.\"PowerMeasurements\" m JOIN fleet.\"Devices\" d ON d.\"CustomerId\" = m.\"CustomerId\" AND d.\"Id\" = m.\"DeviceId\" WHERE m.\"CustomerId\" = '$customer' AND $__timeFilter(m.\"Time\") AND m.\"EnergyImportedKwh\" IS NOT NULL ORDER BY m.\"Time\"" + } + ] + }, + { + "id": 3, + "type": "table", + "title": "Recent ingest events", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "gridPos": { "x": 0, "y": 18, "w": 24, "h": 8 }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "format": "table", + "rawSql": "SELECT \"ReceivedAt\" AS \"Received\", \"BatchType\" AS \"Type\", \"RowsAccepted\" AS \"Accepted\", \"RowsRejected\" AS \"Rejected\", \"BatchBytes\" AS \"Bytes\", \"TimeSpread\" AS \"Spread\", \"Error\" AS \"Error\" FROM fleet.\"IngestEvents\" WHERE \"CustomerId\" = '$customer' ORDER BY \"ReceivedAt\" DESC LIMIT 25" + } + ] + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["fleet", "admin", "drilldown"], + "templating": { + "list": [ + { + "name": "customer", + "label": "Customer", + "type": "query", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "query": "SELECT \"Code\" || ' — ' || \"Name\" AS __text, \"Id\"::text AS __value FROM fleet.\"Customers\" ORDER BY \"Code\"", + "refresh": 1, + "multi": false, + "includeAll": false, + "current": {} + }, + { + "name": "customer_code", + "label": "Customer (display)", + "type": "query", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "query": "SELECT \"Code\" AS __text, \"Code\" AS __value FROM fleet.\"Customers\" WHERE \"Id\"::text = '$customer'", + "refresh": 2, + "hide": 2, + "multi": false, + "includeAll": false, + "current": {} + } + ] + }, + "time": { "from": "now-24h", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "Customer Drilldown", + "uid": "customer-drilldown", + "version": 1 +} diff --git a/portal/grafana/dashboards-admin/fleet-overview.json b/portal/grafana/dashboards-admin/fleet-overview.json new file mode 100644 index 0000000..9f9116a --- /dev/null +++ b/portal/grafana/dashboards-admin/fleet-overview.json @@ -0,0 +1,138 @@ +{ + "annotations": { "list": [] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "id": 1, + "type": "stat", + "title": "Active customers (push in last hour)", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "gridPos": { "x": 0, "y": 0, "w": 6, "h": 4 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } + } + }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "textMode": "auto", "graphMode": "none" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "format": "table", + "rawSql": "SELECT count(*) AS \"Active\" FROM fleet.\"Customers\" WHERE \"LastSeenAt\" > now() - INTERVAL '1 hour'" + } + ] + }, + { + "id": 2, + "type": "stat", + "title": "Total customers", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "gridPos": { "x": 6, "y": 0, "w": 6, "h": 4 }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "none", "textMode": "auto", "graphMode": "none" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "format": "table", + "rawSql": "SELECT count(*) AS \"Total\" FROM fleet.\"Customers\"" + } + ] + }, + { + "id": 3, + "type": "stat", + "title": "Measurements last 24h", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "gridPos": { "x": 12, "y": 0, "w": 6, "h": 4 }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "none", "textMode": "auto", "graphMode": "none" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "format": "table", + "rawSql": "SELECT SUM(samples)::bigint AS \"Samples\" FROM fleet.hourly_per_device WHERE bucket > now() - INTERVAL '24 hours'" + } + ] + }, + { + "id": 4, + "type": "stat", + "title": "Total kWh imported today", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "gridPos": { "x": 18, "y": 0, "w": 6, "h": 4 }, + "fieldConfig": { "defaults": { "unit": "kwatth" } }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "none", "textMode": "auto", "graphMode": "none" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "format": "table", + "rawSql": "SELECT COALESCE(SUM(kwh_imported_delta), 0) AS \"kWh\" FROM fleet.hourly_per_device WHERE bucket >= date_trunc('day', now())" + } + ] + }, + { + "id": 5, + "type": "timeseries", + "title": "Active power per customer (sum across devices)", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "gridPos": { "x": 0, "y": 4, "w": 24, "h": 9 }, + "fieldConfig": { + "defaults": { + "unit": "kwatt", + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false } + } + }, + "options": { "tooltip": { "mode": "multi", "sort": "desc" }, "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "format": "time_series", + "rawSql": "SELECT time_bucket($__interval, h.bucket) AS time, c.\"Code\" AS metric, SUM(h.avg_kw) AS value FROM fleet.hourly_per_device h JOIN fleet.\"Customers\" c ON c.\"Id\" = h.\"CustomerId\" WHERE $__timeFilter(h.bucket) GROUP BY 1, 2 ORDER BY 1" + } + ] + }, + { + "id": 6, + "type": "timeseries", + "title": "kWh imported per hour (per customer)", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "gridPos": { "x": 0, "y": 13, "w": 24, "h": 9 }, + "fieldConfig": { + "defaults": { + "unit": "kwatth", + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "bars", "lineWidth": 1, "fillOpacity": 80 } + } + }, + "options": { "tooltip": { "mode": "multi", "sort": "desc" }, "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "postgres", "uid": "timescaledb" }, + "format": "time_series", + "rawSql": "SELECT h.bucket AS time, c.\"Code\" AS metric, SUM(COALESCE(h.kwh_imported_delta, 0)) AS value FROM fleet.hourly_per_device h JOIN fleet.\"Customers\" c ON c.\"Id\" = h.\"CustomerId\" WHERE $__timeFilter(h.bucket) GROUP BY 1, 2 ORDER BY 1" + } + ] + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["fleet", "admin"], + "templating": { "list": [] }, + "time": { "from": "now-24h", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "Fleet Overview", + "uid": "fleet-overview", + "version": 1 +} diff --git a/portal/src/Tau.Acuvim.Portal/DTOs/FleetDashboardDtos.cs b/portal/src/Tau.Acuvim.Portal/DTOs/FleetDashboardDtos.cs new file mode 100644 index 0000000..ac39130 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/DTOs/FleetDashboardDtos.cs @@ -0,0 +1,50 @@ +namespace Tau.Acuvim.Portal.DTOs; + +public sealed record FleetCustomerSummary( + Guid Id, + string Code, + string Name, + bool IsActive, + DateTime? LastSeenAt, + int Sites, + int Devices, + long MeasurementsToday, + double? KwhImportedToday); + +public sealed record FleetDashboardDto( + int TotalCustomers, + int ActiveCustomers, + long TotalMeasurementsToday, + double TotalKwhImportedToday, + DateTime? OldestActiveLastSeenAt, + IReadOnlyList Customers); + +public sealed record FleetIngestEventDto( + DateTime ReceivedAt, + string BatchType, + int RowsAccepted, + int RowsRejected, + int BatchBytes, + string? ClientHwm, + TimeSpan? TimeSpread, + string? Error); + +public sealed record FleetRecentMeasurementDto( + DateTime Time, + Guid DeviceId, + string DeviceName, + double ActivePowerKw, + double? EnergyImportedKwh); + +public sealed record FleetCustomerDetailDto( + Guid Id, + string Code, + string Name, + bool IsActive, + DateTime? FirstSeenAt, + DateTime? LastSeenAt, + DateTime CreatedAt, + IReadOnlyList Sites, + IReadOnlyList Devices, + IReadOnlyList RecentMeasurements, + IReadOnlyList RecentIngestEvents); diff --git a/portal/src/Tau.Acuvim.Portal/Endpoints/FleetDashboardEndpoints.cs b/portal/src/Tau.Acuvim.Portal/Endpoints/FleetDashboardEndpoints.cs new file mode 100644 index 0000000..d53b941 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Endpoints/FleetDashboardEndpoints.cs @@ -0,0 +1,25 @@ +using Tau.Acuvim.Portal.Constants; +using Tau.Acuvim.Portal.Services; + +namespace Tau.Acuvim.Portal.Endpoints; + +public static class FleetDashboardEndpoints +{ + public static IEndpointRouteBuilder MapFleetDashboardEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/fleet") + .RequireAuthorization(Policies.AdminOnly) + .WithTags("Fleet"); + + group.MapGet("/dashboard", async (FleetQueryService svc, CancellationToken ct) => + Results.Ok(await svc.GetDashboardAsync(ct))); + + group.MapGet("/customers/{id:guid}/detail", async (Guid id, FleetQueryService svc, CancellationToken ct) => + { + var detail = await svc.GetCustomerDetailAsync(id, ct); + return detail is null ? Results.NotFound() : Results.Ok(detail); + }); + + return app; + } +} diff --git a/portal/src/Tau.Acuvim.Portal/Program.cs b/portal/src/Tau.Acuvim.Portal/Program.cs index 60cb39e..59d6fb7 100644 --- a/portal/src/Tau.Acuvim.Portal/Program.cs +++ b/portal/src/Tau.Acuvim.Portal/Program.cs @@ -148,6 +148,7 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); } builder.Services.AddHealthChecks() @@ -264,6 +265,7 @@ try { app.MapAdminCustomersEndpoints(); app.MapFleetIngestEndpoints(); + app.MapFleetDashboardEndpoints(); } app.MapHealthChecks("/health", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions diff --git a/portal/src/Tau.Acuvim.Portal/Services/FleetQueryService.cs b/portal/src/Tau.Acuvim.Portal/Services/FleetQueryService.cs new file mode 100644 index 0000000..2dab16d --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Services/FleetQueryService.cs @@ -0,0 +1,132 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Tau.Acuvim.Portal.Data; +using Tau.Acuvim.Portal.DTOs; + +namespace Tau.Acuvim.Portal.Services; + +public sealed class FleetQueryService(AdminDbContext db) +{ + private static readonly TimeSpan ActiveWindow = TimeSpan.FromHours(1); + + public async Task GetDashboardAsync(CancellationToken ct = default) + { + var nowUtc = DateTime.UtcNow; + var activeThreshold = nowUtc - ActiveWindow; + var dayStart = DateTime.SpecifyKind(nowUtc.Date, DateTimeKind.Utc); + + var customers = await db.Customers.AsNoTracking() + .OrderBy(c => c.Code) + .Select(c => new { c.Id, c.Code, c.Name, c.IsActive, c.LastSeenAt }) + .ToListAsync(ct); + + var siteCounts = await db.FleetSites.AsNoTracking() + .GroupBy(s => s.CustomerId) + .Select(g => new { CustomerId = g.Key, N = g.Count() }) + .ToDictionaryAsync(x => x.CustomerId, x => x.N, ct); + + var deviceCounts = await db.FleetDevices.AsNoTracking() + .GroupBy(d => d.CustomerId) + .Select(g => new { CustomerId = g.Key, N = g.Count() }) + .ToDictionaryAsync(x => x.CustomerId, x => x.N, ct); + + var todayStats = await GetTodayStatsAsync(dayStart, ct); + + var summaries = customers.Select(c => new FleetCustomerSummary( + c.Id, c.Code, c.Name, c.IsActive, c.LastSeenAt, + siteCounts.GetValueOrDefault(c.Id, 0), + deviceCounts.GetValueOrDefault(c.Id, 0), + todayStats.TryGetValue(c.Id, out var s) ? s.Measurements : 0, + todayStats.TryGetValue(c.Id, out s) ? s.KwhImported : null + )).ToList(); + + var activeCount = customers.Count(c => c.LastSeenAt >= activeThreshold); + var oldestActive = customers + .Where(c => c.LastSeenAt is not null && c.LastSeenAt >= activeThreshold) + .Min(c => c.LastSeenAt); + + return new FleetDashboardDto( + TotalCustomers: customers.Count, + ActiveCustomers: activeCount, + TotalMeasurementsToday: summaries.Sum(x => x.MeasurementsToday), + TotalKwhImportedToday: summaries.Sum(x => x.KwhImportedToday ?? 0), + OldestActiveLastSeenAt: oldestActive, + Customers: summaries); + } + + public async Task GetCustomerDetailAsync(Guid id, CancellationToken ct = default) + { + var c = await db.Customers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + if (c is null) return null; + + var sites = await db.FleetSites.AsNoTracking() + .Where(s => s.CustomerId == id) + .OrderBy(s => s.Name) + .Select(s => new FleetSiteDto(s.Id, s.Name, s.Address, s.LocalMunicipalityId, s.IsActive)) + .ToListAsync(ct); + + var devices = await db.FleetDevices.AsNoTracking() + .Where(d => d.CustomerId == id) + .OrderBy(d => d.Name) + .Select(d => new FleetDeviceDto(d.Id, d.SiteId, d.Name, d.ExternalId, d.Description, d.IsActive)) + .ToListAsync(ct); + + var deviceNameMap = devices.ToDictionary(d => d.Id, d => d.Name); + + var recent = await db.FleetPowerMeasurements.AsNoTracking() + .Where(m => m.CustomerId == id) + .OrderByDescending(m => m.Time) + .Take(50) + .Select(m => new { m.Time, m.DeviceId, m.ActivePowerKw, m.EnergyImportedKwh }) + .ToListAsync(ct); + + var recentDtos = recent.Select(r => new FleetRecentMeasurementDto( + r.Time, r.DeviceId, deviceNameMap.GetValueOrDefault(r.DeviceId, "(unknown)"), + r.ActivePowerKw, r.EnergyImportedKwh)).ToList(); + + var events = await db.IngestEvents.AsNoTracking() + .Where(e => e.CustomerId == id) + .OrderByDescending(e => e.ReceivedAt) + .Take(20) + .Select(e => new FleetIngestEventDto( + e.ReceivedAt, e.BatchType, e.RowsAccepted, e.RowsRejected, + e.BatchBytes, e.ClientHwm, e.TimeSpread, e.Error)) + .ToListAsync(ct); + + return new FleetCustomerDetailDto( + c.Id, c.Code, c.Name, c.IsActive, + c.FirstSeenAt, c.LastSeenAt, c.CreatedAt, + sites, devices, recentDtos, events); + } + + // Per-customer today: measurement count + kWh imported delta via raw SQL hitting + // the realtime continuous aggregate (falls back to live raw scan for the unmaterialized tail). + private async Task> GetTodayStatsAsync( + DateTime dayStartUtc, 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 "CustomerId", + SUM(samples)::bigint AS rows, + SUM(COALESCE(kwh_imported_delta, 0))::double precision AS kwh + FROM fleet.hourly_per_device + WHERE bucket >= @dayStart + GROUP BY "CustomerId"; + """; + cmd.Parameters.Add(new NpgsqlParameter("@dayStart", DateTime.SpecifyKind(dayStartUtc, DateTimeKind.Utc))); + + var map = new Dictionary(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + var custId = reader.GetGuid(0); + var rows = reader.GetInt64(1); + var kwh = reader.IsDBNull(2) ? (double?)null : reader.GetDouble(2); + map[custId] = (rows, kwh); + } + return map; + } +}