Phase 15: Admin operator surface + fleet dashboards + onboarding docs

The Admin stack now has a usable operator UI for managing the fleet.
End-to-end verified locally: Client pushes → Admin dashboard reflects
the activity within the CA refresh window.

Backend (Admin-only)
- FleetQueryService: dashboard headline (totals, active count, today's
  measurements + kWh from the hourly_per_device CA) and per-customer
  detail (sites, devices, last 50 measurements, last 20 ingest events).
- /api/fleet/dashboard and /api/fleet/customers/{id}/detail endpoints.
- DTOs added; Program.cs wires the service + endpoints under RunMode=Admin.

Frontend
- DashboardPage now branches on RunMode — Admin renders the fleet
  headline (statistic cards + customer summary table with lag tags),
  Client keeps the existing placeholder.
- AdminCustomerDetailPage drills into one customer: descriptions card +
  tabs for Recent ingest (with rejection counts, batch sizes, time-spread
  for visible firmware-replay waves), Recent measurements, Sites, Devices.
- AdminCustomersPage rows are clickable → /admin/customers/:id (skips
  the click when target is a button/popover so action buttons still work).
- App.tsx adds the /admin/customers/:id route, RequireRole-gated.

Grafana
- grafana/dashboards-admin/fleet-overview.json — 4 stat panels (active
  customers, total, last-24h samples, today's kWh) plus 2 time series
  (per-customer active power, per-customer hourly kWh). Reads from
  fleet.hourly_per_device CA.
- grafana/dashboards-admin/customer-drilldown.json — parameterized by
  $customer (template variable querying fleet.Customers). Per-device
  active power, cumulative kWh, recent ingest events table.

Docs
- README: Phase 15 section describing the new admin UI surface +
  pointer to dashboard-admin folder.
- OPERATIONS: new "Fleet aggregator (Admin stack)" section covering
  one-time provisioning (Admin portal + Admin Grafana), end-to-end
  customer-onboarding workflow (register on Admin → drop token in
  customer .env → restart → verify in UI/SQL), common ops (rotate
  token, disable, investigate, compression stats, force CA refresh,
  decommission), and Admin-DB backup notes.
- README decommissioning note now mentions deleting from fleet.Customers
  if the customer was registered for aggregation.

Verified end-to-end
- Phase 14's Client + Admin stacks rebuilt with Phase 15 code.
- /api/fleet/dashboard returns correct totals (1 customer, 1 active,
  measurements + kWh derived from CA).
- /api/fleet/customers/{id}/detail returns sites, devices, recent
  measurements, recent ingest events.
- Ingested a fresh measurement on Client → after CA refresh, totals
  in Admin dashboard advance correctly.
- All 53 tests still passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diseri Pearson 2026-05-18 10:27:55 +02:00
parent a92b4277ae
commit c5787a7a7f
15 changed files with 879 additions and 1 deletions

View File

@ -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 <timescale-container> 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 <shared-network> \
-e Application__RunMode=Admin \
-e ASPNETCORE_ENVIRONMENT=Production \
-e Application__PublicUrl=https://admin.portal.example.com \
-e Database__ConnectionString='Host=<host>;Port=5432;Database=admin_fleet;Username=power_user;Password=<secret>' \
-e Authentication__DefaultAdminEmail=ops@yourco.example \
-e Authentication__DefaultAdminPassword=<strong> \
-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 <shared-network> \
-e GF_SECURITY_ADMIN_PASSWORD=<strong> \
-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=<secret> \
-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 <<EOF
Application__RunMode=Client
FleetIngest__Enabled=true
FleetIngest__Url=https://admin.portal.example.com/api/fleet/ingest
FleetIngest__Token=<token from step A.3>
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 <admin-timescale> 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" = '<id>' 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).

View File

@ -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

View File

@ -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() {
</RequireRole>
}
/>
<Route
path="admin/customers/:id"
element={
<RequireRole role="Admin">
<AdminCustomerDetailPage />
</RequireRole>
}
/>
<Route
path="settings"
element={

View File

@ -0,0 +1,82 @@
import { api } from './client';
export interface FleetCustomerSummary {
id: string;
code: string;
name: string;
isActive: boolean;
lastSeenAt: string | null;
sites: number;
devices: number;
measurementsToday: number;
kwhImportedToday: number | null;
}
export interface FleetDashboard {
totalCustomers: number;
activeCustomers: number;
totalMeasurementsToday: number;
totalKwhImportedToday: number;
oldestActiveLastSeenAt: string | null;
customers: FleetCustomerSummary[];
}
export interface FleetSite {
id: string;
name: string;
address: string | null;
localMunicipalityId: number | null;
isActive: boolean;
}
export interface FleetDevice {
id: string;
siteId: string;
name: string;
externalId: string;
description: string | null;
isActive: boolean;
}
export interface FleetRecentMeasurement {
time: string;
deviceId: string;
deviceName: string;
activePowerKw: number;
energyImportedKwh: number | null;
}
export interface FleetIngestEvent {
receivedAt: string;
batchType: string;
rowsAccepted: number;
rowsRejected: number;
batchBytes: number;
clientHwm: string | null;
timeSpread: string | null;
error: string | null;
}
export interface FleetCustomerDetail {
id: string;
code: string;
name: string;
isActive: boolean;
firstSeenAt: string | null;
lastSeenAt: string | null;
createdAt: string;
sites: FleetSite[];
devices: FleetDevice[];
recentMeasurements: FleetRecentMeasurement[];
recentIngestEvents: FleetIngestEvent[];
}
export async function fetchFleetDashboard(): Promise<FleetDashboard> {
const { data } = await api.get<FleetDashboard>('/fleet/dashboard');
return data;
}
export async function fetchFleetCustomerDetail(id: string): Promise<FleetCustomerDetail> {
const { data } = await api.get<FleetCustomerDetail>(`/fleet/customers/${id}/detail`);
return data;
}

View File

@ -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 <div style={{ textAlign: 'center', padding: 64 }}><Spin size="large" /></div>;
if (error || !data) {
return <Result status="404" title="Customer not found" extra={<Button onClick={() => navigate('/admin/customers')}>Back</Button>} />;
}
const siteCols: ColumnsType<FleetSite> = [
{ title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'Address', dataIndex: 'address', key: 'addr', render: v => v ?? <Text type="secondary"></Text> },
{ title: 'Active', dataIndex: 'isActive', key: 'a', render: (v: boolean) => v ? <Tag color="green">Active</Tag> : <Tag>Inactive</Tag> },
];
const deviceCols: ColumnsType<FleetDevice> = [
{ title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'External ID', dataIndex: 'externalId', key: 'ext', render: v => <Text code>{v}</Text> },
{ title: 'Description', dataIndex: 'description', key: 'desc', render: v => v ?? <Text type="secondary"></Text> },
{ title: 'Active', dataIndex: 'isActive', key: 'a', render: (v: boolean) => v ? <Tag color="green">Active</Tag> : <Tag>Inactive</Tag> },
];
const measCols: ColumnsType<FleetRecentMeasurement> = [
{ 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<FleetIngestEvent> = [
{ 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 ? <Tag color="orange">{v}</Tag> : 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 ? <Tag color="red">{v}</Tag> : '—' },
];
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/customers')}>Customers</Button>
<Text strong style={{ fontSize: 20 }}>{data.code} · {data.name}</Text>
{data.isActive ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>}
</Space>
<Card size="small">
<Descriptions column={3} size="small">
<Descriptions.Item label="Customer ID">{data.id}</Descriptions.Item>
<Descriptions.Item label="Created">{new Date(data.createdAt).toLocaleString()}</Descriptions.Item>
<Descriptions.Item label="First seen">{data.firstSeenAt ? new Date(data.firstSeenAt).toLocaleString() : <Text type="secondary">Never</Text>}</Descriptions.Item>
<Descriptions.Item label="Last seen" span={3}>{data.lastSeenAt ? new Date(data.lastSeenAt).toLocaleString() : <Text type="secondary">Never</Text>}</Descriptions.Item>
</Descriptions>
</Card>
<Card>
<Tabs
defaultActiveKey="ingest"
items={[
{
key: 'ingest',
label: `Recent ingest (${data.recentIngestEvents.length})`,
children: <Table<FleetIngestEvent> rowKey="receivedAt" columns={eventCols} dataSource={data.recentIngestEvents} pagination={false} size="small" />,
},
{
key: 'measurements',
label: `Recent measurements (${data.recentMeasurements.length})`,
children: <Table<FleetRecentMeasurement> rowKey={(r) => `${r.time}-${r.deviceId}`} columns={measCols} dataSource={data.recentMeasurements} pagination={false} size="small" />,
},
{
key: 'sites',
label: `Sites (${data.sites.length})`,
children: <Table<FleetSite> rowKey="id" columns={siteCols} dataSource={data.sites} pagination={false} size="small" />,
},
{
key: 'devices',
label: `Devices (${data.devices.length})`,
children: <Table<FleetDevice> rowKey="id" columns={deviceCols} dataSource={data.devices} pagination={false} size="small" />,
},
]}
/>
</Card>
</Space>
);
}

View File

@ -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<FormMode | null>(null);
const [formError, setFormError] = useState<string | null>(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' },
})}
/>
<CustomerFormModal

View File

@ -0,0 +1,114 @@
import { Card, Col, Row, Statistic, Table, Tag, Typography, Empty } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { ApartmentOutlined, ThunderboltOutlined, TeamOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { fetchFleetDashboard, type FleetCustomerSummary } from '../api/fleet';
const { Text } = Typography;
export function AdminFleetDashboardPage() {
const navigate = useNavigate();
const { data, isLoading } = useQuery({
queryKey: ['fleet-dashboard'],
queryFn: fetchFleetDashboard,
refetchInterval: 30_000,
});
const columns: ColumnsType<FleetCustomerSummary> = [
{ title: 'Code', dataIndex: 'code', key: 'code', render: (v) => <Text strong>{v}</Text> },
{ title: 'Name', dataIndex: 'name', key: 'name' },
{
title: 'Status', dataIndex: 'isActive', key: 'active',
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>)
},
{
title: 'Last push', dataIndex: 'lastSeenAt', key: 'last',
render: (v: string | null) => v ? lagDescription(v) : <Text type="secondary">Never</Text>
},
{ 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 (
<div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card>
<Statistic title="Customers" value={data?.totalCustomers ?? 0} prefix={<TeamOutlined />} loading={isLoading} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Active (last hr)"
value={data?.activeCustomers ?? 0}
suffix={data ? `/ ${data.totalCustomers}` : ''}
prefix={<CheckCircleOutlined />}
loading={isLoading}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Measurements today"
value={data?.totalMeasurementsToday ?? 0}
prefix={<ApartmentOutlined />}
loading={isLoading}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="kWh imported today"
value={data?.totalKwhImportedToday ?? 0}
precision={2}
prefix={<ThunderboltOutlined />}
loading={isLoading}
/>
</Card>
</Col>
</Row>
<Card title="Customers">
{data && data.customers.length === 0 ? (
<Empty description="No customers registered yet. Go to Customers to register the first one." />
) : (
<Table<FleetCustomerSummary>
rowKey="id"
columns={columns}
dataSource={data?.customers ?? []}
loading={isLoading}
pagination={{ pageSize: 25 }}
onRow={(record) => ({
onClick: () => navigate(`/admin/customers/${record.id}`),
style: { cursor: 'pointer' },
})}
/>
)}
</Card>
</div>
);
}
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 <Tag color="green">just now</Tag>;
if (ageMin < 5) return <Tag color="green">{ageMin}m ago</Tag>;
if (ageMin < 60) return <Tag color="orange">{ageMin}m ago</Tag>;
const ageHr = Math.floor(ageMin / 60);
if (ageHr < 24) return <Tag color="red">{ageHr}h ago</Tag>;
return <Tag color="red">{Math.floor(ageHr / 24)}d ago</Tag>;
}

View File

@ -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 <AdminFleetDashboardPage />;
return (
<Card>
<Title level={3} style={{ marginTop: 0 }}>Dashboard</Title>

View File

View File

@ -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
}

View File

@ -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
}

View File

@ -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<FleetCustomerSummary> 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<FleetSiteDto> Sites,
IReadOnlyList<FleetDeviceDto> Devices,
IReadOnlyList<FleetRecentMeasurementDto> RecentMeasurements,
IReadOnlyList<FleetIngestEventDto> RecentIngestEvents);

View File

@ -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;
}
}

View File

@ -148,6 +148,7 @@ try
builder.Services.AddScoped<CustomerService>();
builder.Services.AddScoped<FleetIngestService>();
builder.Services.AddScoped<FleetTimescaleBootstrapper>();
builder.Services.AddScoped<FleetQueryService>();
}
builder.Services.AddHealthChecks()
@ -264,6 +265,7 @@ try
{
app.MapAdminCustomersEndpoints();
app.MapFleetIngestEndpoints();
app.MapFleetDashboardEndpoints();
}
app.MapHealthChecks("/health", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions

View File

@ -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<FleetDashboardDto> 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<FleetCustomerDetailDto?> 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<Dictionary<Guid, (long Measurements, double? KwhImported)>> 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<Guid, (long, double?)>();
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;
}
}