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
|
// 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({
|
return useQuery({
|
||||||
queryKey: ['dsmr-latest'],
|
queryKey: ['dsmr-latest'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await apiClient.GET('/api/energy/dsmr/latest')
|
const res = await apiClient.GET('/api/energy/dsmr/latest')
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
|
refetchInterval,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { EnergyCharts } from '../energy/EnergyCharts'
|
|||||||
import { ContractManager } from '../energy/ContractManager'
|
import { ContractManager } from '../energy/ContractManager'
|
||||||
import { TibberPrices } from '../energy/TibberPrices'
|
import { TibberPrices } from '../energy/TibberPrices'
|
||||||
import { CostView } from '../energy/CostView'
|
import { CostView } from '../energy/CostView'
|
||||||
|
import { DsmrPanel } from '../energy/DsmrPanel'
|
||||||
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
|
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import { formatMetricValue } from '../energy/format'
|
import { formatMetricValue } from '../energy/format'
|
||||||
@@ -628,6 +629,9 @@ export function EnergyPage() {
|
|||||||
<Tabs.Tab value="costs" data-testid="tab-costs">
|
<Tabs.Tab value="costs" data-testid="tab-costs">
|
||||||
Costs
|
Costs
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="dsmr" data-testid="tab-dsmr">
|
||||||
|
DSMR
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="devices" data-testid="panel-devices">
|
<Tabs.Panel value="devices" data-testid="panel-devices">
|
||||||
@@ -645,6 +649,10 @@ export function EnergyPage() {
|
|||||||
<Tabs.Panel value="costs" data-testid="panel-costs">
|
<Tabs.Panel value="costs" data-testid="panel-costs">
|
||||||
<CostView />
|
<CostView />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="dsmr" data-testid="panel-dsmr">
|
||||||
|
<DsmrPanel />
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user