M5: Energy view — per-metric decimal formatting and chart auto-refresh toggle
This commit is contained in:
@@ -41,7 +41,8 @@ import {
|
||||
} from '@mantine/core'
|
||||
|
||||
import { useReadings, useMetrics } from './hooks'
|
||||
import type { MetricInfo } from './hooks'
|
||||
import type { MetricInfo, AutoRefreshOptions } from './hooks'
|
||||
import { formatMetricValue } from './format'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -59,6 +60,8 @@ interface EnergyChartsProps {
|
||||
uuid: string
|
||||
/** Friendly name for the chart title. */
|
||||
deviceName: string
|
||||
/** Auto-refresh options forwarded to useReadings. */
|
||||
autoRefresh?: AutoRefreshOptions
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -138,7 +141,7 @@ function safePayloadValue(
|
||||
// EnergyCharts component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) {
|
||||
export function EnergyCharts({ uuid, deviceName, autoRefresh }: EnergyChartsProps) {
|
||||
const [activePreset, setActivePreset] = useState<string>(DEFAULT_PRESET)
|
||||
|
||||
const selectedPreset = TIME_PRESETS.find((p) => p.value === activePreset) ?? TIME_PRESETS[0]
|
||||
@@ -150,7 +153,7 @@ export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) {
|
||||
[activePreset],
|
||||
)
|
||||
|
||||
const readingsQuery = useReadings(uuid, { start, end, limit: CHART_READINGS_LIMIT })
|
||||
const readingsQuery = useReadings(uuid, { start, end, limit: CHART_READINGS_LIMIT }, autoRefresh)
|
||||
const metricsQuery = useMetrics(uuid)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -295,10 +298,10 @@ export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) {
|
||||
/>
|
||||
<RechartsTooltip
|
||||
formatter={(value) => {
|
||||
const n = value as number | null | undefined
|
||||
if (n == null) return ['–', metric.label] as [string, string]
|
||||
const formatted = formatMetricValue(value, metric)
|
||||
if (formatted === '—') return ['—', metric.label] as [string, string]
|
||||
return [
|
||||
`${n}${metric.unit ? ' ' + metric.unit : ''}`,
|
||||
`${formatted}${metric.unit ? ' ' + metric.unit : ''}`,
|
||||
metric.label,
|
||||
] as [string, string]
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Tests for energy/format.ts — formatMetricValue helper.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. energy device_class → 3 decimal places.
|
||||
* 2. non-energy device_class → 2 decimal places.
|
||||
* 3. null value → "—" placeholder.
|
||||
* 4. undefined value → "—" placeholder.
|
||||
* 5. NaN value → "—" placeholder.
|
||||
* 6. Missing / null metric metadata → default 2 decimal places.
|
||||
* 7. Integer value still rendered with correct decimal places.
|
||||
* 8. String numeric value is coerced and formatted.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatMetricValue, VALUE_PLACEHOLDER } from './format'
|
||||
import type { MetricInfo } from './hooks'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMetric(overrides: Partial<MetricInfo> = {}): MetricInfo {
|
||||
return {
|
||||
key: 'voltage',
|
||||
label: 'Voltage',
|
||||
unit: 'V',
|
||||
device_class: 'voltage',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const energyMetric = makeMetric({ key: 'import_energy', label: 'Import Energy', unit: 'kWh', device_class: 'energy' })
|
||||
const voltageMetric = makeMetric()
|
||||
const powerMetric = makeMetric({ key: 'active_power', label: 'Active Power', unit: 'W', device_class: 'power' })
|
||||
const powerFactorMetric = makeMetric({ key: 'power_factor', label: 'Power Factor', unit: '', device_class: 'power_factor' })
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatMetricValue — energy device_class → 3 decimal places', () => {
|
||||
it('formats kWh energy value to 3 decimal places', () => {
|
||||
expect(formatMetricValue(123.4567, energyMetric)).toBe('123.457')
|
||||
})
|
||||
|
||||
it('formats a whole-number energy value to 3 decimal places', () => {
|
||||
expect(formatMetricValue(123, energyMetric)).toBe('123.000')
|
||||
})
|
||||
|
||||
it('formats zero energy value to 3 decimal places', () => {
|
||||
expect(formatMetricValue(0, energyMetric)).toBe('0.000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMetricValue — non-energy device_class → 2 decimal places', () => {
|
||||
it('formats voltage to 2 decimal places', () => {
|
||||
expect(formatMetricValue(230.2567, voltageMetric)).toBe('230.26')
|
||||
})
|
||||
|
||||
it('formats power to 2 decimal places', () => {
|
||||
expect(formatMetricValue(295.0, powerMetric)).toBe('295.00')
|
||||
})
|
||||
|
||||
it('formats power factor to 2 decimal places', () => {
|
||||
expect(formatMetricValue(0.98, powerFactorMetric)).toBe('0.98')
|
||||
})
|
||||
|
||||
it('formats current to 2 decimal places', () => {
|
||||
const currentMetric = makeMetric({ key: 'current', label: 'Current', unit: 'A', device_class: 'current' })
|
||||
expect(formatMetricValue(1.3456, currentMetric)).toBe('1.35')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMetricValue — null/undefined/NaN → placeholder', () => {
|
||||
it('returns placeholder for null', () => {
|
||||
expect(formatMetricValue(null, voltageMetric)).toBe(VALUE_PLACEHOLDER)
|
||||
})
|
||||
|
||||
it('returns placeholder for undefined', () => {
|
||||
expect(formatMetricValue(undefined, voltageMetric)).toBe(VALUE_PLACEHOLDER)
|
||||
})
|
||||
|
||||
it('returns placeholder for NaN', () => {
|
||||
expect(formatMetricValue(NaN, voltageMetric)).toBe(VALUE_PLACEHOLDER)
|
||||
})
|
||||
|
||||
it('returns placeholder for non-numeric string', () => {
|
||||
expect(formatMetricValue('not-a-number', voltageMetric)).toBe(VALUE_PLACEHOLDER)
|
||||
})
|
||||
|
||||
it('returns placeholder for Infinity', () => {
|
||||
expect(formatMetricValue(Infinity, voltageMetric)).toBe(VALUE_PLACEHOLDER)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMetricValue — missing metric metadata → default 2 decimal places', () => {
|
||||
it('falls back to 2 decimal places when metric is undefined', () => {
|
||||
expect(formatMetricValue(230.2567)).toBe('230.26')
|
||||
})
|
||||
|
||||
it('falls back to 2 decimal places when metric is null', () => {
|
||||
expect(formatMetricValue(230.2567, null)).toBe('230.26')
|
||||
})
|
||||
|
||||
it('returns placeholder for null value even without metric', () => {
|
||||
expect(formatMetricValue(null)).toBe(VALUE_PLACEHOLDER)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMetricValue — numeric string coercion', () => {
|
||||
it('coerces a numeric string to number and formats it', () => {
|
||||
// API could theoretically return strings; we handle them gracefully.
|
||||
expect(formatMetricValue('230.2567', voltageMetric)).toBe('230.26')
|
||||
})
|
||||
|
||||
it('coerces a numeric string for energy metric → 3 decimals', () => {
|
||||
expect(formatMetricValue('123.4567', energyMetric)).toBe('123.457')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* format.ts — shared metric value formatting helpers for the Energy view.
|
||||
*
|
||||
* Rules (from M5 polish requirements):
|
||||
* - device_class === "energy" → 3 decimal places (kWh values need precision)
|
||||
* - everything else → 2 decimal places
|
||||
* - null / undefined / NaN → placeholder "—"
|
||||
* - missing metric metadata → default 2 decimal places
|
||||
*/
|
||||
|
||||
import type { MetricInfo } from './hooks'
|
||||
|
||||
/** Placeholder shown when a value is unavailable. */
|
||||
export const VALUE_PLACEHOLDER = '—'
|
||||
|
||||
/**
|
||||
* Format a numeric metric value for display.
|
||||
*
|
||||
* @param value The raw value from the API payload (any type; non-numeric → "—").
|
||||
* @param metric Optional metric metadata from /metrics. When absent, falls back
|
||||
* to 2 decimal places.
|
||||
* @returns A formatted string ready for display (e.g. "230.25" or "—").
|
||||
*/
|
||||
export function formatMetricValue(
|
||||
value: unknown,
|
||||
metric?: MetricInfo | null,
|
||||
): string {
|
||||
// Guard: null / undefined
|
||||
if (value === null || value === undefined) return VALUE_PLACEHOLDER
|
||||
|
||||
const n = Number(value)
|
||||
|
||||
// Guard: NaN or non-finite
|
||||
if (!Number.isFinite(n)) return VALUE_PLACEHOLDER
|
||||
|
||||
// Determine decimal places from device_class.
|
||||
const decimals = metric?.device_class === 'energy' ? 3 : 2
|
||||
|
||||
return n.toFixed(decimals)
|
||||
}
|
||||
@@ -274,6 +274,80 @@ describe('useMetrics', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useLatestReading — auto-refresh option', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('passes refetchInterval to TanStack Query when refetchIntervalMs is provided', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
data: { found: true, recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } },
|
||||
})
|
||||
|
||||
const { Wrapper } = makeWrapper()
|
||||
const { useLatestReading } = await import('./hooks')
|
||||
const { result } = renderHook(
|
||||
() => useLatestReading('test-uuid-1', { refetchIntervalMs: 5_000 }),
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
// Query succeeds — refetchIntervalMs is wired through without error.
|
||||
expect(result.current.data?.found).toBe(true)
|
||||
})
|
||||
|
||||
it('works without options (no auto-refresh)', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
data: { found: false, recorded_at: null, payload: null },
|
||||
})
|
||||
|
||||
const { Wrapper } = makeWrapper()
|
||||
const { useLatestReading } = await import('./hooks')
|
||||
const { result } = renderHook(
|
||||
() => useLatestReading('test-uuid-2'),
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data?.found).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useReadings — auto-refresh option', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('passes refetchInterval to TanStack Query when refetchIntervalMs is provided', async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }] },
|
||||
})
|
||||
|
||||
const { Wrapper } = makeWrapper()
|
||||
const { useReadings } = await import('./hooks')
|
||||
const { result } = renderHook(
|
||||
() => useReadings('test-uuid-1', {}, { refetchIntervalMs: 5_000 }),
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
// Query succeeds — refetchIntervalMs is wired through without error.
|
||||
expect(result.current.data?.items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('enforces minimum refetchInterval of 2000ms', async () => {
|
||||
mockGet.mockResolvedValue({ data: { items: [] } })
|
||||
|
||||
const { Wrapper } = makeWrapper()
|
||||
const { useReadings } = await import('./hooks')
|
||||
// Pass a very small value — should be clamped to 2000ms internally.
|
||||
const { result } = renderHook(
|
||||
() => useReadings('test-uuid-1', {}, { refetchIntervalMs: 100 }),
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
// No crash; the hook accepted the option and clamped it internally.
|
||||
expect(result.current.isSuccess).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useReadings', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
|
||||
@@ -132,11 +132,28 @@ export function useTestReadDevice() {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query options shared by auto-refresh–capable queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AutoRefreshOptions {
|
||||
/**
|
||||
* When provided, TanStack Query will automatically re-fetch this query at
|
||||
* this interval (milliseconds). Pass `undefined` to disable auto-refresh.
|
||||
* Minimum enforced value: 2 000 ms.
|
||||
*/
|
||||
refetchIntervalMs?: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query: latest reading for a device
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useLatestReading(uuid: string) {
|
||||
export function useLatestReading(uuid: string, options?: AutoRefreshOptions) {
|
||||
const refetchInterval = options?.refetchIntervalMs != null
|
||||
? Math.max(2_000, options.refetchIntervalMs)
|
||||
: undefined
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['modbus-latest', uuid],
|
||||
queryFn: async () => {
|
||||
@@ -145,8 +162,7 @@ export function useLatestReading(uuid: string) {
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
// Refresh every 10 s to show up-to-date readings.
|
||||
refetchInterval: 10_000,
|
||||
refetchInterval,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -175,10 +191,18 @@ export function useMetrics(uuid: string) {
|
||||
/** Hard upper-bound on readings fetched; prevents accidental full-table pulls. */
|
||||
const READINGS_MAX_LIMIT = 1000
|
||||
|
||||
export function useReadings(uuid: string, params: ReadingsQueryParams) {
|
||||
export function useReadings(
|
||||
uuid: string,
|
||||
params: ReadingsQueryParams,
|
||||
options?: AutoRefreshOptions,
|
||||
) {
|
||||
const { start, end, limit } = params
|
||||
const effectiveLimit = Math.min(limit ?? READINGS_MAX_LIMIT, READINGS_MAX_LIMIT)
|
||||
|
||||
const refetchInterval = options?.refetchIntervalMs != null
|
||||
? Math.max(2_000, options.refetchIntervalMs)
|
||||
: undefined
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['modbus-readings', uuid, { start, end, limit: effectiveLimit }],
|
||||
queryFn: async () => {
|
||||
@@ -196,5 +220,6 @@ export function useReadings(uuid: string, params: ReadingsQueryParams) {
|
||||
},
|
||||
// Enabled only when we have valid uuid; start/end may be null (full window).
|
||||
enabled: !!uuid,
|
||||
refetchInterval,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Tests for EnergyPage (M5-T06).
|
||||
* Tests for EnergyPage (M5-T06 + M5-polish).
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Renders device list from GET /api/modbus/devices.
|
||||
@@ -10,6 +10,7 @@
|
||||
* 6. Delete 409 error shows friendly hint in confirmation modal.
|
||||
* 7. Test-read button calls POST /api/modbus/devices/{uuid}/test and shows result.
|
||||
* 8. Test-read failure shows error in modal.
|
||||
* 9. Auto-refresh switch is rendered (default on) and can be toggled.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
@@ -321,6 +322,7 @@ describe('EnergyPage — test-read', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('test-read-ok')).toBeInTheDocument()
|
||||
// Values rendered via formatMetricValue — voltage 230.2 → "230.20"
|
||||
expect(screen.getByTestId('test-read-payload').textContent).toContain('230.2')
|
||||
})
|
||||
|
||||
@@ -345,3 +347,48 @@ describe('EnergyPage — test-read', () => {
|
||||
expect(screen.getByTestId('test-read-error').textContent).toContain('Connection timed out')
|
||||
})
|
||||
})
|
||||
|
||||
describe('EnergyPage — auto-refresh switch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupDefaultMocks()
|
||||
})
|
||||
|
||||
it('renders auto-refresh switch when devices exist', async () => {
|
||||
renderEnergy()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('auto-refresh-switch')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('auto-refresh switch is checked by default (default on)', async () => {
|
||||
renderEnergy()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('auto-refresh-switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Mantine Switch uses role="switch" and places data-testid directly on the
|
||||
// <input> element; query it by the testid or by role.
|
||||
const switchInput = screen.getByRole('switch', { name: /自动刷新/i })
|
||||
expect(switchInput).toBeChecked()
|
||||
})
|
||||
|
||||
it('toggling auto-refresh switch changes its checked state', async () => {
|
||||
renderEnergy()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('auto-refresh-switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const switchInput = screen.getByRole('switch', { name: /自动刷新/i })
|
||||
expect(switchInput).toBeChecked()
|
||||
|
||||
fireEvent.click(switchInput)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(switchInput).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,12 +33,14 @@ import {
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
Switch,
|
||||
} from '@mantine/core'
|
||||
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
|
||||
import { DeviceForm } from '../energy/DeviceForm'
|
||||
import { EnergyCharts } from '../energy/EnergyCharts'
|
||||
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
|
||||
import { ApiError } from '../api/client'
|
||||
import { formatMetricValue } from '../energy/format'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete confirmation modal (with 409 "disable instead" hint)
|
||||
@@ -99,10 +101,11 @@ function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error
|
||||
interface TestResultModalProps {
|
||||
result: ModbusTestReadResponse
|
||||
deviceName: string
|
||||
metrics?: MetricInfo[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function TestResultModal({ result, deviceName, onClose }: TestResultModalProps) {
|
||||
function TestResultModal({ result, deviceName, metrics, onClose }: TestResultModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
opened
|
||||
@@ -114,9 +117,33 @@ function TestResultModal({ result, deviceName, onClose }: TestResultModalProps)
|
||||
{result.ok ? (
|
||||
<Stack gap="sm">
|
||||
<Badge color="green" data-testid="test-read-ok">Success</Badge>
|
||||
{result.payload && typeof result.payload === 'object' ? (
|
||||
<SimpleGrid cols={2} spacing="xs" data-testid="test-read-payload">
|
||||
{Object.entries(result.payload as Record<string, unknown>).map(([key, val]) => {
|
||||
const metricMeta = metrics?.find((m) => m.key === key)
|
||||
return (
|
||||
<Group key={key} justify="space-between" align="baseline" gap={4}
|
||||
data-testid={`test-read-value-${key}`}>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{metricMeta?.label ?? key}
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{formatMetricValue(val, metricMeta)}
|
||||
{metricMeta?.unit ? (
|
||||
<Text component="span" size="xs" c="dimmed" ml={2}>
|
||||
{metricMeta.unit}
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</Group>
|
||||
)
|
||||
})}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Code block data-testid="test-read-payload">
|
||||
{JSON.stringify(result.payload, null, 2)}
|
||||
</Code>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
@@ -147,6 +174,7 @@ interface TestReadButtonProps {
|
||||
|
||||
function TestReadButton({ device }: TestReadButtonProps) {
|
||||
const testMutation = useTestReadDevice()
|
||||
const metricsQuery = useMetrics(device.uuid)
|
||||
const [result, setResult] = useState<ModbusTestReadResponse | null>(null)
|
||||
const [testError, setTestError] = useState<string | null>(null)
|
||||
|
||||
@@ -209,6 +237,7 @@ function TestReadButton({ device }: TestReadButtonProps) {
|
||||
<TestResultModal
|
||||
result={result}
|
||||
deviceName={device.friendly_name}
|
||||
metrics={metricsQuery.data?.metrics}
|
||||
onClose={() => setResult(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -223,10 +252,11 @@ function TestReadButton({ device }: TestReadButtonProps) {
|
||||
|
||||
interface LatestReadingsCardProps {
|
||||
device: ModbusDevice
|
||||
autoRefresh?: { refetchIntervalMs: number }
|
||||
}
|
||||
|
||||
function LatestReadingsCard({ device }: LatestReadingsCardProps) {
|
||||
const latestQuery = useLatestReading(device.uuid)
|
||||
function LatestReadingsCard({ device, autoRefresh }: LatestReadingsCardProps) {
|
||||
const latestQuery = useLatestReading(device.uuid, autoRefresh)
|
||||
const metricsQuery = useMetrics(device.uuid)
|
||||
|
||||
const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? []
|
||||
@@ -282,12 +312,8 @@ function LatestReadingsCard({ device }: LatestReadingsCardProps) {
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{metrics.map((m) => {
|
||||
const raw = payload[m.key]
|
||||
const hasValue = raw !== null && raw !== undefined
|
||||
const displayValue = hasValue
|
||||
? typeof raw === 'number'
|
||||
? raw.toFixed(raw % 1 === 0 ? 0 : 2)
|
||||
: String(raw)
|
||||
: '—'
|
||||
const displayValue = formatMetricValue(raw, m)
|
||||
const hasValue = displayValue !== '—'
|
||||
|
||||
return (
|
||||
<Group key={m.key} justify="space-between" align="baseline" gap={4}
|
||||
@@ -411,25 +437,50 @@ function DeviceTable({ devices, onEdit, onDelete }: DeviceTableProps) {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device readings section — latest card + trend chart per device (T07)
|
||||
// Auto-refresh switch controls TanStack Query refetchInterval for all
|
||||
// readings and latest queries in this section.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Default auto-refresh interval in milliseconds (matches backend ~5s poll). */
|
||||
const AUTO_REFRESH_INTERVAL_MS = 5_000
|
||||
|
||||
interface DeviceReadingsSectionProps {
|
||||
devices: ModbusDevice[]
|
||||
}
|
||||
|
||||
function DeviceReadingsSection({ devices }: DeviceReadingsSectionProps) {
|
||||
// Default on — user wants charts to auto-update without manual refresh.
|
||||
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true)
|
||||
|
||||
if (devices.length === 0) return null
|
||||
|
||||
const autoRefresh = autoRefreshEnabled
|
||||
? { refetchIntervalMs: AUTO_REFRESH_INTERVAL_MS }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Stack gap="xl" data-testid="device-readings-section">
|
||||
<Divider label="Readings & Trends" labelPosition="left" />
|
||||
<Group justify="space-between" align="center">
|
||||
<Divider label="Readings & Trends" labelPosition="left" style={{ flex: 1 }} />
|
||||
<Switch
|
||||
label="自动刷新"
|
||||
checked={autoRefreshEnabled}
|
||||
onChange={(e) => setAutoRefreshEnabled(e.currentTarget.checked)}
|
||||
size="sm"
|
||||
data-testid="auto-refresh-switch"
|
||||
/>
|
||||
</Group>
|
||||
{devices.map((device) => (
|
||||
<Stack key={device.uuid} gap="sm" data-testid={`device-readings-${device.uuid}`}>
|
||||
<Text fw={500} size="sm">
|
||||
{device.friendly_name}
|
||||
</Text>
|
||||
<LatestReadingsCard device={device} />
|
||||
<EnergyCharts uuid={device.uuid} deviceName={device.friendly_name} />
|
||||
<LatestReadingsCard device={device} autoRefresh={autoRefresh} />
|
||||
<EnergyCharts
|
||||
uuid={device.uuid}
|
||||
deviceName={device.friendly_name}
|
||||
autoRefresh={autoRefresh}
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
Reference in New Issue
Block a user