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