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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user