Tau.Acuvim/portal/frontend/src/pages/AdminCustomersPage.tsx
Diseri Pearson c5787a7a7f 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>
2026-05-18 10:27:55 +02:00

194 lines
6.7 KiB
TypeScript

import { useState } from 'react';
import { Card, Table, Button, Space, Tag, Popconfirm, Tooltip, Typography, message } from 'antd';
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,
} from '../api/customers';
import { CustomerFormModal } from '../components/customers/CustomerFormModal';
import { TokenShownOnceModal } from '../components/customers/TokenShownOnceModal';
const { Text } = Typography;
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 }>({
open: false, code: null, token: null,
});
const { data: customers = [], isLoading } = useQuery({
queryKey: ['admin', 'customers'],
queryFn: listCustomers,
});
const invalidate = () => qc.invalidateQueries({ queryKey: ['admin', 'customers'] });
const createMut = useMutation({
mutationFn: (p: CreateCustomerPayload) => createCustomer(p),
onSuccess: (result) => {
setFormMode(null);
setFormError(null);
setTokenModal({ open: true, code: result.customer.code, token: result.token });
invalidate();
},
onError: (err: unknown) => setFormError(extractError(err)),
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: UpdateCustomerPayload }) => updateCustomer(id, payload),
onSuccess: () => {
message.success('Customer updated');
setFormMode(null);
setFormError(null);
invalidate();
},
onError: (err: unknown) => setFormError(extractError(err)),
});
const rotateMut = useMutation({
mutationFn: (id: string) => rotateCustomerToken(id),
onSuccess: (result) => {
setTokenModal({ open: true, code: result.customer.code, token: result.token });
invalidate();
},
onError: (err: unknown) => message.error(extractError(err)),
});
const deleteMut = useMutation({
mutationFn: (id: string) => deleteCustomer(id),
onSuccess: () => { message.success('Customer deleted'); invalidate(); },
onError: (err: unknown) => message.error(extractError(err)),
});
const handleSubmit = (payload: CreateCustomerPayload | UpdateCustomerPayload) => {
setFormError(null);
if (formMode?.kind === 'edit') {
updateMut.mutate({ id: formMode.customer.id, payload: payload as UpdateCustomerPayload });
} else {
createMut.mutate(payload as CreateCustomerPayload);
}
};
const columns: ColumnsType<CustomerListItem> = [
{ title: 'Code', dataIndex: 'code', key: 'code', render: (v) => <Text strong>{v}</Text> },
{ title: 'Name', dataIndex: 'name', key: 'name' },
{
title: 'Status',
dataIndex: 'isActive',
key: 'isActive',
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>),
},
{
title: 'Last push',
dataIndex: 'lastSeenAt',
key: 'lastSeenAt',
render: (v: string | null) =>
v ? new Date(v).toLocaleString() : <Text type="secondary">Never</Text>,
},
{
title: 'Token issued',
key: 'token',
render: (_, c) => {
const ts = c.tokenRotatedAt ?? c.tokenIssuedAt;
return new Date(ts).toLocaleDateString();
},
},
{
title: 'Actions',
key: 'actions',
render: (_, c) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => { setFormError(null); setFormMode({ kind: 'edit', customer: c }); }}>
Edit
</Button>
<Tooltip title="Generate a new token. Old token stops working immediately.">
<Popconfirm
title={`Rotate token for ${c.code}?`}
description="The customer's push service will fail until their .env is updated with the new token."
okText="Rotate"
okButtonProps={{ danger: true }}
onConfirm={() => rotateMut.mutate(c.id)}
>
<Button size="small" icon={<ReloadOutlined />} loading={rotateMut.isPending && rotateMut.variables === c.id}>
Rotate token
</Button>
</Popconfirm>
</Tooltip>
<Popconfirm
title={`Delete ${c.code}?`}
description="All this customer's mirrored data (sites, devices, measurements, events) is removed."
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => deleteMut.mutate(c.id)}
>
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
</Popconfirm>
</Space>
),
},
];
return (
<Card
title="Customers"
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => { setFormError(null); setFormMode({ kind: 'create' }); }}
>
Register customer
</Button>
}
>
<Table<CustomerListItem>
rowKey="id"
columns={columns}
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
open={formMode !== null}
mode={formMode}
submitting={createMut.isPending || updateMut.isPending}
error={formError}
onClose={() => { setFormMode(null); setFormError(null); }}
onSubmit={handleSubmit}
/>
<TokenShownOnceModal
open={tokenModal.open}
customerCode={tokenModal.code}
token={tokenModal.token}
onClose={() => setTokenModal({ open: false, code: null, token: null })}
/>
</Card>
);
}
function extractError(err: unknown): string {
if (typeof err === 'object' && err !== null && 'response' in err) {
const data = (err as { response?: { data?: { error?: string } } }).response?.data;
if (data?.error) return data.error;
}
return 'Request failed.';
}