M5: add read-only per-meter readings tabs to Records page; English-only UI strings
This commit is contained in:
@@ -246,7 +246,7 @@ export function EnergyCharts({ uuid, deviceName, autoRefresh }: EnergyChartsProp
|
|||||||
ta="right"
|
ta="right"
|
||||||
data-testid={`energy-charts-truncated-${uuid}`}
|
data-testid={`energy-charts-truncated-${uuid}`}
|
||||||
>
|
>
|
||||||
显示最近 {CHART_READINGS_LIMIT} 条;完整长时段走势待降采样
|
Showing the most recent {CHART_READINGS_LIMIT} readings; full long-range trend pending downsampling
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { screen, waitFor, fireEvent } from '@testing-library/react'
|
|||||||
import { renderWithProviders } from '../test-utils'
|
import { renderWithProviders } from '../test-utils'
|
||||||
import { RecordsPage } from './RecordsPage'
|
import { RecordsPage } from './RecordsPage'
|
||||||
import type { LocationRecord } from '../records'
|
import type { LocationRecord } from '../records'
|
||||||
|
import type { ModbusDevice, MetricInfo } from '../energy/hooks'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Fixtures
|
// Fixtures
|
||||||
@@ -439,3 +440,303 @@ describe('RecordsPage — multiple poo rows', () => {
|
|||||||
expect(screen.getByText('2026-06-12T11:00:00Z')).toBeInTheDocument()
|
expect(screen.getByText('2026-06-12T11:00:00Z')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Meter readings tabs (M5)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const METER_DEVICE_1: ModbusDevice = {
|
||||||
|
uuid: 'dev-uuid-1',
|
||||||
|
friendly_name: 'Main Meter',
|
||||||
|
transport: 'tcp',
|
||||||
|
host: '192.168.1.10',
|
||||||
|
port: 502,
|
||||||
|
unit_id: 1,
|
||||||
|
profile: 'sdm630',
|
||||||
|
poll_interval_s: 5,
|
||||||
|
enabled: true,
|
||||||
|
last_poll_at: '2026-06-22T10:00:00Z',
|
||||||
|
last_poll_ok: true,
|
||||||
|
created_at: '2026-06-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-22T10:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const METER_DEVICE_2: ModbusDevice = {
|
||||||
|
uuid: 'dev-uuid-2',
|
||||||
|
friendly_name: 'Sub Meter',
|
||||||
|
transport: 'tcp',
|
||||||
|
host: '192.168.1.11',
|
||||||
|
port: 502,
|
||||||
|
unit_id: 1,
|
||||||
|
profile: 'sdm120',
|
||||||
|
poll_interval_s: 5,
|
||||||
|
enabled: true,
|
||||||
|
last_poll_at: null,
|
||||||
|
last_poll_ok: null,
|
||||||
|
created_at: '2026-06-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-22T10:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const METER_METRICS: MetricInfo[] = [
|
||||||
|
{ key: 'voltage', label: 'Voltage', unit: 'V', device_class: 'voltage' },
|
||||||
|
{ key: 'energy_kwh', label: 'Energy', unit: 'kWh', device_class: 'energy' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** A single reading with an open-ended payload shape for test flexibility. */
|
||||||
|
type TestReading = { recorded_at: string; payload: Record<string, number> }
|
||||||
|
|
||||||
|
const METER_READINGS: TestReading[] = [
|
||||||
|
{
|
||||||
|
recorded_at: '2026-06-22T09:00:00Z',
|
||||||
|
payload: { voltage: 230.5, energy_kwh: 12.345 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
recorded_at: '2026-06-22T09:05:00Z',
|
||||||
|
payload: { voltage: 231.1, energy_kwh: 12.350 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Extend the poo/location mock to also serve Modbus endpoints. */
|
||||||
|
function setupGetMockWithMeters({
|
||||||
|
devices = [METER_DEVICE_1],
|
||||||
|
metricsMap = { 'dev-uuid-1': METER_METRICS },
|
||||||
|
readingsMap = { 'dev-uuid-1': METER_READINGS },
|
||||||
|
}: {
|
||||||
|
devices?: ModbusDevice[]
|
||||||
|
metricsMap?: Record<string, MetricInfo[]>
|
||||||
|
readingsMap?: Record<string, TestReading[]>
|
||||||
|
} = {}) {
|
||||||
|
mockGet.mockImplementation((path: string, opts?: { params?: { path?: { uuid?: string }; query?: { offset?: number } } }) => {
|
||||||
|
const offset = opts?.params?.query?.offset ?? 0
|
||||||
|
if (path === '/api/poo') {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { items: [POO_RECORD], limit: 100, offset },
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (path === '/api/locations') {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { items: [LOCATION_RECORD], limit: 100, offset },
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (path === '/api/modbus/devices') {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { items: devices, total: devices.length },
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const uuid = opts?.params?.path?.uuid
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/metrics' && uuid) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { profile: 'sdm630', metrics: metricsMap[uuid] ?? [] },
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (path === '/api/modbus/devices/{uuid}/readings' && uuid) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { items: readingsMap[uuid] ?? [] },
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RecordsPage — meter tabs', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 0 devices: no meter tabs
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows no meter tabs when there are 0 devices', async () => {
|
||||||
|
setupGetMockWithMeters({ devices: [] })
|
||||||
|
|
||||||
|
renderRecords()
|
||||||
|
|
||||||
|
// Poo and Locations tabs must still be present
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('tab-poo')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('tab-locations')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// No meter tab rendered
|
||||||
|
expect(screen.queryByTestId('tab-meter-dev-uuid-1')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 1 device: 1 meter tab with friendly_name label
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('renders 1 meter tab with friendly_name for 1 device', async () => {
|
||||||
|
setupGetMockWithMeters({ devices: [METER_DEVICE_1] })
|
||||||
|
|
||||||
|
renderRecords()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toHaveTextContent(METER_DEVICE_1.friendly_name)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 2 devices: 2 meter tabs with correct friendly_names
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('renders 2 meter tabs for 2 devices, each with correct friendly_name', async () => {
|
||||||
|
setupGetMockWithMeters({
|
||||||
|
devices: [METER_DEVICE_1, METER_DEVICE_2],
|
||||||
|
metricsMap: { 'dev-uuid-1': METER_METRICS, 'dev-uuid-2': METER_METRICS },
|
||||||
|
readingsMap: { 'dev-uuid-1': METER_READINGS, 'dev-uuid-2': [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRecords()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_2.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toHaveTextContent('Main Meter')
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_2.uuid}`)).toHaveTextContent('Sub Meter')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Meter tab: switching to it shows the readings table with metric columns
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('switching to meter tab shows table with metric column headers and reading rows', async () => {
|
||||||
|
setupGetMockWithMeters({ devices: [METER_DEVICE_1] })
|
||||||
|
|
||||||
|
renderRecords()
|
||||||
|
|
||||||
|
// Wait for tab to appear then click it
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`))
|
||||||
|
|
||||||
|
// Table should be rendered
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`meter-table-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Metric column headers
|
||||||
|
expect(screen.getByText(/Voltage/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Energy/i)).toBeInTheDocument()
|
||||||
|
|
||||||
|
// At least one reading row
|
||||||
|
const rows = screen.getAllByTestId(`meter-row-${METER_DEVICE_1.uuid}`)
|
||||||
|
expect(rows).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Meter tab: values formatted correctly (voltage 2 dp, energy 3 dp)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('formats voltage with 2 dp and energy with 3 dp', async () => {
|
||||||
|
setupGetMockWithMeters({ devices: [METER_DEVICE_1] })
|
||||||
|
|
||||||
|
renderRecords()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`meter-table-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// voltage: 230.5 → "230.50" (2 dp, device_class != energy)
|
||||||
|
expect(screen.getByText('230.50')).toBeInTheDocument()
|
||||||
|
// energy_kwh: 12.345 → "12.345" (3 dp, device_class == energy)
|
||||||
|
expect(screen.getByText('12.345')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Meter tab: missing key in payload → placeholder "—"
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows placeholder "—" for a missing key in payload', async () => {
|
||||||
|
const readingsWithMissingKey = [
|
||||||
|
{
|
||||||
|
recorded_at: '2026-06-22T09:00:00Z',
|
||||||
|
payload: { voltage: 230.5 }, // energy_kwh missing
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
setupGetMockWithMeters({
|
||||||
|
devices: [METER_DEVICE_1],
|
||||||
|
readingsMap: { 'dev-uuid-1': readingsWithMissingKey },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRecords()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`meter-table-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// The "—" placeholder must appear (for the missing energy_kwh key)
|
||||||
|
expect(screen.getByText('—')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Meter tab: empty readings → empty state message
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows empty state when device has no readings', async () => {
|
||||||
|
setupGetMockWithMeters({
|
||||||
|
devices: [METER_DEVICE_1],
|
||||||
|
readingsMap: { 'dev-uuid-1': [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRecords()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`meter-empty-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Meter tab: 1000 readings → truncation notice shown
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows truncation notice when readings.length equals METER_READINGS_LIMIT (1000)', async () => {
|
||||||
|
const truncatedReadings = Array.from({ length: 1000 }, (_, i) => ({
|
||||||
|
recorded_at: `2026-06-22T${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00Z`,
|
||||||
|
payload: { voltage: 230 + i * 0.01, energy_kwh: 10 + i * 0.001 },
|
||||||
|
}))
|
||||||
|
|
||||||
|
setupGetMockWithMeters({
|
||||||
|
devices: [METER_DEVICE_1],
|
||||||
|
readingsMap: { 'dev-uuid-1': truncatedReadings },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRecords()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`meter-truncated-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* RecordsPage — paginated lists + edit/delete for poo and location records (M2-T10).
|
* RecordsPage — paginated lists + edit/delete for poo and location records (M2-T10).
|
||||||
|
* Meter readings tab (read-only) per Modbus device dynamically appended (M5).
|
||||||
*
|
*
|
||||||
* - Poo list: GET /api/poo, query key ['poo', {limit, offset}], page size 100.
|
* - Poo list: GET /api/poo, query key ['poo', {limit, offset}], page size 100.
|
||||||
* - Location list: GET /api/locations, query key ['locations', {limit, offset}], page size 100.
|
* - Location list: GET /api/locations, query key ['locations', {limit, offset}], page size 100.
|
||||||
* - Edit and delete use reusable components from src/records/.
|
* - Edit and delete use reusable components from src/records/.
|
||||||
* - Delete has a二次确认 modal before calling DELETE.
|
* - Delete has a二次确认 modal before calling DELETE.
|
||||||
* - Pagination with Mantine Pagination; next/prev fetches per-page (no full-table pull).
|
* - Pagination with Mantine Pagination; next/prev fetches per-page (no full-table pull).
|
||||||
|
* - Meter tabs: one read-only tab per Modbus device; readings from /readings (last 24h, limit 1000).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
@@ -30,6 +32,9 @@ import apiClient from '../api/client'
|
|||||||
import { EditPooModal, EditLocationModal, ConfirmDeleteModal } from '../records'
|
import { EditPooModal, EditLocationModal, ConfirmDeleteModal } from '../records'
|
||||||
import { useDeletePoo, useDeleteLocation } from '../records'
|
import { useDeletePoo, useDeleteLocation } from '../records'
|
||||||
import type { PooRecord, LocationRecord } from '../records'
|
import type { PooRecord, LocationRecord } from '../records'
|
||||||
|
import { useDevices, useMetrics, useReadings } from '../energy/hooks'
|
||||||
|
import type { ModbusDevice, MetricInfo } from '../energy/hooks'
|
||||||
|
import { formatMetricValue } from '../energy/format'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -341,11 +346,133 @@ function LocationList() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MeterReadingsTable — read-only table for a single Modbus device's readings
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** How many ms of history to show per meter tab (last 24 h). */
|
||||||
|
const METER_SPAN_MS = 24 * 60 * 60 * 1000
|
||||||
|
/** Upper bound on readings fetched; never a full-table pull. */
|
||||||
|
const METER_READINGS_LIMIT = 1000
|
||||||
|
|
||||||
|
interface MeterReadingsTableProps {
|
||||||
|
device: ModbusDevice
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format an ISO timestamp for display. */
|
||||||
|
function formatRecordedAt(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString()
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function MeterReadingsTable({ device }: MeterReadingsTableProps) {
|
||||||
|
const metricsQuery = useMetrics(device.uuid)
|
||||||
|
const readingsQuery = useReadings(device.uuid, {
|
||||||
|
spanMs: METER_SPAN_MS,
|
||||||
|
limit: METER_READINGS_LIMIT,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = metricsQuery.isLoading || readingsQuery.isLoading
|
||||||
|
const isError = metricsQuery.isError || readingsQuery.isError
|
||||||
|
|
||||||
|
const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? []
|
||||||
|
const readings = readingsQuery.data?.items ?? []
|
||||||
|
const isTruncated = !isLoading && !isError && readings.length >= METER_READINGS_LIMIT
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Center pt="xl" data-testid={`meter-loading-${device.uuid}`}>
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Alert color="red" data-testid={`meter-error-${device.uuid}`}>
|
||||||
|
Failed to load meter readings. Please refresh.
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const colSpan = 1 + metrics.length // time column + one per metric
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{isTruncated && (
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
ta="right"
|
||||||
|
data-testid={`meter-truncated-${device.uuid}`}
|
||||||
|
>
|
||||||
|
Showing the most recent {METER_READINGS_LIMIT} readings (within the last 24h)
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<ScrollArea>
|
||||||
|
<Table striped highlightOnHover withTableBorder data-testid={`meter-table-${device.uuid}`}>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th style={{ whiteSpace: 'nowrap' }}>Recorded At</Table.Th>
|
||||||
|
{metrics.map((m) => (
|
||||||
|
<Table.Th key={m.key} style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{m.label}
|
||||||
|
{m.unit ? (
|
||||||
|
<Text component="span" size="xs" c="dimmed" ml={4}>
|
||||||
|
({m.unit})
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{readings.length === 0 ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={colSpan}>
|
||||||
|
<Text c="dimmed" ta="center" size="sm" data-testid={`meter-empty-${device.uuid}`}>
|
||||||
|
No readings in the last 24 hours.
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : (
|
||||||
|
readings.map((row) => {
|
||||||
|
const payload = row.payload as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
<Table.Tr
|
||||||
|
key={row.recorded_at}
|
||||||
|
data-testid={`meter-row-${device.uuid}`}
|
||||||
|
>
|
||||||
|
<Table.Td style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{formatRecordedAt(row.recorded_at)}
|
||||||
|
</Table.Td>
|
||||||
|
{metrics.map((m) => (
|
||||||
|
<Table.Td key={m.key} data-testid={`meter-cell-${device.uuid}-${m.key}`}>
|
||||||
|
{formatMetricValue(payload[m.key], m)}
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// RecordsPage — top-level
|
// RecordsPage — top-level
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function RecordsPage() {
|
export function RecordsPage() {
|
||||||
|
const devicesQuery = useDevices()
|
||||||
|
const devices: ModbusDevice[] = devicesQuery.data?.items ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" pt="xl" pb="xl" data-testid="records-page">
|
<Container size="xl" pt="xl" pb="xl" data-testid="records-page">
|
||||||
<Title order={2} mb="lg">
|
<Title order={2} mb="lg">
|
||||||
@@ -360,6 +487,11 @@ export function RecordsPage() {
|
|||||||
<Tabs.Tab value="locations" data-testid="tab-locations">
|
<Tabs.Tab value="locations" data-testid="tab-locations">
|
||||||
Locations
|
Locations
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<Tabs.Tab key={d.uuid} value={d.uuid} data-testid={`tab-meter-${d.uuid}`}>
|
||||||
|
{d.friendly_name}
|
||||||
|
</Tabs.Tab>
|
||||||
|
))}
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="poo">
|
<Tabs.Panel value="poo">
|
||||||
@@ -369,6 +501,12 @@ export function RecordsPage() {
|
|||||||
<Tabs.Panel value="locations">
|
<Tabs.Panel value="locations">
|
||||||
<LocationList />
|
<LocationList />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{devices.map((d) => (
|
||||||
|
<Tabs.Panel key={d.uuid} value={d.uuid}>
|
||||||
|
<MeterReadingsTable device={d} />
|
||||||
|
</Tabs.Panel>
|
||||||
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user