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
+82
View File
@@ -0,0 +1,82 @@
/**
* Tests for DsmrPanel — latest parsed DSMR telegram view.
*
* Coverage:
* 1. Loading state.
* 2. Empty state (found=false) with a helpful hint.
* 3. Renders the payload as a key/value table; null values shown as "—".
* 4. Error state.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
const mockGet = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
POST: vi.fn(),
PATCH: vi.fn(),
DELETE: vi.fn(),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
import { DsmrPanel } from './DsmrPanel'
describe('DsmrPanel', () => {
beforeEach(() => vi.clearAllMocks())
it('renders loading state initially', () => {
mockGet.mockImplementation(() => new Promise(() => {}))
renderWithProviders(<DsmrPanel />)
expect(screen.getByTestId('dsmr-loading')).toBeInTheDocument()
})
it('shows an empty hint when no DSMR data has been ingested', async () => {
mockGet.mockResolvedValue({ data: { found: false, recorded_at: null, payload: null } })
renderWithProviders(<DsmrPanel />)
await waitFor(() => expect(screen.getByTestId('dsmr-empty')).toBeInTheDocument())
expect(screen.queryByTestId('dsmr-table')).not.toBeInTheDocument()
})
it('renders the latest telegram as a key/value table; null shown as dash', async () => {
mockGet.mockResolvedValue({
data: {
found: true,
recorded_at: '2026-06-23T12:16:00Z',
payload: {
electricity_delivered_1: '20915.154',
phase_voltage_l2: null,
},
},
})
renderWithProviders(<DsmrPanel />)
await waitFor(() => expect(screen.getByTestId('dsmr-table')).toBeInTheDocument())
// Field rows present (keys are sorted).
expect(screen.getByTestId('dsmr-row-electricity_delivered_1')).toHaveTextContent('20915.154')
// Null phase value rendered as an em dash, not omitted.
expect(screen.getByTestId('dsmr-row-phase_voltage_l2')).toHaveTextContent('—')
expect(screen.getByTestId('dsmr-recorded-at')).toBeInTheDocument()
})
it('renders an error state when the request fails', async () => {
mockGet.mockRejectedValue(new Error('network down'))
renderWithProviders(<DsmrPanel />)
await waitFor(() => expect(screen.getByTestId('dsmr-error')).toBeInTheDocument())
})
})
+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>
)
}
+6 -1
View File
@@ -385,13 +385,18 @@ export function useEnergyCostSummary(start?: string, end?: string) {
// Query: DSMR latest reading
// ---------------------------------------------------------------------------
export function useDsmrLatest() {
export function useDsmrLatest(options?: AutoRefreshOptions) {
const refetchInterval = options?.refetchIntervalMs != null
? Math.max(2_000, options.refetchIntervalMs)
: undefined
return useQuery({
queryKey: ['dsmr-latest'],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/dsmr/latest')
return res.data
},
refetchInterval,
})
}
+8
View File
@@ -44,6 +44,7 @@ import { EnergyCharts } from '../energy/EnergyCharts'
import { ContractManager } from '../energy/ContractManager'
import { TibberPrices } from '../energy/TibberPrices'
import { CostView } from '../energy/CostView'
import { DsmrPanel } from '../energy/DsmrPanel'
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
import { ApiError } from '../api/client'
import { formatMetricValue } from '../energy/format'
@@ -628,6 +629,9 @@ export function EnergyPage() {
<Tabs.Tab value="costs" data-testid="tab-costs">
Costs
</Tabs.Tab>
<Tabs.Tab value="dsmr" data-testid="tab-dsmr">
DSMR
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="devices" data-testid="panel-devices">
@@ -645,6 +649,10 @@ export function EnergyPage() {
<Tabs.Panel value="costs" data-testid="panel-costs">
<CostView />
</Tabs.Panel>
<Tabs.Panel value="dsmr" data-testid="panel-dsmr">
<DsmrPanel />
</Tabs.Panel>
</Tabs>
</Container>
)