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:
parent
a92b4277ae
commit
c5787a7a7f
@ -316,4 +316,98 @@ docker volume rm \
|
|||||||
rm -rf /srv/portal/abc0001
|
rm -rf /srv/portal/abc0001
|
||||||
|
|
||||||
# DNS record + cert (manual or via your DNS automation)
|
# 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).
|
||||||
|
|||||||
@ -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.
|
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:**
|
**Spin up an Admin stack:**
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { DashboardPage } from './pages/DashboardPage';
|
|||||||
import { DashboardsPage } from './pages/DashboardsPage';
|
import { DashboardsPage } from './pages/DashboardsPage';
|
||||||
import { AdminSitesPage } from './pages/AdminSitesPage';
|
import { AdminSitesPage } from './pages/AdminSitesPage';
|
||||||
import { AdminCustomersPage } from './pages/AdminCustomersPage';
|
import { AdminCustomersPage } from './pages/AdminCustomersPage';
|
||||||
|
import { AdminCustomerDetailPage } from './pages/AdminCustomerDetailPage';
|
||||||
import { SettingsPage } from './pages/SettingsPage';
|
import { SettingsPage } from './pages/SettingsPage';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@ -54,6 +55,14 @@ export default function App() {
|
|||||||
</RequireRole>
|
</RequireRole>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/customers/:id"
|
||||||
|
element={
|
||||||
|
<RequireRole role="Admin">
|
||||||
|
<AdminCustomerDetailPage />
|
||||||
|
</RequireRole>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="settings"
|
path="settings"
|
||||||
element={
|
element={
|
||||||
|
|||||||
82
portal/frontend/src/api/fleet.ts
Normal file
82
portal/frontend/src/api/fleet.ts
Normal 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;
|
||||||
|
}
|
||||||
103
portal/frontend/src/pages/AdminCustomerDetailPage.tsx
Normal file
103
portal/frontend/src/pages/AdminCustomerDetailPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { Card, Table, Button, Space, Tag, Popconfirm, Tooltip, Typography, messa
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
listCustomers, createCustomer, updateCustomer, rotateCustomerToken, deleteCustomer,
|
listCustomers, createCustomer, updateCustomer, rotateCustomerToken, deleteCustomer,
|
||||||
type CustomerListItem, type CreateCustomerPayload, type UpdateCustomerPayload,
|
type CustomerListItem, type CreateCustomerPayload, type UpdateCustomerPayload,
|
||||||
@ -16,6 +17,7 @@ type FormMode = { kind: 'create' } | { kind: 'edit'; customer: CustomerListItem
|
|||||||
|
|
||||||
export function AdminCustomersPage() {
|
export function AdminCustomersPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [formMode, setFormMode] = useState<FormMode | null>(null);
|
const [formMode, setFormMode] = useState<FormMode | null>(null);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [tokenModal, setTokenModal] = useState<{ open: boolean; code: string | null; token: string | null }>({
|
const [tokenModal, setTokenModal] = useState<{ open: boolean; code: string | null; token: string | null }>({
|
||||||
@ -153,6 +155,14 @@ export function AdminCustomersPage() {
|
|||||||
dataSource={customers}
|
dataSource={customers}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
pagination={{ pageSize: 25 }}
|
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
|
<CustomerFormModal
|
||||||
|
|||||||
114
portal/frontend/src/pages/AdminFleetDashboardPage.tsx
Normal file
114
portal/frontend/src/pages/AdminFleetDashboardPage.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@ -1,8 +1,13 @@
|
|||||||
import { Card, Typography } from 'antd';
|
import { Card, Typography } from 'antd';
|
||||||
|
import { useAppInfo } from '../hooks/useAppInfo';
|
||||||
|
import { AdminFleetDashboardPage } from './AdminFleetDashboardPage';
|
||||||
|
|
||||||
const { Title, Paragraph } = Typography;
|
const { Title, Paragraph } = Typography;
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
|
const { isAdmin } = useAppInfo();
|
||||||
|
if (isAdmin) return <AdminFleetDashboardPage />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<Title level={3} style={{ marginTop: 0 }}>Dashboard</Title>
|
<Title level={3} style={{ marginTop: 0 }}>Dashboard</Title>
|
||||||
|
|||||||
0
portal/grafana/dashboards-admin/.gitkeep
Normal file
0
portal/grafana/dashboards-admin/.gitkeep
Normal file
108
portal/grafana/dashboards-admin/customer-drilldown.json
Normal file
108
portal/grafana/dashboards-admin/customer-drilldown.json
Normal 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
|
||||||
|
}
|
||||||
138
portal/grafana/dashboards-admin/fleet-overview.json
Normal file
138
portal/grafana/dashboards-admin/fleet-overview.json
Normal 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
|
||||||
|
}
|
||||||
50
portal/src/Tau.Acuvim.Portal/DTOs/FleetDashboardDtos.cs
Normal file
50
portal/src/Tau.Acuvim.Portal/DTOs/FleetDashboardDtos.cs
Normal 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);
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -148,6 +148,7 @@ try
|
|||||||
builder.Services.AddScoped<CustomerService>();
|
builder.Services.AddScoped<CustomerService>();
|
||||||
builder.Services.AddScoped<FleetIngestService>();
|
builder.Services.AddScoped<FleetIngestService>();
|
||||||
builder.Services.AddScoped<FleetTimescaleBootstrapper>();
|
builder.Services.AddScoped<FleetTimescaleBootstrapper>();
|
||||||
|
builder.Services.AddScoped<FleetQueryService>();
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.Services.AddHealthChecks()
|
builder.Services.AddHealthChecks()
|
||||||
@ -264,6 +265,7 @@ try
|
|||||||
{
|
{
|
||||||
app.MapAdminCustomersEndpoints();
|
app.MapAdminCustomersEndpoints();
|
||||||
app.MapFleetIngestEndpoints();
|
app.MapFleetIngestEndpoints();
|
||||||
|
app.MapFleetDashboardEndpoints();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.MapHealthChecks("/health", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
app.MapHealthChecks("/health", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||||||
|
|||||||
132
portal/src/Tau.Acuvim.Portal/Services/FleetQueryService.cs
Normal file
132
portal/src/Tau.Acuvim.Portal/Services/FleetQueryService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user