frontend: add DSMR data panel to Energy page

- New 'DSMR' tab showing the latest parsed telegram (GET /api/energy/dsmr/latest)
  as a key/value table — the source-of-truth view so you can see ingest is
  landing data without reading the DB.
- Auto-refresh toggle (~10s); loading/error/empty states (empty explains the
  likely cause); null phase values shown as a dash.
- useDsmrLatest extended with optional auto-refresh. Tests added.
This commit is contained in:
2026-06-24 11:02:42 +02:00
parent 8d4f496ff4
commit e8521351d7
4 changed files with 249 additions and 1 deletions
+153
View File
@@ -0,0 +1,153 @@
/**
* DsmrPanel — shows the latest parsed DSMR telegram (the source of our metering data).
*
* This is the "ground truth" view: it fetches GET /api/energy/dsmr/latest and
* renders the full parsed frame as a key/value table, so you can confirm at a
* glance whether DSMR ingest is actually landing data (without reading the DB).
*
* - Auto-refresh toggle (default on) so new telegrams appear without a manual reload.
* - Loading / error / empty states; "empty" explains the likely cause.
* - Null phase values are shown as "—" (kept verbatim in the payload).
*/
import { useState } from 'react'
import {
Stack,
Group,
Text,
Loader,
Center,
Alert,
Paper,
Table,
ScrollArea,
Switch,
Divider,
Badge,
} from '@mantine/core'
import { useDsmrLatest } from './hooks'
/** Default auto-refresh interval (ms) — DSMR ingest stores ~one row per 10 s. */
const AUTO_REFRESH_INTERVAL_MS = 10_000
function formatValue(value: unknown): string {
if (value === null || value === undefined) return '—'
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
export function DsmrPanel() {
const [autoRefresh, setAutoRefresh] = useState(true)
const latestQuery = useDsmrLatest(
autoRefresh ? { refetchIntervalMs: AUTO_REFRESH_INTERVAL_MS } : undefined,
)
return (
<Stack gap="md" data-testid="dsmr-panel">
<Group justify="space-between" align="center">
<div>
<Text fw={600}>Latest DSMR reading</Text>
<Text size="xs" c="dimmed">
The most recent parsed telegram persisted to <code>dsmr_reading</code>.
</Text>
</div>
<Switch
label="Auto-refresh"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.currentTarget.checked)}
size="sm"
data-testid="dsmr-auto-refresh-switch"
/>
</Group>
<Divider />
<DsmrContent
isLoading={latestQuery.isLoading}
isError={latestQuery.isError}
data={latestQuery.data}
/>
</Stack>
)
}
interface DsmrContentProps {
isLoading: boolean
isError: boolean
data:
| { found: boolean; recorded_at?: string | null; payload?: Record<string, unknown> | null }
| undefined
}
function DsmrContent({ isLoading, isError, data }: DsmrContentProps) {
if (isLoading) {
return (
<Center py="xl" data-testid="dsmr-loading">
<Loader />
</Center>
)
}
if (isError || !data) {
return (
<Alert color="red" data-testid="dsmr-error">
Failed to load the latest DSMR reading.
</Alert>
)
}
if (!data.found || !data.payload) {
return (
<Alert color="gray" data-testid="dsmr-empty">
No DSMR data yet. Enable <strong>DSMR ingest</strong> in Config, make sure MQTT
is connected, and confirm the DSMR Reader is publishing to the configured topic
(default <code>dsmr/json</code>). Rows are stored about once every 10 seconds.
</Alert>
)
}
const payload = data.payload
const keys = Object.keys(payload).sort()
return (
<Stack gap="sm" data-testid="dsmr-data">
<Group gap="xs">
<Badge color="teal" variant="light">
{keys.length} fields
</Badge>
{data.recorded_at && (
<Text size="sm" c="dimmed" data-testid="dsmr-recorded-at">
recorded at {new Date(data.recorded_at).toLocaleString()}
</Text>
)}
</Group>
<Paper withBorder>
<ScrollArea.Autosize mah={480}>
<Table striped highlightOnHover withColumnBorders data-testid="dsmr-table">
<Table.Thead>
<Table.Tr>
<Table.Th>Field</Table.Th>
<Table.Th>Value</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{keys.map((key) => (
<Table.Tr key={key} data-testid={`dsmr-row-${key}`}>
<Table.Td>
<Text size="sm" ff="monospace">
{key}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{formatValue(payload[key])}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
</Stack>
)
}