diff --git a/frontend/src/energy/EnergyCharts.tsx b/frontend/src/energy/EnergyCharts.tsx index 5b75274..477223a 100644 --- a/frontend/src/energy/EnergyCharts.tsx +++ b/frontend/src/energy/EnergyCharts.tsx @@ -246,7 +246,7 @@ export function EnergyCharts({ uuid, deviceName, autoRefresh }: EnergyChartsProp ta="right" data-testid={`energy-charts-truncated-${uuid}`} > - 显示最近 {CHART_READINGS_LIMIT} 条;完整长时段走势待降采样 + Showing the most recent {CHART_READINGS_LIMIT} readings; full long-range trend pending downsampling )} diff --git a/frontend/src/pages/RecordsPage.test.tsx b/frontend/src/pages/RecordsPage.test.tsx index 04053aa..9b372ce 100644 --- a/frontend/src/pages/RecordsPage.test.tsx +++ b/frontend/src/pages/RecordsPage.test.tsx @@ -20,6 +20,7 @@ import { screen, waitFor, fireEvent } from '@testing-library/react' import { renderWithProviders } from '../test-utils' import { RecordsPage } from './RecordsPage' import type { LocationRecord } from '../records' +import type { ModbusDevice, MetricInfo } from '../energy/hooks' // --------------------------------------------------------------------------- // Fixtures @@ -439,3 +440,303 @@ describe('RecordsPage — multiple poo rows', () => { 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 } + +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 + readingsMap?: Record +} = {}) { + 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() + }) + }) +}) diff --git a/frontend/src/pages/RecordsPage.tsx b/frontend/src/pages/RecordsPage.tsx index 3a2d5bb..a6fc6b2 100644 --- a/frontend/src/pages/RecordsPage.tsx +++ b/frontend/src/pages/RecordsPage.tsx @@ -1,11 +1,13 @@ /** * 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. * - Location list: GET /api/locations, query key ['locations', {limit, offset}], page size 100. * - Edit and delete use reusable components from src/records/. * - Delete has a二次确认 modal before calling DELETE. * - 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' @@ -30,6 +32,9 @@ import apiClient from '../api/client' import { EditPooModal, EditLocationModal, ConfirmDeleteModal } from '../records' import { useDeletePoo, useDeleteLocation } 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 @@ -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 ( +
+ +
+ ) + } + + if (isError) { + return ( + + Failed to load meter readings. Please refresh. + + ) + } + + const colSpan = 1 + metrics.length // time column + one per metric + + return ( + + {isTruncated && ( + + Showing the most recent {METER_READINGS_LIMIT} readings (within the last 24h) + + )} + + + + + Recorded At + {metrics.map((m) => ( + + {m.label} + {m.unit ? ( + + ({m.unit}) + + ) : null} + + ))} + + + + {readings.length === 0 ? ( + + + + No readings in the last 24 hours. + + + + ) : ( + readings.map((row) => { + const payload = row.payload as Record + return ( + + + {formatRecordedAt(row.recorded_at)} + + {metrics.map((m) => ( + + {formatMetricValue(payload[m.key], m)} + + ))} + + ) + }) + )} + +
+
+
+ ) +} + // --------------------------------------------------------------------------- // RecordsPage — top-level // --------------------------------------------------------------------------- export function RecordsPage() { + const devicesQuery = useDevices() + const devices: ModbusDevice[] = devicesQuery.data?.items ?? [] + return ( @@ -360,6 +487,11 @@ export function RecordsPage() { <Tabs.Tab value="locations" data-testid="tab-locations"> Locations </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.Panel value="poo"> @@ -369,6 +501,12 @@ export function RecordsPage() { <Tabs.Panel value="locations"> <LocationList /> </Tabs.Panel> + + {devices.map((d) => ( + <Tabs.Panel key={d.uuid} value={d.uuid}> + <MeterReadingsTable device={d} /> + </Tabs.Panel> + ))} </Tabs> </Container> )