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'
|
} from '@mantine/core'
|
||||||
|
|
||||||
import { useReadings, useMetrics } from './hooks'
|
import { useReadings, useMetrics } from './hooks'
|
||||||
import type { MetricInfo } from './hooks'
|
import type { MetricInfo, AutoRefreshOptions } from './hooks'
|
||||||
|
import { formatMetricValue } from './format'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -59,6 +60,8 @@ interface EnergyChartsProps {
|
|||||||
uuid: string
|
uuid: string
|
||||||
/** Friendly name for the chart title. */
|
/** Friendly name for the chart title. */
|
||||||
deviceName: string
|
deviceName: string
|
||||||
|
/** Auto-refresh options forwarded to useReadings. */
|
||||||
|
autoRefresh?: AutoRefreshOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -138,7 +141,7 @@ function safePayloadValue(
|
|||||||
// EnergyCharts component
|
// EnergyCharts component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) {
|
export function EnergyCharts({ uuid, deviceName, autoRefresh }: EnergyChartsProps) {
|
||||||
const [activePreset, setActivePreset] = useState<string>(DEFAULT_PRESET)
|
const [activePreset, setActivePreset] = useState<string>(DEFAULT_PRESET)
|
||||||
|
|
||||||
const selectedPreset = TIME_PRESETS.find((p) => p.value === activePreset) ?? TIME_PRESETS[0]
|
const selectedPreset = TIME_PRESETS.find((p) => p.value === activePreset) ?? TIME_PRESETS[0]
|
||||||
@@ -150,7 +153,7 @@ export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) {
|
|||||||
[activePreset],
|
[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)
|
const metricsQuery = useMetrics(uuid)
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -295,10 +298,10 @@ export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) {
|
|||||||
/>
|
/>
|
||||||
<RechartsTooltip
|
<RechartsTooltip
|
||||||
formatter={(value) => {
|
formatter={(value) => {
|
||||||
const n = value as number | null | undefined
|
const formatted = formatMetricValue(value, metric)
|
||||||
if (n == null) return ['–', metric.label] as [string, string]
|
if (formatted === '—') return ['—', metric.label] as [string, string]
|
||||||
return [
|
return [
|
||||||
`${n}${metric.unit ? ' ' + metric.unit : ''}`,
|
`${formatted}${metric.unit ? ' ' + metric.unit : ''}`,
|
||||||
metric.label,
|
metric.label,
|
||||||
] as [string, string]
|
] 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', () => {
|
describe('useReadings', () => {
|
||||||
beforeEach(() => vi.clearAllMocks())
|
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
|
// 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({
|
return useQuery({
|
||||||
queryKey: ['modbus-latest', uuid],
|
queryKey: ['modbus-latest', uuid],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -145,8 +162,7 @@ export function useLatestReading(uuid: string) {
|
|||||||
})
|
})
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
// Refresh every 10 s to show up-to-date readings.
|
refetchInterval,
|
||||||
refetchInterval: 10_000,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,10 +191,18 @@ export function useMetrics(uuid: string) {
|
|||||||
/** Hard upper-bound on readings fetched; prevents accidental full-table pulls. */
|
/** Hard upper-bound on readings fetched; prevents accidental full-table pulls. */
|
||||||
const READINGS_MAX_LIMIT = 1000
|
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 { start, end, limit } = params
|
||||||
const effectiveLimit = Math.min(limit ?? READINGS_MAX_LIMIT, READINGS_MAX_LIMIT)
|
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({
|
return useQuery({
|
||||||
queryKey: ['modbus-readings', uuid, { start, end, limit: effectiveLimit }],
|
queryKey: ['modbus-readings', uuid, { start, end, limit: effectiveLimit }],
|
||||||
queryFn: async () => {
|
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 only when we have valid uuid; start/end may be null (full window).
|
||||||
enabled: !!uuid,
|
enabled: !!uuid,
|
||||||
|
refetchInterval,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for EnergyPage (M5-T06).
|
* Tests for EnergyPage (M5-T06 + M5-polish).
|
||||||
*
|
*
|
||||||
* Coverage:
|
* Coverage:
|
||||||
* 1. Renders device list from GET /api/modbus/devices.
|
* 1. Renders device list from GET /api/modbus/devices.
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
* 6. Delete 409 error shows friendly hint in confirmation modal.
|
* 6. Delete 409 error shows friendly hint in confirmation modal.
|
||||||
* 7. Test-read button calls POST /api/modbus/devices/{uuid}/test and shows result.
|
* 7. Test-read button calls POST /api/modbus/devices/{uuid}/test and shows result.
|
||||||
* 8. Test-read failure shows error in modal.
|
* 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'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
@@ -321,6 +322,7 @@ describe('EnergyPage — test-read', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(screen.getByTestId('test-read-ok')).toBeInTheDocument()
|
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')
|
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')
|
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,
|
Paper,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Divider,
|
Divider,
|
||||||
|
Switch,
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
|
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
|
||||||
import { DeviceForm } from '../energy/DeviceForm'
|
import { DeviceForm } from '../energy/DeviceForm'
|
||||||
import { EnergyCharts } from '../energy/EnergyCharts'
|
import { EnergyCharts } from '../energy/EnergyCharts'
|
||||||
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'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Delete confirmation modal (with 409 "disable instead" hint)
|
// Delete confirmation modal (with 409 "disable instead" hint)
|
||||||
@@ -99,10 +101,11 @@ function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error
|
|||||||
interface TestResultModalProps {
|
interface TestResultModalProps {
|
||||||
result: ModbusTestReadResponse
|
result: ModbusTestReadResponse
|
||||||
deviceName: string
|
deviceName: string
|
||||||
|
metrics?: MetricInfo[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function TestResultModal({ result, deviceName, onClose }: TestResultModalProps) {
|
function TestResultModal({ result, deviceName, metrics, onClose }: TestResultModalProps) {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened
|
opened
|
||||||
@@ -114,9 +117,33 @@ function TestResultModal({ result, deviceName, onClose }: TestResultModalProps)
|
|||||||
{result.ok ? (
|
{result.ok ? (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Badge color="green" data-testid="test-read-ok">Success</Badge>
|
<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">
|
<Code block data-testid="test-read-payload">
|
||||||
{JSON.stringify(result.payload, null, 2)}
|
{JSON.stringify(result.payload, null, 2)}
|
||||||
</Code>
|
</Code>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
@@ -147,6 +174,7 @@ interface TestReadButtonProps {
|
|||||||
|
|
||||||
function TestReadButton({ device }: TestReadButtonProps) {
|
function TestReadButton({ device }: TestReadButtonProps) {
|
||||||
const testMutation = useTestReadDevice()
|
const testMutation = useTestReadDevice()
|
||||||
|
const metricsQuery = useMetrics(device.uuid)
|
||||||
const [result, setResult] = useState<ModbusTestReadResponse | null>(null)
|
const [result, setResult] = useState<ModbusTestReadResponse | null>(null)
|
||||||
const [testError, setTestError] = useState<string | null>(null)
|
const [testError, setTestError] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -209,6 +237,7 @@ function TestReadButton({ device }: TestReadButtonProps) {
|
|||||||
<TestResultModal
|
<TestResultModal
|
||||||
result={result}
|
result={result}
|
||||||
deviceName={device.friendly_name}
|
deviceName={device.friendly_name}
|
||||||
|
metrics={metricsQuery.data?.metrics}
|
||||||
onClose={() => setResult(null)}
|
onClose={() => setResult(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -223,10 +252,11 @@ function TestReadButton({ device }: TestReadButtonProps) {
|
|||||||
|
|
||||||
interface LatestReadingsCardProps {
|
interface LatestReadingsCardProps {
|
||||||
device: ModbusDevice
|
device: ModbusDevice
|
||||||
|
autoRefresh?: { refetchIntervalMs: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
function LatestReadingsCard({ device }: LatestReadingsCardProps) {
|
function LatestReadingsCard({ device, autoRefresh }: LatestReadingsCardProps) {
|
||||||
const latestQuery = useLatestReading(device.uuid)
|
const latestQuery = useLatestReading(device.uuid, autoRefresh)
|
||||||
const metricsQuery = useMetrics(device.uuid)
|
const metricsQuery = useMetrics(device.uuid)
|
||||||
|
|
||||||
const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? []
|
const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? []
|
||||||
@@ -282,12 +312,8 @@ function LatestReadingsCard({ device }: LatestReadingsCardProps) {
|
|||||||
<SimpleGrid cols={2} spacing="xs">
|
<SimpleGrid cols={2} spacing="xs">
|
||||||
{metrics.map((m) => {
|
{metrics.map((m) => {
|
||||||
const raw = payload[m.key]
|
const raw = payload[m.key]
|
||||||
const hasValue = raw !== null && raw !== undefined
|
const displayValue = formatMetricValue(raw, m)
|
||||||
const displayValue = hasValue
|
const hasValue = displayValue !== '—'
|
||||||
? typeof raw === 'number'
|
|
||||||
? raw.toFixed(raw % 1 === 0 ? 0 : 2)
|
|
||||||
: String(raw)
|
|
||||||
: '—'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group key={m.key} justify="space-between" align="baseline" gap={4}
|
<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)
|
// 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 {
|
interface DeviceReadingsSectionProps {
|
||||||
devices: ModbusDevice[]
|
devices: ModbusDevice[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeviceReadingsSection({ devices }: DeviceReadingsSectionProps) {
|
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
|
if (devices.length === 0) return null
|
||||||
|
|
||||||
|
const autoRefresh = autoRefreshEnabled
|
||||||
|
? { refetchIntervalMs: AUTO_REFRESH_INTERVAL_MS }
|
||||||
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xl" data-testid="device-readings-section">
|
<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) => (
|
{devices.map((device) => (
|
||||||
<Stack key={device.uuid} gap="sm" data-testid={`device-readings-${device.uuid}`}>
|
<Stack key={device.uuid} gap="sm" data-testid={`device-readings-${device.uuid}`}>
|
||||||
<Text fw={500} size="sm">
|
<Text fw={500} size="sm">
|
||||||
{device.friendly_name}
|
{device.friendly_name}
|
||||||
</Text>
|
</Text>
|
||||||
<LatestReadingsCard device={device} />
|
<LatestReadingsCard device={device} autoRefresh={autoRefresh} />
|
||||||
<EnergyCharts uuid={device.uuid} deviceName={device.friendly_name} />
|
<EnergyCharts
|
||||||
|
uuid={device.uuid}
|
||||||
|
deviceName={device.friendly_name}
|
||||||
|
autoRefresh={autoRefresh}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
Reference in New Issue
Block a user