M8-T18: add source and multi-commodity meter UI

This commit is contained in:
2026-08-23 21:22:06 +02:00
parent 39c11ae606
commit 963e43e3e4
15 changed files with 319 additions and 18 deletions
+10
View File
@@ -50,6 +50,9 @@ describe('DsmrPanel', () => {
renderWithProviders(<DsmrPanel />)
await waitFor(() => expect(screen.getByTestId('dsmr-empty')).toBeInTheDocument())
expect(screen.queryByTestId('dsmr-table')).not.toBeInTheDocument()
expect(screen.getByText(/In this DSMR Source, enable or edit the broker, topic, and profile/)).toBeInTheDocument()
expect(screen.getByText(/confirm the publisher is sending/)).toBeInTheDocument()
expect(screen.queryByText(/Enable DSMR ingest.*Config/)).not.toBeInTheDocument()
})
it('renders the latest telegram as a key/value table; null shown as dash', async () => {
@@ -79,4 +82,11 @@ describe('DsmrPanel', () => {
renderWithProviders(<DsmrPanel />)
await waitFor(() => expect(screen.getByTestId('dsmr-error')).toBeInTheDocument())
})
it('keeps the compatibility endpoint available for DSMR source detail', async () => {
mockGet.mockResolvedValue({ data: { found: true, recorded_at: '2026-06-23T12:16:00Z', payload: { tariff: 'low' } } })
renderWithProviders(<DsmrPanel />)
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/api/energy/dsmr/latest'))
expect(await screen.findByText('Latest DSMR reading (compatibility view)')).toBeInTheDocument()
})
})
+4 -4
View File
@@ -47,7 +47,7 @@ export function DsmrPanel() {
<Stack gap="md" data-testid="dsmr-panel">
<Group justify="space-between" align="center">
<div>
<Text fw={600}>Latest DSMR reading</Text>
<Text fw={600}>Latest DSMR reading (compatibility view)</Text>
<Text size="xs" c="dimmed">
The most recent parsed telegram persisted to <code>dsmr_reading</code>.
</Text>
@@ -100,9 +100,9 @@ function DsmrContent({ isLoading, isError, data }: DsmrContentProps) {
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.
No DSMR data yet. In this DSMR Source, enable or edit the broker, topic, and profile
configuration, then confirm the publisher is sending to the configured topic (default
<code>dsmr/json</code>). Rows are stored about once every 10 seconds.
</Alert>
)
}
+37
View File
@@ -84,6 +84,7 @@ const METERS_RESPONSE = {
// ---------------------------------------------------------------------------
describe('MeterManager — loading / error / empty states', () => {
// M8 keeps the existing Modbus-facing meter regressions alongside commodity additions.
beforeEach(() => vi.clearAllMocks())
it('renders loading state initially', () => {
@@ -115,6 +116,24 @@ describe('MeterManager — loading / error / empty states', () => {
})
})
describe('MeterManager — binding switch safety', () => {
beforeEach(() => vi.clearAllMocks())
it('does not offer switching for a closed meter epoch', async () => {
mockGet.mockResolvedValue({ data: { items: [CLOSED_METER], total: 1 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId('meters-table')).toBeInTheDocument())
expect(screen.queryByRole('button', { name: 'Switch source' })).not.toBeInTheDocument()
})
it('disables switch submit until the binding timeline has loaded', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => path === '/api/energy/meters' ? Promise.resolve({ data: { items: [ACTIVE_METER], total: 1 } }) : new Promise(() => {}))
renderWithProviders(<MeterManager />)
await user.click(await screen.findByRole('button', { name: 'Switch source' }))
expect(await screen.findByText('Loading binding timeline…')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Switch binding' })).toBeDisabled()
})
})
describe('MeterManager — meter list', () => {
beforeEach(() => vi.clearAllMocks())
@@ -140,6 +159,24 @@ describe('MeterManager — meter list', () => {
expect(screen.getByText('meter_swap')).toBeInTheDocument()
})
it('renders every binding timeline segment with source, channel, and half-open boundaries', async () => {
const meterWithBindings = {
...ACTIVE_METER,
bindings: [
{ uuid: 'binding-closed', source_uuid: 'source-old', source_channel_uuid: 'channel-old', started_at: '2025-01-01T00:00:00Z', ended_at: '2025-02-01T00:00:00Z' },
{ uuid: 'binding-active', source_uuid: 'source-new', source_channel_uuid: 'channel-new', started_at: '2025-02-01T00:00:00Z', ended_at: null },
],
}
mockGet.mockResolvedValue({ data: { items: [meterWithBindings], total: 1 } })
renderWithProviders(<MeterManager />)
const closed = await screen.findByTestId('binding-timeline-binding-closed')
const active = screen.getByTestId('binding-timeline-binding-active')
expect(closed).toHaveTextContent('source-old → channel-old')
expect(closed).toHaveTextContent('[1/1/2025, 00:00:00, 2/1/2025, 00:00:00) (closed)')
expect(active).toHaveTextContent('source-new → channel-new')
expect(active).toHaveTextContent('[2/1/2025, 00:00:00, open-ended) (active)')
})
it('renders "Declare New Meter" button', async () => {
mockGet.mockResolvedValue({ data: METERS_RESPONSE })
+55 -3
View File
@@ -34,11 +34,16 @@ import {
useMeters,
useDeclareMeter,
useUpdateMeter,
useSources,
useSourceChannels,
useMeterBindings,
useCreateBinding,
useCloseBinding,
type MeterResponse,
type MeterReason,
} from './hooks'
import { ApiError } from '../api/client'
import { formatLocalDate, parseBackendTimestamp } from '../utils/datetime'
import { formatLocalDate, formatLocalDateTime, parseBackendTimestamp } from '../utils/datetime'
// ---------------------------------------------------------------------------
// Helpers
@@ -88,8 +93,15 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
const [reason, setReason] = useState<string | null>(null)
const [note, setNote] = useState('')
const [error, setError] = useState<string | null>(null)
const [commodity, setCommodity] = useState<string | null>('electricity')
const [sourceUuid, setSourceUuid] = useState<string | null>(null)
const [channelUuid, setChannelUuid] = useState<string | null>(null)
const sources = useSources()
const channels = useSourceChannels(sourceUuid)
const declareMutation = useDeclareMeter()
const compatible = (channel: { unit: string; binding_count: number; bound_meter_ids: number[] }) =>
({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record<string, string>)[commodity ?? 'electricity'] === channel.unit && channel.binding_count === 0 && channel.bound_meter_ids.length === 0
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -114,7 +126,8 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
started_at: toLocalMidnightNaive(dateStr),
reason: reason as MeterReason,
note: note.trim() || undefined,
commodity: 'electricity',
commodity: commodity ?? 'electricity',
...(channelUuid ? { source_channel_uuid: channelUuid } : {}),
})
onSaved()
onClose()
@@ -166,6 +179,14 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
data-testid="meter-reason"
/>
<Select label="Commodity" value={commodity} onChange={setCommodity} data={[
{ value: 'electricity', label: 'Electricity' },
{ value: 'heating', label: 'Heating' },
{ value: 'hot_water', label: 'Hot water' },
]} />
<Select label="Bind source (optional)" value={sourceUuid} onChange={(value) => { setSourceUuid(value); setChannelUuid(null) }} data={sources.data?.items.filter((source) => typeof source.uuid === 'string').map((source) => ({ value: source.uuid, label: source.name })) ?? []} />
{sourceUuid && <Select label="Compatible source channel (optional)" value={channelUuid} onChange={setChannelUuid} description="Only unbound channels with the required unit are eligible. Suggestions are informational only." data={channels.data?.items.filter(compatible).map((channel) => ({ value: channel.uuid, label: `${channel.label} (${channel.unit})${channel.suggested_commodity ? ` — suggestion: ${channel.suggested_commodity}` : ''}` })) ?? []} />}
<Textarea
label="Note (optional)"
value={note}
@@ -356,6 +377,7 @@ function MeterTable({ meters, onEdit }: MeterTableProps) {
<Table.Th>To</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Reason</Table.Th>
<Table.Th>Binding timeline</Table.Th>
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
@@ -399,8 +421,20 @@ function MeterTable({ meters, onEdit }: MeterTableProps) {
{meter.reason}
</Badge>
</Table.Td>
<Table.Td>
{meter.bindings?.length ? meter.bindings.map((binding) => (
<Stack key={binding.uuid} gap={0} mb="xs" data-testid={`binding-timeline-${binding.uuid}`}>
<Text size="xs">{binding.source_uuid} {binding.source_channel_uuid}</Text>
<Text size="xs" c="dimmed">
[{formatLocalDateTime(binding.started_at)}, {binding.ended_at ? formatLocalDateTime(binding.ended_at) : 'open-ended'})
{' '}({binding.ended_at ? 'closed' : 'active'})
</Text>
</Stack>
)) : <Text size="xs" c="dimmed">Unbound</Text>}
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap="xs">
{isActive && <SourceSwitchButton meter={meter} />}
<Button
size="xs"
variant="outline"
@@ -420,6 +454,24 @@ function MeterTable({ meters, onEdit }: MeterTableProps) {
)
}
function SourceSwitchButton({ meter }: { meter: MeterResponse }) {
const [opened, setOpened] = useState(false)
const [sourceUuid, setSourceUuid] = useState<string | null>(null)
const [channelUuid, setChannelUuid] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const sources = useSources(); const channels = useSourceChannels(sourceUuid); const bindings = useMeterBindings(opened ? meter.id : null)
const create = useCreateBinding(); const close = useCloseBinding()
const compatible = (channel: { unit: string; binding_count: number; bound_meter_ids: number[] }) => ({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record<string, string>)[meter.commodity] === channel.unit && channel.binding_count === 0 && channel.bound_meter_ids.length === 0
const timeline = bindings.data?.items
const timelineReady = bindings.isSuccess && !!timeline
async function save() { if (!timelineReady) return setError('Binding timeline has not loaded; no change was made.'); if (!channelUuid) return setError('Select a compatible unbound source channel.'); setError(null)
const started_at = new Date().toISOString(); const active = bindings.data?.items.find((binding) => !binding.ended_at)
try { if (active) await close.mutateAsync({ uuid: active.uuid, ended_at: started_at }) } catch (err) { const detail = err instanceof ApiError ? String((err.body as { detail?: string })?.detail ?? '') : ''; return setError(`Could not close the old binding; no change was made.${detail ? ` ${detail}` : ''}`) }
try { await create.mutateAsync({ id: meter.id, body: { source_channel_uuid: channelUuid, started_at } }); setOpened(false) } catch (err) { const detail = err instanceof ApiError ? String((err.body as { detail?: string })?.detail ?? '') : ''; setError(`Old binding was closed, but creating the new binding failed. Retry after resolving the error.${detail ? ` ${detail}` : ''}`) }
}
return <><Button size="xs" variant="subtle" onClick={() => setOpened(true)}>Switch source</Button>{opened && <Modal opened onClose={() => setOpened(false)} title="Switch source binding"><Stack><Alert color="blue">This is a two-step close then create process, not a meter swap. If create fails after close, the old binding remains closed and you can retry.</Alert>{bindings.isLoading && <Alert color="blue">Loading binding timeline</Alert>}{bindings.isError && <Alert color="red">Failed to load binding timeline; no change was made.</Alert>}{timelineReady && timeline.length === 0 && <Alert color="gray">No existing bindings for this meter.</Alert>}<Select label="Source" value={sourceUuid} onChange={(value) => { setSourceUuid(value); setChannelUuid(null) }} data={sources.data?.items.filter((source) => typeof source.uuid === 'string').map((source) => ({ value: source.uuid, label: source.name })) ?? []} /><Select label="Compatible unbound channel" value={channelUuid} onChange={setChannelUuid} description="Eligibility is based on unit and binding state; suggestions are informational." data={channels.data?.items.filter(compatible).map((channel) => ({ value: channel.uuid, label: `${channel.label} (${channel.unit})` })) ?? []} />{error && <Alert color="red">{error}</Alert>}<Group justify="flex-end"><Button variant="default" onClick={() => setOpened(false)}>Cancel</Button><Button onClick={save} loading={create.isPending || close.isPending} disabled={!timelineReady}>Switch binding</Button></Group></Stack></Modal>}</>
}
// ---------------------------------------------------------------------------
// MeterManager — top-level
// ---------------------------------------------------------------------------
@@ -456,7 +508,7 @@ export function MeterManager() {
return (
<Stack gap="lg" data-testid="meter-manager">
<Group justify="space-between" align="center">
<Text fw={500}>Electricity Meters</Text>
<Text fw={500}>Meters</Text>
<Button
onClick={() => setShowDeclareForm(true)}
data-testid="meter-declare-button"
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '../test-utils'
import { SourceForm } from './SourceForm'
const mockGet = vi.fn(); const mockPatch = vi.fn()
vi.mock('../api/client', () => ({ default: { GET: (...a: unknown[]) => mockGet(...a), POST: vi.fn(), PATCH: (...a: unknown[]) => mockPatch(...a), DELETE: vi.fn() }, ApiError: class ApiError extends Error { constructor(public status: number, public body: unknown) { super(`API error ${status}`) } }, registerLoginRedirect: vi.fn() }))
const profile = { kind: 'dsmr_mqtt', fields: [{ name: 'tls_enabled', value_type: 'bool', default: false }, { name: 'port', value_type: 'int', default: 1883 }, { name: 'topic', value_type: 'string', default: 'telegram' }, { name: 'password', value_type: 'string', secret: true }] }
const source = { uuid: 'source-1', name: 'DSMR', kind: 'dsmr_mqtt', enabled: true, config: { tls_enabled: true, port: 8883, topic: 'old', password: '********' } }
describe('SourceForm typed PATCH and secrets', () => {
beforeEach(() => { vi.clearAllMocks(); mockGet.mockResolvedValue({ data: { items: [profile] } }) })
it('preserves native bool/number/string and omits untouched masked secret', async () => { const user = userEvent.setup(); mockPatch.mockResolvedValue({ data: source }); renderWithProviders(<SourceForm source={source as never} onClose={vi.fn()} />); await user.click(await screen.findByRole('button', { name: 'Save Source' })); await waitFor(() => expect(mockPatch).toHaveBeenCalled()); expect(mockPatch).toHaveBeenCalledWith('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: 'source-1' } }, body: { name: 'DSMR', enabled: true, config: { tls_enabled: true, port: 8883, topic: 'old' } } }) })
it('sends a replacement secret and shows 422 detail', async () => { const user = userEvent.setup(); mockPatch.mockRejectedValue(new (await import('../api/client')).ApiError(422, { detail: 'invalid broker' })); renderWithProviders(<SourceForm source={source as never} onClose={vi.fn()} />); await user.type(await screen.findByLabelText('password'), 'new-secret'); await user.click(screen.getByRole('button', { name: 'Save Source' })); await waitFor(() => expect(mockPatch).toHaveBeenCalled()); expect(mockPatch.mock.calls[0][1].body.config).toMatchObject({ password: 'new-secret', tls_enabled: true, port: 8883 }); expect(await screen.findByText('invalid broker')).toBeInTheDocument() })
})
+33
View File
@@ -0,0 +1,33 @@
import { useState } from 'react'
import { Alert, Button, Checkbox, Group, Modal, Select, Stack, TextInput } from '@mantine/core'
import { ApiError } from '../api/client'
import { useCreateSource, useSourceProfiles, useUpdateSource, type MeterSourceResponse } from './hooks'
export function SourceForm({ source, onClose }: { source?: MeterSourceResponse; onClose: () => void }) {
const profiles = useSourceProfiles(); const create = useCreateSource(); const update = useUpdateSource()
const [name, setName] = useState(source?.name ?? ''); const [kind, setKind] = useState<string | null>(source?.kind ?? null)
const [enabled, setEnabled] = useState(source?.enabled ?? true); const [config, setConfig] = useState<Record<string, string | boolean | number>>({}); const [error, setError] = useState<string | null>(null)
const profile = profiles.data?.items.find((item) => item.kind === kind)
async function submit(e: React.FormEvent) { e.preventDefault(); setError(null); if (!name.trim() || !kind) return setError('Name and source type are required.')
const values: Record<string, unknown> = {}; profile?.fields.forEach((field) => {
const changed = Object.prototype.hasOwnProperty.call(config, field.name)
const raw = changed ? config[field.name] : (source?.config[field.name] ?? field.default ?? '')
// A masked secret is deliberately absent from edit PATCHes until the user
// explicitly enters a replacement; sending an empty/masked value is unsafe.
if (field.secret && source && (!changed || raw === '')) return
if (field.value_type === 'bool' || field.value_type === 'boolean') values[field.name] = typeof raw === 'boolean' ? raw : raw === 'true'
else if (field.value_type === 'int' || field.value_type === 'integer') values[field.name] = typeof raw === 'number' ? raw : Number(raw)
else values[field.name] = typeof raw === 'string' ? raw : String(raw)
})
try { if (source) await update.mutateAsync({ uuid: source.uuid, body: { name: name.trim(), enabled, config: values } }); else await create.mutateAsync({ name: name.trim(), kind, enabled, config: values }); onClose() } catch (err) { setError(err instanceof ApiError ? String((err.body as { detail?: string })?.detail ?? `Error ${err.status}`) : 'Could not save source.') }
}
return <Modal opened onClose={onClose} title={source ? 'Edit Source' : 'New Source'}><form onSubmit={submit}><Stack>
<TextInput label="Name" required value={name} onChange={(e) => setName(e.currentTarget.value)} />
{profiles.isLoading && <Alert color="blue">Loading source profiles</Alert>}{profiles.isError && <Alert color="red">Failed to load source profiles.</Alert>}
<Select label="Source type" required data={profiles.data?.items.map((p) => ({ value: p.kind, label: p.kind })) ?? []} value={kind} onChange={setKind} disabled={!!source} />
{kind === 'warmtelink_serial' && <Alert color="blue">Serial sources use <code>/dev/serial/by-id/</code>; 115200 7N1.</Alert>}
{profile?.fields.map((field) => field.value_type === 'bool' || field.value_type === 'boolean' ? <Checkbox key={field.name} label={field.name} checked={Boolean(config[field.name] ?? source?.config[field.name] ?? field.default ?? false)} onChange={(e) => setConfig({ ...config, [field.name]: e.currentTarget.checked })} /> : <TextInput key={field.name} label={field.name} required={field.required} type={field.secret ? 'password' : (field.value_type === 'int' || field.value_type === 'integer' ? 'number' : 'text')} placeholder={field.secret && source ? 'Stored secret unchanged when blank' : undefined} value={String(config[field.name] ?? (field.secret ? '' : source?.config[field.name] ?? field.default ?? ''))} onChange={(e) => setConfig({ ...config, [field.name]: e.currentTarget.value })} />)}
<Checkbox label="Enabled" checked={enabled} onChange={(e) => setEnabled(e.currentTarget.checked)} />
{error && <Alert color="red">{error}</Alert>}<Group justify="flex-end"><Button variant="default" onClick={onClose}>Cancel</Button><Button type="submit" loading={create.isPending || update.isPending}>Save Source</Button></Group>
</Stack></form></Modal>
}
@@ -0,0 +1,16 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '../test-utils'
import { SourceManager } from './SourceManager'
const mockGet = vi.fn(); const mockPost = vi.fn(); const mockDelete = vi.fn()
vi.mock('../api/client', () => ({ default: { GET: (...a: unknown[]) => mockGet(...a), POST: (...a: unknown[]) => mockPost(...a), PATCH: vi.fn(), DELETE: (...a: unknown[]) => mockDelete(...a) }, ApiError: class ApiError extends Error { constructor(public status: number, public body: unknown) { super(`API error ${status}`) } }, registerLoginRedirect: vi.fn() }))
const source = { uuid: 's1', name: 'WarmteLink', kind: 'warmtelink_serial', enabled: true, status: 'online', config: {} }
describe('SourceManager API states', () => {
beforeEach(() => vi.clearAllMocks())
it('renders source list, detail and a completed discovery result', async () => { const user = userEvent.setup(); mockGet.mockImplementation((path: string) => Promise.resolve({ data: path === '/api/energy/sources' ? { items: [source] } : path.includes('channels') ? { items: [] } : source })); mockPost.mockResolvedValue({ data: { status: 'completed' } }); renderWithProviders(<SourceManager />); await user.click(await screen.findByText('WarmteLink')); expect(await screen.findByText(/No channels discovered yet/)).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /Discover channels/ })); expect(await screen.findByText(/Discovery completed/)).toBeInTheDocument() })
it('renders source list empty and error states', async () => { mockGet.mockResolvedValueOnce({ data: { items: [] } }); const { unmount } = renderWithProviders(<SourceManager />); expect(await screen.findByText(/No sources configured/)).toBeInTheDocument(); unmount(); mockGet.mockRejectedValueOnce(new Error('offline')); renderWithProviders(<SourceManager />); expect(await screen.findByText(/Failed to load sources/)).toBeInTheDocument() })
it('contains the wide four-column source table in a scroll area', async () => { mockGet.mockResolvedValue({ data: { items: [source] } }); renderWithProviders(<SourceManager />); expect(await screen.findByTestId('sources-table-scrollarea')).toBeInTheDocument(); expect(screen.getByTestId('sources-table')).toHaveStyle({ minWidth: '640px' }) })
it('safely deletes an unreferenced source and clears its selection', async () => { const user = userEvent.setup(); let items = [source]; mockGet.mockImplementation((path: string) => Promise.resolve({ data: path === '/api/energy/sources' ? { items } : path.includes('channels') ? { items: [] } : source })); mockDelete.mockImplementation(async () => { items = []; return { data: undefined } }); renderWithProviders(<SourceManager />); await user.click(await screen.findByText('WarmteLink')); await user.click(screen.getByRole('button', { name: 'Delete source' })); await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: 's1' } } })); expect(await screen.findByText(/No sources configured/)).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Delete source' })).not.toBeInTheDocument() })
it('keeps source visible and explains dependencies when deletion returns 409', async () => { const user = userEvent.setup(); mockGet.mockImplementation((path: string) => Promise.resolve({ data: path === '/api/energy/sources' ? { items: [source] } : path.includes('channels') ? { items: [] } : source })); const { ApiError } = await import('../api/client'); mockDelete.mockRejectedValue(new ApiError(409, { detail: 'dependent readings' })); renderWithProviders(<SourceManager />); await user.click(await screen.findByText('WarmteLink')); await user.click(screen.getByRole('button', { name: 'Delete source' })); expect(await screen.findByText(/dependent channels, readings, or meter bindings/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'WarmteLink' })).toBeInTheDocument() })
})
+33
View File
@@ -0,0 +1,33 @@
import { useState } from 'react'
import { Alert, Badge, Button, Center, Group, Loader, Paper, ScrollArea, Stack, Table, Text } from '@mantine/core'
import { DsmrPanel } from './DsmrPanel'
import { SourceForm } from './SourceForm'
import { SourceReadings } from './SourceReadings'
import { ApiError } from '../api/client'
import { useDeleteSource, useDiscoverSource, useSource, useSourceChannels, useSources, type MeterSourceResponse } from './hooks'
import { formatLocalDateTime } from '../utils/datetime'
export function SourceManager() {
const sources = useSources(); const [selected, setSelected] = useState<string | null>(null); const [form, setForm] = useState<MeterSourceResponse | undefined | null>(null)
if (sources.isLoading) return <Center data-testid="sources-loading"><Loader /></Center>
if (sources.isError || !sources.data) return <Alert color="red">Failed to load sources.</Alert>
return <Stack data-testid="source-manager"><Group justify="space-between"><Text fw={600}>Sources</Text><Button onClick={() => setForm(undefined)}>New Source</Button></Group>
{sources.data.items.length === 0 ? <Text c="dimmed">No sources configured yet.</Text> : <ScrollArea data-testid="sources-table-scrollarea"><Table data-testid="sources-table" style={{ minWidth: 640 }}><Table.Thead><Table.Tr><Table.Th>Name</Table.Th><Table.Th>Type</Table.Th><Table.Th>Status</Table.Th><Table.Th>Last seen</Table.Th></Table.Tr></Table.Thead><Table.Tbody>{sources.data.items.map((source) => <Table.Tr key={source.uuid}><Table.Td><Button variant="subtle" onClick={() => setSelected(source.uuid)}>{source.name}</Button></Table.Td><Table.Td>{source.kind}</Table.Td><Table.Td><Badge color={source.enabled && source.status === 'online' ? 'green' : 'gray'}>{source.enabled ? source.status : 'disabled'}</Badge>{source.last_error && <Text c="red" size="xs">{source.last_error}</Text>}</Table.Td><Table.Td>{source.last_seen_at ? formatLocalDateTime(source.last_seen_at) : '—'}</Table.Td></Table.Tr>)}</Table.Tbody></Table></ScrollArea>}
{selected && <SourceDetail uuid={selected} onEdit={setForm} onDeleted={() => setSelected(null)} />}{form !== null && <SourceForm source={form} onClose={() => setForm(null)} />}
</Stack>
}
function SourceDetail({ uuid, onEdit, onDeleted }: { uuid: string; onEdit: (source: MeterSourceResponse) => void; onDeleted: () => void }) {
const source = useSource(uuid); const channels = useSourceChannels(uuid); const discover = useDiscoverSource(); const remove = useDeleteSource(); const [deleteError, setDeleteError] = useState<string | null>(null)
async function deleteSource() { setDeleteError(null); try { await remove.mutateAsync(uuid); onDeleted() } catch (err) { if (err instanceof ApiError && err.status === 409) setDeleteError('This source cannot be deleted because it still has dependent channels, readings, or meter bindings. Remove those dependencies first; no data was deleted.'); else setDeleteError('Could not delete source. No data was deleted.') } }
if (source.isLoading) return <Loader />; if (source.isError || !source.data) return <Alert color="red">Failed to load source.</Alert>
const detail = source.data!
const result = discover.data?.data
return <Paper withBorder p="md"><Stack><Group justify="space-between"><Text fw={600}>{detail.name}</Text><Group><Button variant="default" onClick={() => onEdit(detail)}>Edit</Button><Button loading={discover.isPending} onClick={() => discover.mutate(uuid)}>Discover channels</Button><Button color="red" variant="outline" loading={remove.isPending} onClick={deleteSource}>Delete source</Button></Group></Group>
{deleteError && <Alert color="red">{deleteError}</Alert>}
{discover.isPending && <Alert color="blue">Discovery pending</Alert>}{discover.isError && <Alert color="red">Discovery request failed. Check the source and try again.</Alert>}
{result && <Alert color={result.status === 'error' || result.status === 'timeout' ? 'red' : 'blue'}>Discovery {result.status}: {result.detail ?? (result.status === 'completed' ? 'Channels refreshed.' : 'Waiting for discovery.')}</Alert>}
{detail.kind.includes('dsmr') && <DsmrPanel />}
{channels.isLoading && <Loader />}{channels.isError && <Alert color="red">Failed to load channels.</Alert>}{channels.data?.items.length === 0 && <Text c="dimmed">No channels discovered yet.</Text>}
{channels.data?.items.map((channel) => <Stack key={channel.uuid} gap="xs"><Text>{channel.label} ({channel.unit}) suggestion: {channel.suggested_commodity ?? 'none'} (review before binding)</Text><Text size="sm">Latest: {channel.latest_value ?? '—'}; quality: {channel.latest_quality ?? 'unknown'}; bindings: {channel.binding_count}; meter IDs: {channel.bound_meter_ids.length ? channel.bound_meter_ids.join(', ') : 'none'}</Text><SourceReadings sourceUuid={uuid} channel={channel} /></Stack>)}
</Stack></Paper>
}
@@ -0,0 +1,13 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { SourceReadings } from './SourceReadings'
const mockGet = vi.fn()
vi.mock('../api/client', () => ({ default: { GET: (...a: unknown[]) => mockGet(...a), POST: vi.fn(), PATCH: vi.fn(), DELETE: vi.fn() }, ApiError: class extends Error {}, registerLoginRedirect: vi.fn() }))
const channel = { uuid: 'channel-1', label: 'Heat', unit: 'GJ', latest_value: '1.2', latest_quality: 'unverifiable' }
describe('SourceReadings', () => {
beforeEach(() => vi.clearAllMocks())
it('renders quality and history from the mocked channel API', async () => { mockGet.mockResolvedValue({ data: { items: [{ recorded_at: '2026-08-01T10:00:00Z', value: '1.1', quality: 'unverifiable' }] } }); renderWithProviders(<SourceReadings sourceUuid="s1" channel={channel as never} />); expect(await screen.findByText(/shown for review, not marked verified/)).toBeInTheDocument(); await waitFor(() => expect(screen.getByText('1.1')).toBeInTheDocument()) })
it('renders an API error and empty history', async () => { mockGet.mockRejectedValueOnce(new Error('down')); const { unmount } = renderWithProviders(<SourceReadings sourceUuid="s1" channel={channel as never} />); expect(await screen.findByText('Failed to load channel history.')).toBeInTheDocument(); unmount(); mockGet.mockResolvedValue({ data: { items: [] } }); renderWithProviders(<SourceReadings sourceUuid="s1" channel={channel as never} />); await waitFor(() => expect(screen.getByText('No channel history yet.')).toBeInTheDocument()) })
it('keeps the latest quality explanation visible while history is loading or fails', async () => { mockGet.mockImplementationOnce(() => new Promise(() => {})); const { unmount } = renderWithProviders(<SourceReadings sourceUuid="s1" channel={channel as never} />); expect(screen.getByText('Quality: unverifiable')).toBeInTheDocument(); expect(screen.getByText(/shown for review, not marked verified/)).toBeInTheDocument(); unmount(); mockGet.mockRejectedValueOnce(new Error('down')); renderWithProviders(<SourceReadings sourceUuid="s1" channel={channel as never} />); expect(await screen.findByText('Failed to load channel history.')).toBeInTheDocument(); expect(screen.getByText('Quality: unverifiable')).toBeInTheDocument(); expect(screen.getByText(/shown for review, not marked verified/)).toBeInTheDocument() })
})
+12
View File
@@ -0,0 +1,12 @@
import { Alert, Badge, Center, Loader, Stack, Table, Text } from '@mantine/core'
import { useChannelReadings, type MeterSourceChannelResponse } from './hooks'
import { formatLocalDateTime } from '../utils/datetime'
export function SourceReadings({ sourceUuid, channel }: { sourceUuid: string; channel: MeterSourceChannelResponse }) {
const query = useChannelReadings(sourceUuid, channel.uuid)
return <Stack gap="xs"><Text fw={500}>{channel.label} latest {channel.latest_value ?? '—'} {channel.unit}</Text>
<Text size="sm">Quality: {channel.latest_quality ?? 'unknown'}</Text>
{channel.latest_quality === 'unverifiable' && <Alert color="yellow">This reading is unverifiable: it is shown for review, not marked verified.</Alert>}
{query.isLoading ? <Center><Loader /></Center> : query.isError || !query.data ? <Alert color="red">Failed to load channel history.</Alert> : query.data.items.length === 0 ? <Text c="dimmed">No channel history yet.</Text> : <Table><Table.Thead><Table.Tr><Table.Th>Recorded</Table.Th><Table.Th>Value</Table.Th><Table.Th>Quality</Table.Th></Table.Tr></Table.Thead><Table.Tbody>{query.data.items.map((row) => <Table.Tr key={row.recorded_at}><Table.Td>{formatLocalDateTime(row.recorded_at)}</Table.Td><Table.Td>{row.value ?? '—'}</Table.Td><Table.Td><Badge color={row.quality === 'unverifiable' ? 'yellow' : 'gray'}>{row.quality ?? 'unknown'}</Badge></Table.Td></Table.Tr>)}</Table.Tbody></Table>}
</Stack>
}
+19
View File
@@ -81,6 +81,7 @@ function makeWrapper() {
// ---------------------------------------------------------------------------
describe('useDevices', () => {
// Source hooks use the same typed client and QueryClient invalidation boundary.
beforeEach(() => vi.clearAllMocks())
it('calls GET /api/modbus/devices and returns device list', async () => {
@@ -98,6 +99,24 @@ describe('useDevices', () => {
})
})
describe('useDeclareMeter source binding invalidation', () => {
beforeEach(() => vi.clearAllMocks())
it('invalidates every cache affected by atomic meter and binding creation', async () => {
mockPost.mockResolvedValue({ data: { id: 9 } })
const { qc, Wrapper } = makeWrapper()
const affectedKeys = [
['energy-meters'], ['energy-source-channels'], ['energy-meter-bindings', 9],
['energy-sources'], ['expose-catalog'], ['energy-costs'], ['energy-costs-summary'],
]
for (const queryKey of affectedKeys) qc.setQueryData(queryKey, { cached: true })
const { useDeclareMeter } = await import('./hooks')
const { result } = renderHook(() => useDeclareMeter(), { wrapper: Wrapper })
await act(async () => { await result.current.mutateAsync({ label: 'Heat', commodity: 'heating', started_at: '2026-08-01T00:00:00Z', reason: 'initial', source_channel_uuid: 'channel-1' } as never) })
expect(mockPost).toHaveBeenCalledWith('/api/energy/meters', expect.objectContaining({ body: expect.objectContaining({ source_channel_uuid: 'channel-1' }) }))
for (const queryKey of affectedKeys) expect(qc.getQueryState(queryKey)?.isInvalidated).toBe(true)
})
})
describe('useProfiles', () => {
beforeEach(() => vi.clearAllMocks())
+47 -1
View File
@@ -237,6 +237,13 @@ export type SummaryResponse = components['schemas']['SummaryResponse']
export type DsmrLatestResponse = components['schemas']['DsmrLatestResponse']
export type TibberTestResponse = components['schemas']['TibberTestResponse']
export type TibberTestPriceSchema = components['schemas']['TibberTestPriceSchema']
export type SourceProfileResponse = components['schemas']['SourceProfileResponse']
export type MeterSourceResponse = components['schemas']['MeterSourceResponse']
export type MeterSourceCreate = components['schemas']['MeterSourceCreate']
export type MeterSourcePatch = components['schemas']['MeterSourcePatch']
export type MeterSourceChannelResponse = components['schemas']['MeterSourceChannelResponse']
export type BindingResponse = components['schemas']['BindingResponse']
export type BindingCreate = components['schemas']['BindingCreate']
// ---------------------------------------------------------------------------
// Query: list all energy contracts
@@ -470,8 +477,14 @@ export function useDeclareMeter() {
return useMutation({
mutationFn: (body: MeterDeclareRequest) =>
apiClient.POST('/api/energy/meters', { body }),
onSuccess: () => {
onSuccess: (_data, variables) => {
void qc.invalidateQueries({ queryKey: ['energy-meters'] })
if (variables.source_channel_uuid) {
void qc.invalidateQueries({ queryKey: ['energy-source-channels'] })
void qc.invalidateQueries({ queryKey: ['energy-meter-bindings'] })
void qc.invalidateQueries({ queryKey: ['energy-sources'] })
void qc.invalidateQueries({ queryKey: ['expose-catalog'] })
}
// Invalidate cost-related queries: a new meter may trigger recompute server-side.
void qc.invalidateQueries({ queryKey: ['energy-costs'] })
void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
@@ -500,6 +513,39 @@ export function useUpdateMeter() {
})
}
// Source → channel → binding hooks. These deliberately use the generated
// OpenAPI types; UI suggestions remain just suggestions until a user binds one.
export function useSourceProfiles() {
return useQuery({ queryKey: ['energy-source-profiles'], queryFn: async () => {
const res = await apiClient.GET('/api/energy/source-profiles'); return res.data
}, staleTime: 5 * 60 * 1000 })
}
export function useSources() {
return useQuery({ queryKey: ['energy-sources'], queryFn: async () => {
const res = await apiClient.GET('/api/energy/sources'); return res.data
} })
}
export function useSource(uuid: string | null) {
return useQuery({ queryKey: ['energy-source', uuid], enabled: !!uuid, queryFn: async () => {
const res = await apiClient.GET('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: uuid! } } }); return res.data
} })
}
function invalidateSourceQueries(qc: ReturnType<typeof useQueryClient>) {
void qc.invalidateQueries({ queryKey: ['energy-sources'] }); void qc.invalidateQueries({ queryKey: ['energy-source'] });
void qc.invalidateQueries({ queryKey: ['energy-source-channels'] }); void qc.invalidateQueries({ queryKey: ['energy-meters'] });
void qc.invalidateQueries({ queryKey: ['energy-channel-readings'] }); void qc.invalidateQueries({ queryKey: ['energy-meter-bindings'] })
void qc.invalidateQueries({ queryKey: ['expose-catalog'] })
}
export function useCreateSource() { const qc = useQueryClient(); return useMutation({ mutationFn: (body: MeterSourceCreate) => apiClient.POST('/api/energy/sources', { body }), onSuccess: () => invalidateSourceQueries(qc) }) }
export function useUpdateSource() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ uuid, body }: { uuid: string; body: MeterSourcePatch }) => apiClient.PATCH('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: uuid } }, body }), onSuccess: () => invalidateSourceQueries(qc) }) }
export function useDeleteSource() { const qc = useQueryClient(); return useMutation({ mutationFn: (uuid: string) => apiClient.DELETE('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: uuid } } }), onSuccess: () => invalidateSourceQueries(qc) }) }
export function useDiscoverSource() { const qc = useQueryClient(); return useMutation({ mutationFn: (uuid: string) => apiClient.POST('/api/energy/sources/{source_uuid}/discover', { params: { path: { source_uuid: uuid } } }), onSuccess: () => invalidateSourceQueries(qc) }) }
export function useSourceChannels(uuid: string | null) { return useQuery({ queryKey: ['energy-source-channels', uuid], enabled: !!uuid, queryFn: async () => { const res = await apiClient.GET('/api/energy/sources/{source_uuid}/channels', { params: { path: { source_uuid: uuid! } } }); return res.data } }) }
export function useChannelReadings(sourceUuid: string | null, channelUuid: string | null) { return useQuery({ queryKey: ['energy-channel-readings', sourceUuid, channelUuid], enabled: !!sourceUuid && !!channelUuid, queryFn: async () => { const res = await apiClient.GET('/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings', { params: { path: { source_uuid: sourceUuid!, channel_uuid: channelUuid! }, query: { limit: 60 } } }); return res.data } }) }
export function useMeterBindings(id: number | null) { return useQuery({ queryKey: ['energy-meter-bindings', id], enabled: id != null, queryFn: async () => { const res = await apiClient.GET('/api/energy/meters/{meter_id}/bindings', { params: { path: { meter_id: id! } } }); return res.data } }) }
export function useCreateBinding() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, body }: { id: number; body: BindingCreate }) => apiClient.POST('/api/energy/meters/{meter_id}/bindings', { params: { path: { meter_id: id } }, body }), onSuccess: () => invalidateSourceQueries(qc) }) }
export function useCloseBinding() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ uuid, ended_at }: { uuid: string; ended_at: string }) => apiClient.PATCH('/api/energy/bindings/{binding_uuid}', { params: { path: { binding_uuid: uuid } }, body: { ended_at } }), onSuccess: () => invalidateSourceQueries(qc) }) }
// ---------------------------------------------------------------------------
// Query: time-range readings for a device (window + limit — never full-table)
// ---------------------------------------------------------------------------