M8-T18: add source and multi-commodity meter UI
This commit is contained in:
@@ -973,7 +973,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接
|
|||||||
|
|
||||||
### M8-T18 — Sources UI 与多 Commodity Meters [structural]
|
### M8-T18 — Sources UI 与多 Commodity Meters [structural]
|
||||||
|
|
||||||
- **Status**: `todo`
|
- **Status**: `done`
|
||||||
- **Depends**: M8-T17
|
- **Depends**: M8-T17
|
||||||
- **Context**: 把新数据源/binding 概念变成可配置体验,并把 Modbus 专用页面准确命名。
|
- **Context**: 把新数据源/binding 概念变成可配置体验,并把 Modbus 专用页面准确命名。
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ describe('DsmrPanel', () => {
|
|||||||
renderWithProviders(<DsmrPanel />)
|
renderWithProviders(<DsmrPanel />)
|
||||||
await waitFor(() => expect(screen.getByTestId('dsmr-empty')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId('dsmr-empty')).toBeInTheDocument())
|
||||||
expect(screen.queryByTestId('dsmr-table')).not.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 () => {
|
it('renders the latest telegram as a key/value table; null shown as dash', async () => {
|
||||||
@@ -79,4 +82,11 @@ describe('DsmrPanel', () => {
|
|||||||
renderWithProviders(<DsmrPanel />)
|
renderWithProviders(<DsmrPanel />)
|
||||||
await waitFor(() => expect(screen.getByTestId('dsmr-error')).toBeInTheDocument())
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function DsmrPanel() {
|
|||||||
<Stack gap="md" data-testid="dsmr-panel">
|
<Stack gap="md" data-testid="dsmr-panel">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600}>Latest DSMR reading</Text>
|
<Text fw={600}>Latest DSMR reading (compatibility view)</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
The most recent parsed telegram persisted to <code>dsmr_reading</code>.
|
The most recent parsed telegram persisted to <code>dsmr_reading</code>.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -100,9 +100,9 @@ function DsmrContent({ isLoading, isError, data }: DsmrContentProps) {
|
|||||||
if (!data.found || !data.payload) {
|
if (!data.found || !data.payload) {
|
||||||
return (
|
return (
|
||||||
<Alert color="gray" data-testid="dsmr-empty">
|
<Alert color="gray" data-testid="dsmr-empty">
|
||||||
No DSMR data yet. Enable <strong>DSMR ingest</strong> in Config, make sure MQTT
|
No DSMR data yet. In this DSMR Source, enable or edit the broker, topic, and profile
|
||||||
is connected, and confirm the DSMR Reader is publishing to the configured topic
|
configuration, then confirm the publisher is sending to the configured topic (default
|
||||||
(default <code>dsmr/json</code>). Rows are stored about once every 10 seconds.
|
<code>dsmr/json</code>). Rows are stored about once every 10 seconds.
|
||||||
</Alert>
|
</Alert>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const METERS_RESPONSE = {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('MeterManager — loading / error / empty states', () => {
|
describe('MeterManager — loading / error / empty states', () => {
|
||||||
|
// M8 keeps the existing Modbus-facing meter regressions alongside commodity additions.
|
||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
it('renders loading state initially', () => {
|
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', () => {
|
describe('MeterManager — meter list', () => {
|
||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
@@ -140,6 +159,24 @@ describe('MeterManager — meter list', () => {
|
|||||||
expect(screen.getByText('meter_swap')).toBeInTheDocument()
|
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 () => {
|
it('renders "Declare New Meter" button', async () => {
|
||||||
mockGet.mockResolvedValue({ data: METERS_RESPONSE })
|
mockGet.mockResolvedValue({ data: METERS_RESPONSE })
|
||||||
|
|
||||||
|
|||||||
@@ -34,11 +34,16 @@ import {
|
|||||||
useMeters,
|
useMeters,
|
||||||
useDeclareMeter,
|
useDeclareMeter,
|
||||||
useUpdateMeter,
|
useUpdateMeter,
|
||||||
|
useSources,
|
||||||
|
useSourceChannels,
|
||||||
|
useMeterBindings,
|
||||||
|
useCreateBinding,
|
||||||
|
useCloseBinding,
|
||||||
type MeterResponse,
|
type MeterResponse,
|
||||||
type MeterReason,
|
type MeterReason,
|
||||||
} from './hooks'
|
} from './hooks'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import { formatLocalDate, parseBackendTimestamp } from '../utils/datetime'
|
import { formatLocalDate, formatLocalDateTime, parseBackendTimestamp } from '../utils/datetime'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -88,8 +93,15 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
|
|||||||
const [reason, setReason] = useState<string | null>(null)
|
const [reason, setReason] = useState<string | null>(null)
|
||||||
const [note, setNote] = useState('')
|
const [note, setNote] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
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 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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -114,7 +126,8 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
|
|||||||
started_at: toLocalMidnightNaive(dateStr),
|
started_at: toLocalMidnightNaive(dateStr),
|
||||||
reason: reason as MeterReason,
|
reason: reason as MeterReason,
|
||||||
note: note.trim() || undefined,
|
note: note.trim() || undefined,
|
||||||
commodity: 'electricity',
|
commodity: commodity ?? 'electricity',
|
||||||
|
...(channelUuid ? { source_channel_uuid: channelUuid } : {}),
|
||||||
})
|
})
|
||||||
onSaved()
|
onSaved()
|
||||||
onClose()
|
onClose()
|
||||||
@@ -166,6 +179,14 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
|
|||||||
data-testid="meter-reason"
|
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
|
<Textarea
|
||||||
label="Note (optional)"
|
label="Note (optional)"
|
||||||
value={note}
|
value={note}
|
||||||
@@ -356,6 +377,7 @@ function MeterTable({ meters, onEdit }: MeterTableProps) {
|
|||||||
<Table.Th>To</Table.Th>
|
<Table.Th>To</Table.Th>
|
||||||
<Table.Th>Status</Table.Th>
|
<Table.Th>Status</Table.Th>
|
||||||
<Table.Th>Reason</Table.Th>
|
<Table.Th>Reason</Table.Th>
|
||||||
|
<Table.Th>Binding timeline</Table.Th>
|
||||||
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
|
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
@@ -399,8 +421,20 @@ function MeterTable({ meters, onEdit }: MeterTableProps) {
|
|||||||
{meter.reason}
|
{meter.reason}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</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>
|
<Table.Td>
|
||||||
<Group justify="flex-end" gap="xs">
|
<Group justify="flex-end" gap="xs">
|
||||||
|
{isActive && <SourceSwitchButton meter={meter} />}
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="outline"
|
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
|
// MeterManager — top-level
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -456,7 +508,7 @@ export function MeterManager() {
|
|||||||
return (
|
return (
|
||||||
<Stack gap="lg" data-testid="meter-manager">
|
<Stack gap="lg" data-testid="meter-manager">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Text fw={500}>Electricity Meters</Text>
|
<Text fw={500}>Meters</Text>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowDeclareForm(true)}
|
onClick={() => setShowDeclareForm(true)}
|
||||||
data-testid="meter-declare-button"
|
data-testid="meter-declare-button"
|
||||||
|
|||||||
@@ -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() })
|
||||||
|
})
|
||||||
@@ -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() })
|
||||||
|
})
|
||||||
@@ -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() })
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -81,6 +81,7 @@ function makeWrapper() {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('useDevices', () => {
|
describe('useDevices', () => {
|
||||||
|
// Source hooks use the same typed client and QueryClient invalidation boundary.
|
||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
it('calls GET /api/modbus/devices and returns device list', async () => {
|
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', () => {
|
describe('useProfiles', () => {
|
||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
|||||||
@@ -237,6 +237,13 @@ export type SummaryResponse = components['schemas']['SummaryResponse']
|
|||||||
export type DsmrLatestResponse = components['schemas']['DsmrLatestResponse']
|
export type DsmrLatestResponse = components['schemas']['DsmrLatestResponse']
|
||||||
export type TibberTestResponse = components['schemas']['TibberTestResponse']
|
export type TibberTestResponse = components['schemas']['TibberTestResponse']
|
||||||
export type TibberTestPriceSchema = components['schemas']['TibberTestPriceSchema']
|
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
|
// Query: list all energy contracts
|
||||||
@@ -470,8 +477,14 @@ export function useDeclareMeter() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: MeterDeclareRequest) =>
|
mutationFn: (body: MeterDeclareRequest) =>
|
||||||
apiClient.POST('/api/energy/meters', { body }),
|
apiClient.POST('/api/energy/meters', { body }),
|
||||||
onSuccess: () => {
|
onSuccess: (_data, variables) => {
|
||||||
void qc.invalidateQueries({ queryKey: ['energy-meters'] })
|
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.
|
// Invalidate cost-related queries: a new meter may trigger recompute server-side.
|
||||||
void qc.invalidateQueries({ queryKey: ['energy-costs'] })
|
void qc.invalidateQueries({ queryKey: ['energy-costs'] })
|
||||||
void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
|
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)
|
// Query: time-range readings for a device (window + limit — never full-table)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
import { renderWithProviders } from '../test-utils'
|
import { renderWithProviders } from '../test-utils'
|
||||||
import { EnergyPage } from './EnergyPage'
|
import { EnergyPage } from './EnergyPage'
|
||||||
|
|
||||||
@@ -135,6 +136,19 @@ describe('EnergyPage — device list', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('EnergyPage — M8 navigation labels', () => {
|
||||||
|
beforeEach(() => { vi.clearAllMocks(); setupDefaultMocks() })
|
||||||
|
it('has keyboard-operable Sources, Modbus Devices and Meters tabs, without a DSMR tab', async () => {
|
||||||
|
const user = userEvent.setup(); renderEnergy()
|
||||||
|
const sources = screen.getByRole('tab', { name: 'Sources' })
|
||||||
|
expect(screen.getByRole('tab', { name: 'Modbus Devices' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: 'Meters' })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('tab', { name: /^DSMR$/ })).not.toBeInTheDocument()
|
||||||
|
sources.focus(); await user.keyboard('{Enter}')
|
||||||
|
expect(await screen.findByTestId('panel-sources')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('EnergyPage — create device', () => {
|
describe('EnergyPage — create device', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import { ContractManager } from '../energy/ContractManager'
|
|||||||
import { MeterManager } from '../energy/MeterManager'
|
import { MeterManager } from '../energy/MeterManager'
|
||||||
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 { SourceManager } from '../energy/SourceManager'
|
||||||
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'
|
||||||
@@ -603,7 +603,7 @@ function DevicesTab() {
|
|||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Title order={2}>Energy — Devices</Title>
|
<Title order={2}>Energy — Modbus Devices</Title>
|
||||||
<Button onClick={openCreate} data-testid="device-new-button">
|
<Button onClick={openCreate} data-testid="device-new-button">
|
||||||
New Device
|
New Device
|
||||||
</Button>
|
</Button>
|
||||||
@@ -659,8 +659,11 @@ export function EnergyPage() {
|
|||||||
<Container size="xl" pt="xl" pb="xl" data-testid="energy-page">
|
<Container size="xl" pt="xl" pb="xl" data-testid="energy-page">
|
||||||
<Tabs defaultValue="devices">
|
<Tabs defaultValue="devices">
|
||||||
<Tabs.List mb="lg">
|
<Tabs.List mb="lg">
|
||||||
|
<Tabs.Tab value="sources" data-testid="tab-sources">
|
||||||
|
Sources
|
||||||
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="devices" data-testid="tab-devices">
|
<Tabs.Tab value="devices" data-testid="tab-devices">
|
||||||
Devices
|
Modbus Devices
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="meters" data-testid="tab-meters">
|
<Tabs.Tab value="meters" data-testid="tab-meters">
|
||||||
Meters
|
Meters
|
||||||
@@ -674,11 +677,12 @@ 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="sources" data-testid="panel-sources">
|
||||||
|
<SourceManager />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="devices" data-testid="panel-devices">
|
<Tabs.Panel value="devices" data-testid="panel-devices">
|
||||||
<DevicesTab />
|
<DevicesTab />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
@@ -699,9 +703,6 @@ export function EnergyPage() {
|
|||||||
<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