diff --git a/apps/web/src/components/dashboard/widget-config-dialog.test.tsx b/apps/web/src/components/dashboard/widget-config-dialog.test.tsx index da4dc86e..e46ac1a2 100644 --- a/apps/web/src/components/dashboard/widget-config-dialog.test.tsx +++ b/apps/web/src/components/dashboard/widget-config-dialog.test.tsx @@ -24,7 +24,12 @@ const translations: Record = { 'common.metrics.health': 'Health', 'common.metrics.cpu': 'CPU', 'common.metrics.memory': 'Memory', + 'widgets.common.placeholders.selectServer': 'Select server', + 'widgets.common.empty.noServers': 'No servers', + 'common.timeRange.realtime': 'Realtime', 'common.timeRange.1hour': '1 hour', + 'common.timeRange.6hours': '6 hours', + 'common.timeRange.7days': '7 days', 'common.timeRange.24hours': '24 hours', 'common.timeRange.30days': '30 days', 'common.timeRange.60days': '60 days', @@ -291,6 +296,53 @@ describe('WidgetConfigDialog', () => { expect(screen.getByText('90 days')).toBeInTheDocument() }) + it('renders server + range (with realtime) for network-latency widget', () => { + render( + + ) + + expect(screen.getByText('Server')).toBeInTheDocument() + expect(screen.getByText('Time Range')).toBeInTheDocument() + expect(screen.getByText('Realtime')).toBeInTheDocument() + expect(screen.getByText('Server 1')).toBeInTheDocument() + }) + + it('renders a server select for network-quality widget', () => { + render( + + ) + + expect(screen.getByText('Server')).toBeInTheDocument() + expect(screen.getByText('Server 1')).toBeInTheDocument() + }) + + it('renders a server multi-select for network-overview widget', () => { + render( + + ) + + expect(screen.getByText('Servers')).toBeInTheDocument() + expect(screen.getByText('Server 1')).toBeInTheDocument() + }) + describe('module widgets', () => { const moduleId = 'com.test.cfg-dialog' const fakeManifest: WidgetManifest = { diff --git a/apps/web/src/components/dashboard/widget-config-dialog.tsx b/apps/web/src/components/dashboard/widget-config-dialog.tsx index 0123113a..e65dc6f9 100644 --- a/apps/web/src/components/dashboard/widget-config-dialog.tsx +++ b/apps/web/src/components/dashboard/widget-config-dialog.tsx @@ -20,6 +20,9 @@ import type { MetricCardConfig, MetricCardMetric, MultiLineConfig, + NetworkLatencyConfig, + NetworkOverviewConfig, + NetworkQualityConfig, ServerCardsConfig, StatNumberConfig, TopNConfig, @@ -101,6 +104,16 @@ function useRangeOptions(t: (key: string) => string): { label: string; value: st ] } +function useNetworkRangeOptions(t: (key: string) => string): { label: string; value: string }[] { + return [ + { label: t('common.timeRange.realtime'), value: '0' }, + { label: t('common.timeRange.1hour'), value: '1' }, + { label: t('common.timeRange.6hours'), value: '6' }, + { label: t('common.timeRange.24hours'), value: '24' }, + { label: t('common.timeRange.7days'), value: '168' } + ] +} + function parseExistingConfig(widget?: DashboardWidget): WidgetConfig | null { if (!widget) { return null @@ -709,6 +722,94 @@ function UptimeTimelineForm({ ) } +function NetworkLatencyForm({ + config, + servers, + onChange, + t +}: { + config: Partial + onChange: (c: Partial) => void + servers: ServerMetrics[] + t: (key: string) => string +}) { + const NETWORK_RANGE_OPTIONS = useNetworkRangeOptions(t) + return ( + <> + onChange({ ...config, server_id: v })} + placeholder={t('widgets.common.placeholders.selectServer')} + servers={servers} + value={config.server_id ?? ''} + /> +
+ + +
+ + ) +} + +function NetworkQualityForm({ + config, + servers, + onChange, + t +}: { + config: Partial + onChange: (c: Partial) => void + servers: ServerMetrics[] + t: (key: string) => string +}) { + return ( + onChange({ ...config, server_id: v })} + placeholder={t('widgets.common.placeholders.selectServer')} + servers={servers} + value={config.server_id ?? ''} + /> + ) +} + +function NetworkOverviewForm({ + config, + servers, + onChange, + t +}: { + config: Partial + onChange: (c: Partial) => void + servers: ServerMetrics[] + t: (key: string) => string +}) { + return ( + onChange({ ...config, server_ids: ids })} + selected={config.server_ids ?? []} + servers={servers} + /> + ) +} + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: dispatcher renders one form per widget_type; refactoring to a table is more work than the value export function WidgetConfigDialog({ open, @@ -813,6 +914,30 @@ export function WidgetConfigDialog({ t={t} /> )} + {widgetType === 'network-latency' && ( + } + onChange={setConfig} + servers={servers} + t={t} + /> + )} + {widgetType === 'network-quality' && ( + } + onChange={setConfig} + servers={servers} + t={t} + /> + )} + {widgetType === 'network-overview' && ( + } + onChange={setConfig} + servers={servers} + t={t} + /> + )} {isModule && moduleEntry && } {moduleMissing && (

diff --git a/apps/web/src/components/dashboard/widget-picker.tsx b/apps/web/src/components/dashboard/widget-picker.tsx index 457630e1..8f7e0020 100644 --- a/apps/web/src/components/dashboard/widget-picker.tsx +++ b/apps/web/src/components/dashboard/widget-picker.tsx @@ -46,7 +46,10 @@ const WIDGET_ICONS: Record = { 'disk-io': HardDrive, 'server-map': Globe, markdown: FileText, - 'uptime-timeline': Activity + 'uptime-timeline': Activity, + 'network-latency': LineChart, + 'network-quality': Gauge, + 'network-overview': Network } const CATEGORY_ORDER: WidgetCategory[] = ['Real-time', 'Charts', 'Status'] diff --git a/apps/web/src/components/dashboard/widget-render-dependencies.ts b/apps/web/src/components/dashboard/widget-render-dependencies.ts index eee72d46..fc5c49a9 100644 --- a/apps/web/src/components/dashboard/widget-render-dependencies.ts +++ b/apps/web/src/components/dashboard/widget-render-dependencies.ts @@ -62,6 +62,11 @@ function getWidgetServerScope(widget: DashboardWidget): ServerScope { return selectedServerScope(config.server_ids, 'map') case 'uptime-timeline': return configuredServerScope(config.server_ids, 'name') + case 'network-latency': + case 'network-quality': + return singleServerScope(config.server_id, 'name') + case 'network-overview': + return selectedServerScope(config.server_ids, 'name') case 'markdown': case 'service-status': return { mode: 'none' } diff --git a/apps/web/src/components/dashboard/widget-renderer.tsx b/apps/web/src/components/dashboard/widget-renderer.tsx index 7909f084..9bd996bd 100644 --- a/apps/web/src/components/dashboard/widget-renderer.tsx +++ b/apps/web/src/components/dashboard/widget-renderer.tsx @@ -10,6 +10,9 @@ import type { MarkdownConfig, MetricCardConfig, MultiLineConfig, + NetworkLatencyConfig, + NetworkOverviewConfig, + NetworkQualityConfig, ServerCardsConfig, ServerMapConfig, ServiceStatusConfig, @@ -27,6 +30,9 @@ import { LineChartWidget } from './widgets/line-chart-widget' import { MarkdownWidget } from './widgets/markdown' import { MetricCardWidget } from './widgets/metric-card' import { MultiLineWidget } from './widgets/multi-line' +import { NetworkLatencyWidget } from './widgets/network-latency-widget' +import { NetworkOverviewWidget } from './widgets/network-overview-widget' +import { NetworkQualityWidget } from './widgets/network-quality' import { ServerCardsWidget } from './widgets/server-cards' import { ServerMapWidget } from './widgets/server-map' import { ServiceStatusWidget } from './widgets/service-status' @@ -119,6 +125,12 @@ function WidgetContent({ widget, servers }: WidgetRendererProps) { return case 'uptime-timeline': return + case 'network-quality': + return + case 'network-latency': + return + case 'network-overview': + return default: return (

diff --git a/apps/web/src/components/dashboard/widgets/network-latency-widget.test.tsx b/apps/web/src/components/dashboard/widgets/network-latency-widget.test.tsx new file mode 100644 index 00000000..04126143 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/network-latency-widget.test.tsx @@ -0,0 +1,107 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { NetworkProbeRecord, NetworkServerSummary } from '@/lib/network-types' +import { NetworkLatencyWidget } from './network-latency-widget' + +const NO_DATA_RE = /no network probe data/i + +const recordsMock = vi.fn<() => { records: NetworkProbeRecord[]; isLoading: boolean }>() +const summaryMock = vi.fn<() => { data: NetworkServerSummary | undefined; isLoading: boolean }>() + +vi.mock('@/hooks/use-network-chart-records', () => ({ + useNetworkChartRecords: () => recordsMock() +})) + +vi.mock('@/hooks/use-network-api', () => ({ + useNetworkServerSummary: () => summaryMock() +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (_k: string, fallback?: string) => fallback ?? _k }) +})) + +// LatencyChart is exercised in its own context; stub it so this test focuses on the widget shell. +vi.mock('@/components/network/latency-chart', () => ({ + LatencyChart: ({ records, embedded }: { records: NetworkProbeRecord[]; embedded?: boolean }) => ( +
+ {records.length} points +
+ ) +})) + +const summary: NetworkServerSummary = { + server_id: 'srv-1', + server_name: 'Server 1', + online: true, + last_probe_at: null, + anomaly_count: 0, + latency_sparkline: [], + loss_sparkline: [], + targets: [ + { + target_id: 't-1', + target_name: 'China Telecom', + provider: 'ct', + avg_latency: 20, + min_latency: 18, + max_latency: 25, + packet_loss: 0, + availability: 100 + } + ] +} + +const sampleRecords: NetworkProbeRecord[] = [ + { + id: 1, + server_id: 'srv-1', + target_id: 't-1', + timestamp: '2026-05-29T10:00:00.000Z', + avg_latency: 20, + min_latency: 18, + max_latency: 25, + packet_loss: 0, + packet_sent: 10, + packet_received: 10 + } +] + +describe('NetworkLatencyWidget', () => { + it('renders the latency chart with merged records', () => { + summaryMock.mockReturnValue({ data: summary, isLoading: false }) + recordsMock.mockReturnValue({ records: sampleRecords, isLoading: false }) + render() + expect(screen.getByTestId('latency-chart')).toHaveTextContent('1 points') + }) + + it('renders the chart in embedded mode so it fills the widget cell without nested card chrome', () => { + summaryMock.mockReturnValue({ data: summary, isLoading: false }) + recordsMock.mockReturnValue({ records: sampleRecords, isLoading: false }) + render() + expect(screen.getByTestId('latency-chart')).toHaveAttribute('data-embedded', 'true') + }) + + it('renders a skeleton while records are loading instead of flashing the empty state', () => { + summaryMock.mockReturnValue({ data: summary, isLoading: false }) + recordsMock.mockReturnValue({ records: [], isLoading: true }) + const { container } = render() + expect(container.querySelector('[data-slot="skeleton"]')).toBeInTheDocument() + expect(screen.queryByText(NO_DATA_RE)).not.toBeInTheDocument() + expect(screen.queryByTestId('latency-chart')).not.toBeInTheDocument() + }) + + it('renders a skeleton while the summary (chart targets) is still loading', () => { + summaryMock.mockReturnValue({ data: undefined, isLoading: true }) + recordsMock.mockReturnValue({ records: sampleRecords, isLoading: false }) + const { container } = render() + expect(container.querySelector('[data-slot="skeleton"]')).toBeInTheDocument() + expect(screen.queryByTestId('latency-chart')).not.toBeInTheDocument() + }) + + it('renders empty state when there are no records', () => { + summaryMock.mockReturnValue({ data: summary, isLoading: false }) + recordsMock.mockReturnValue({ records: [], isLoading: false }) + render() + expect(screen.getByText(NO_DATA_RE)).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/dashboard/widgets/network-latency-widget.tsx b/apps/web/src/components/dashboard/widgets/network-latency-widget.tsx new file mode 100644 index 00000000..674a9a88 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/network-latency-widget.tsx @@ -0,0 +1,75 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { LatencyChart } from '@/components/network/latency-chart' +import { Skeleton } from '@/components/ui/skeleton' +import { useNetworkServerSummary } from '@/hooks/use-network-api' +import { useNetworkChartRecords } from '@/hooks/use-network-chart-records' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { CHART_COLORS } from '@/lib/chart-colors' +import type { NetworkLatencyConfig } from '@/lib/widget-types' + +interface NetworkLatencyWidgetProps { + config: NetworkLatencyConfig + servers: ServerMetrics[] +} + +export function NetworkLatencyWidget({ config }: NetworkLatencyWidgetProps) { + const { t } = useTranslation('dashboard') + const serverId = config.server_id ?? '' + const hours = config.hours ?? 24 + const isRealtime = hours === 0 + + const { records, isLoading: recordsLoading } = useNetworkChartRecords(serverId, hours) + const { data: summary, isLoading: summaryLoading } = useNetworkServerSummary(serverId) + + const chartTargets = useMemo( + () => + (summary?.targets ?? []).map((target, i) => ({ + id: target.target_id, + name: target.target_name, + color: CHART_COLORS[i % CHART_COLORS.length], + visible: true + })), + [summary] + ) + + // Wait for both the records and the summary (which supplies chart targets) so + // we render a skeleton instead of flashing the empty state or an axis-only chart. + if (recordsLoading || summaryLoading) { + return ( +
+ + +
+ ) + } + + if (records.length === 0) { + return ( +
+

{t('widgets.networkLatency.title', 'Network Latency')}

+
+ {t('widgets.networkLatency.empty.noData', 'No network probe data available')} +
+
+ ) + } + + return ( +
+
+

{t('widgets.networkLatency.title', 'Network Latency')}

+

{summary?.server_name}

+
+
+ +
+
+ ) +} diff --git a/apps/web/src/components/dashboard/widgets/network-overview-widget.test.tsx b/apps/web/src/components/dashboard/widgets/network-overview-widget.test.tsx new file mode 100644 index 00000000..620f9aab --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/network-overview-widget.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { NetworkServerSummary } from '@/lib/network-types' +import { NetworkOverviewWidget } from './network-overview-widget' + +const NO_DATA_RE = /no network probe data/i + +const overviewMock = vi.fn<() => { data: NetworkServerSummary[]; isLoading: boolean }>() + +vi.mock('@/hooks/use-network-api', () => ({ + useNetworkOverview: () => overviewMock() +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (_k: string, fallback?: string) => fallback ?? _k }) +})) + +vi.mock('@/components/ui/scroll-area', () => ({ + ScrollArea: ({ children }: { children?: ReactNode }) =>
{children}
+})) + +// Render TanStack Router Link as a plain anchor so the widget can be tested in isolation. +vi.mock('@tanstack/react-router', () => ({ + Link: ({ children, to, params }: { children?: ReactNode; to?: string; params?: Record }) => ( + {children} + ) +})) + +const summaries: NetworkServerSummary[] = [ + { + server_id: 'srv-1', + server_name: 'Server 1', + online: true, + last_probe_at: null, + anomaly_count: 2, + latency_sparkline: [10, 12], + loss_sparkline: [0, 0], + targets: [ + { + target_id: 't-1', + target_name: 'CT', + provider: 'ct', + avg_latency: 20, + min_latency: 18, + max_latency: 25, + packet_loss: 0.012, + availability: 99 + } + ] + }, + { + server_id: 'srv-2', + server_name: 'Server 2', + online: false, + last_probe_at: null, + anomaly_count: 0, + latency_sparkline: [], + loss_sparkline: [], + targets: [] + } +] + +describe('NetworkOverviewWidget', () => { + it('renders one row per server with a link to the network detail page', () => { + overviewMock.mockReturnValue({ data: summaries, isLoading: false }) + render() + expect(screen.getByText('Server 1')).toBeInTheDocument() + expect(screen.getByText('Server 2')).toBeInTheDocument() + const link = screen.getByText('Server 1').closest('a') + expect(link).toHaveAttribute('href', '/network/$serverId/srv-1') + }) + + it('filters to configured server_ids', () => { + overviewMock.mockReturnValue({ data: summaries, isLoading: false }) + render() + expect(screen.queryByText('Server 1')).not.toBeInTheDocument() + expect(screen.getByText('Server 2')).toBeInTheDocument() + }) + + it('renders empty state when there is no data', () => { + overviewMock.mockReturnValue({ data: [], isLoading: false }) + render() + expect(screen.getByText(NO_DATA_RE)).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/dashboard/widgets/network-overview-widget.tsx b/apps/web/src/components/dashboard/widgets/network-overview-widget.tsx new file mode 100644 index 00000000..461f3dac --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/network-overview-widget.tsx @@ -0,0 +1,92 @@ +import { Link } from '@tanstack/react-router' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Skeleton } from '@/components/ui/skeleton' +import { useNetworkOverview } from '@/hooks/use-network-api' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { formatLatency, type NetworkServerSummary } from '@/lib/network-types' +import type { NetworkOverviewConfig } from '@/lib/widget-types' + +interface NetworkOverviewWidgetProps { + config: NetworkOverviewConfig + servers: ServerMetrics[] +} + +// Average latency across a server's targets, ignoring targets with no reading. +function avgLatency(summary: NetworkServerSummary): number | null { + const values = summary.targets.map((target) => target.avg_latency).filter((v): v is number => v != null) + if (values.length === 0) { + return null + } + return values.reduce((a, b) => a + b, 0) / values.length +} + +export function NetworkOverviewWidget({ config }: NetworkOverviewWidgetProps) { + const { t } = useTranslation('dashboard') + const { data: overview = [], isLoading } = useNetworkOverview() + + const rows = useMemo(() => { + const ids = config.server_ids + if (!ids || ids.length === 0) { + return overview + } + const allow = new Set(ids) + return overview.filter((summary) => allow.has(summary.server_id)) + }, [overview, config.server_ids]) + + if (isLoading) { + return ( +
+ + +
+ ) + } + + if (rows.length === 0) { + return ( +
+

{t('widgets.networkOverview.title', 'Network Overview')}

+
+ {t('widgets.networkOverview.empty.noData', 'No network probe data available')} +
+
+ ) + } + + return ( +
+

{t('widgets.networkOverview.title', 'Network Overview')}

+ +
    + {rows.map((summary) => { + const latency = avgLatency(summary) + return ( +
  • + +
  • + ) + })} +
+
+
+ ) +} diff --git a/apps/web/src/components/dashboard/widgets/network-quality.test.tsx b/apps/web/src/components/dashboard/widgets/network-quality.test.tsx new file mode 100644 index 00000000..01ab9dc4 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/network-quality.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { NetworkServerSummary } from '@/lib/network-types' +import { NetworkQualityWidget } from './network-quality' + +const NO_DATA_RE = /no network probe data/i + +const summaryMock = vi.fn<() => { data: NetworkServerSummary | undefined; isLoading: boolean }>() + +vi.mock('@/hooks/use-network-api', () => ({ + useNetworkServerSummary: () => summaryMock() +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (_k: string, fallback?: string) => fallback ?? _k }) +})) + +vi.mock('@/components/ui/scroll-area', () => ({ + ScrollArea: ({ children }: { children?: ReactNode }) =>
{children}
+})) + +const baseSummary: NetworkServerSummary = { + server_id: 'srv-1', + server_name: 'Server 1', + online: true, + last_probe_at: '2026-05-29T10:00:00.000Z', + anomaly_count: 0, + latency_sparkline: [], + loss_sparkline: [], + targets: [ + { + target_id: 't-1', + target_name: 'China Telecom', + provider: 'ct', + avg_latency: 23.1, + min_latency: 20, + max_latency: 30, + packet_loss: 0, + availability: 100 + }, + { + target_id: 't-2', + target_name: 'International', + provider: 'international', + avg_latency: 142.3, + min_latency: 130, + max_latency: 160, + packet_loss: 0.015, + availability: 98 + } + ] +} + +describe('NetworkQualityWidget', () => { + it('renders each target with latency and packet loss', () => { + summaryMock.mockReturnValue({ data: baseSummary, isLoading: false }) + render() + expect(screen.getByText('China Telecom')).toBeInTheDocument() + expect(screen.getByText('International')).toBeInTheDocument() + expect(screen.getByText('23.1 ms')).toBeInTheDocument() + }) + + it('renders empty state when there are no targets', () => { + summaryMock.mockReturnValue({ data: { ...baseSummary, targets: [] }, isLoading: false }) + render() + expect(screen.getByText(NO_DATA_RE)).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/dashboard/widgets/network-quality.tsx b/apps/web/src/components/dashboard/widgets/network-quality.tsx new file mode 100644 index 00000000..93b7e175 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/network-quality.tsx @@ -0,0 +1,68 @@ +import { useTranslation } from 'react-i18next' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Skeleton } from '@/components/ui/skeleton' +import { useNetworkServerSummary } from '@/hooks/use-network-api' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { CHART_COLORS } from '@/lib/chart-colors' +import { formatLatency, formatPacketLoss, getLossTextClassName } from '@/lib/network-types' +import type { NetworkQualityConfig } from '@/lib/widget-types' + +interface NetworkQualityWidgetProps { + config: NetworkQualityConfig + servers: ServerMetrics[] +} + +export function NetworkQualityWidget({ config }: NetworkQualityWidgetProps) { + const { t } = useTranslation('dashboard') + const serverId = config.server_id ?? '' + const { data: summary, isLoading } = useNetworkServerSummary(serverId) + + if (isLoading) { + return ( +
+ + +
+ ) + } + + const targets = summary?.targets ?? [] + + if (targets.length === 0) { + return ( +
+

{t('widgets.networkQuality.title', 'Network Quality')}

+
+ {t('widgets.networkQuality.empty.noData', 'No network probe data available')} +
+
+ ) + } + + return ( +
+
+

{t('widgets.networkQuality.title', 'Network Quality')}

+

{summary?.server_name}

+
+ +
    + {targets.map((target, i) => ( +
  • +
  • + ))} +
+
+
+ ) +} diff --git a/apps/web/src/components/network/latency-chart.tsx b/apps/web/src/components/network/latency-chart.tsx index c0fc94e1..9c181257 100644 --- a/apps/web/src/components/network/latency-chart.tsx +++ b/apps/web/src/components/network/latency-chart.tsx @@ -13,6 +13,10 @@ interface TargetInfo { } interface LatencyChartProps { + // When embedded in a dashboard widget, drop the standalone card chrome and + // title and fill the parent height instead of using a fixed height. The host + // widget already provides the card, header, and a sized flex container. + embedded?: boolean hours?: number isRealtime?: boolean records: NetworkProbeRecord[] @@ -42,7 +46,7 @@ function formatDateTimeMDHM(timestamp: string): string { return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` } -export function LatencyChart({ records, targets, isRealtime = false, hours = 1 }: LatencyChartProps) { +export function LatencyChart({ records, targets, isRealtime = false, hours = 1, embedded = false }: LatencyChartProps) { const { t } = useTranslation('network') // Build chartConfig for ALL targets (ChartContainer needs all color vars injected) const chartConfig = useMemo(() => { @@ -132,6 +136,13 @@ export function LatencyChart({ records, targets, isRealtime = false, hours = 1 } }, [isExtendedRange]) if (chartData.length === 0) { + if (embedded) { + return ( +
+

{t('latency_chart_no_data')}

+
+ ) + } return (

{t('latency_chart_no_data')}

@@ -139,42 +150,47 @@ export function LatencyChart({ records, targets, isRealtime = false, hours = 1 } ) } + const chart = ( + + + + + + `${v.toFixed(1)} ms`} /> + } + /> + {visibleWithIndex.map(({ id, originalIndex }) => ( + + ))} + + + ) + + if (embedded) { + return chart + } + return (

{t('latency_title')}

- - - - - - `${v.toFixed(1)} ms`} - /> - } - /> - {visibleWithIndex.map(({ id, originalIndex }) => ( - - ))} - - + {chart}
) } diff --git a/apps/web/src/hooks/use-network-chart-records.ts b/apps/web/src/hooks/use-network-chart-records.ts new file mode 100644 index 00000000..d91e420d --- /dev/null +++ b/apps/web/src/hooks/use-network-chart-records.ts @@ -0,0 +1,38 @@ +import { useMemo } from 'react' +import { useNetworkRecords } from '@/hooks/use-network-api' +import { useNetworkRealtime } from '@/hooks/use-network-realtime' +import { mergeNetworkChartRecords } from '@/lib/network-chart-records' +import type { NetworkProbeRecord } from '@/lib/network-types' + +interface NetworkChartRecords { + // True while the query backing the current range is still loading its first + // page, so callers can show a skeleton instead of flashing an empty state. + isLoading: boolean + records: NetworkProbeRecord[] +} + +// `hours === 0` means realtime. Returns a record series ready for LatencyChart, +// combining historical OR (seed + live) data depending on the range, plus the +// loading state of whichever query is active for the current range. +export function useNetworkChartRecords(serverId: string, hours: number): NetworkChartRecords { + const isRealtime = hours === 0 + const historicalQuery = useNetworkRecords(serverId, hours, { enabled: !isRealtime && serverId.length > 0 }) + const seedQuery = useNetworkRecords(serverId, 1, { enabled: isRealtime && serverId.length > 0 }) + const { data: realtime } = useNetworkRealtime(serverId) + + const records = useMemo( + () => + mergeNetworkChartRecords({ + historical: historicalQuery.data ?? [], + isRealtime, + realtime, + seed: seedQuery.data ?? [], + serverId + }), + [historicalQuery.data, isRealtime, realtime, seedQuery.data, serverId] + ) + + const isLoading = isRealtime ? seedQuery.isLoading : historicalQuery.isLoading + + return { isLoading, records } +} diff --git a/apps/web/src/lib/network-chart-records.test.ts b/apps/web/src/lib/network-chart-records.test.ts new file mode 100644 index 00000000..7370b0ca --- /dev/null +++ b/apps/web/src/lib/network-chart-records.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { mergeNetworkChartRecords } from './network-chart-records' +import type { NetworkProbeRecord, NetworkProbeResultData } from './network-types' + +const seed: NetworkProbeRecord[] = [ + { + id: 1, + server_id: 'srv-1', + target_id: 't-1', + timestamp: '2026-05-29T10:00:00.000Z', + avg_latency: 20, + min_latency: 18, + max_latency: 25, + packet_loss: 0, + packet_sent: 10, + packet_received: 10 + } +] + +const realtime: Record = { + 't-1': [ + { + target_id: 't-1', + timestamp: '2026-05-29T10:01:00.000Z', + avg_latency: 22, + min_latency: 19, + max_latency: 28, + packet_loss: 0, + packet_sent: 10, + packet_received: 10 + } + ] +} + +describe('mergeNetworkChartRecords', () => { + it('returns historical records unchanged when not realtime', () => { + const result = mergeNetworkChartRecords({ + isRealtime: false, + historical: seed, + seed: [], + realtime: {}, + serverId: 'srv-1' + }) + expect(result).toEqual(seed) + }) + + it('flattens realtime map and merges with seed in realtime mode', () => { + const result = mergeNetworkChartRecords({ isRealtime: true, historical: [], seed, realtime, serverId: 'srv-1' }) + expect(result).toHaveLength(2) + expect(result.map((r) => r.timestamp)).toEqual(['2026-05-29T10:00:00.000Z', '2026-05-29T10:01:00.000Z']) + }) + + it('dedupes by target_id + timestamp keeping the latest entry', () => { + const dupRealtime: Record = { + 't-1': [ + { + target_id: 't-1', + timestamp: '2026-05-29T10:00:00.000Z', + avg_latency: 99, + min_latency: 99, + max_latency: 99, + packet_loss: 0, + packet_sent: 10, + packet_received: 10 + } + ] + } + const result = mergeNetworkChartRecords({ + isRealtime: true, + historical: [], + seed, + realtime: dupRealtime, + serverId: 'srv-1' + }) + expect(result).toHaveLength(1) + expect(result[0].avg_latency).toBe(99) + }) +}) diff --git a/apps/web/src/lib/network-chart-records.ts b/apps/web/src/lib/network-chart-records.ts new file mode 100644 index 00000000..15d48af3 --- /dev/null +++ b/apps/web/src/lib/network-chart-records.ts @@ -0,0 +1,57 @@ +import type { NetworkProbeRecord, NetworkProbeResultData } from './network-types' + +interface MergeArgs { + historical: NetworkProbeRecord[] + isRealtime: boolean + realtime: Record + seed: NetworkProbeRecord[] + serverId: string +} + +// Combine the 1h "seed" snapshot with live realtime points in realtime mode, or +// return historical records as-is otherwise. Realtime points override seed points +// at the same (target_id, timestamp) bucket. Mirrors the logic previously inlined +// in the network detail page. +export function mergeNetworkChartRecords({ + historical, + isRealtime, + realtime, + seed, + serverId +}: MergeArgs): NetworkProbeRecord[] { + if (!isRealtime) { + return historical + } + + const realtimeFlat: NetworkProbeRecord[] = [] + for (const [targetId, points] of Object.entries(realtime)) { + for (const point of points) { + realtimeFlat.push({ + id: 0, + server_id: serverId, + target_id: targetId, + timestamp: point.timestamp, + avg_latency: point.avg_latency, + min_latency: point.min_latency, + max_latency: point.max_latency, + packet_loss: point.packet_loss, + packet_sent: point.packet_sent, + packet_received: point.packet_received + }) + } + } + + const merged = [...seed, ...realtimeFlat] + const seen = new Set() + const deduped: NetworkProbeRecord[] = [] + for (let i = merged.length - 1; i >= 0; i--) { + const r = merged[i] + const key = `${r.target_id}:${r.timestamp}` + if (!seen.has(key)) { + seen.add(key) + deduped.push(r) + } + } + deduped.reverse() + return deduped +} diff --git a/apps/web/src/lib/widget-types.ts b/apps/web/src/lib/widget-types.ts index c720df04..8be17193 100644 --- a/apps/web/src/lib/widget-types.ts +++ b/apps/web/src/lib/widget-types.ts @@ -183,6 +183,42 @@ export const WIDGET_TYPES = [ maxW: 12, maxH: 6, sizing: { kind: 'free' } + }, + { + id: 'network-latency', + label: 'Network Latency', + category: 'Charts', + defaultW: 6, + defaultH: 4, + minW: 4, + minH: 3, + maxW: 12, + maxH: 8, + sizing: { kind: 'free' } + }, + { + id: 'network-quality', + label: 'Network Quality', + category: 'Real-time', + defaultW: 4, + defaultH: 4, + minW: 3, + minH: 3, + maxW: 8, + maxH: 8, + sizing: { kind: 'free' } + }, + { + id: 'network-overview', + label: 'Network Overview', + category: 'Status', + defaultW: 8, + defaultH: 5, + minW: 4, + minH: 3, + maxW: 12, + maxH: 8, + sizing: { kind: 'free' } } ] as const satisfies readonly WidgetTypeDefinition[] @@ -270,6 +306,19 @@ export interface UptimeTimelineConfig { server_ids: string[] } +export interface NetworkLatencyConfig { + hours?: number // 0 means realtime + server_id: string +} + +export interface NetworkQualityConfig { + server_id: string +} + +export interface NetworkOverviewConfig { + server_ids?: string[] +} + export type WidgetConfig = | StatNumberConfig | MetricCardConfig @@ -285,6 +334,9 @@ export type WidgetConfig = | ServerMapConfig | MarkdownConfig | UptimeTimelineConfig + | NetworkLatencyConfig + | NetworkQualityConfig + | NetworkOverviewConfig // API response types diff --git a/apps/web/src/locales/en/dashboard.json b/apps/web/src/locales/en/dashboard.json index f43f5ae4..04181650 100644 --- a/apps/web/src/locales/en/dashboard.json +++ b/apps/web/src/locales/en/dashboard.json @@ -100,6 +100,18 @@ "uptime-timeline": { "label": "Uptime Timeline", "description": "90-day uptime timeline bar" + }, + "network-latency": { + "label": "Network Latency", + "description": "Latency over time to network probe targets" + }, + "network-quality": { + "label": "Network Quality", + "description": "Current latency and packet loss per target" + }, + "network-overview": { + "label": "Network Overview", + "description": "Network quality across servers" } } }, @@ -187,6 +199,24 @@ "empty": { "noServers": "No servers available" } + }, + "networkLatency": { + "title": "Network Latency", + "empty": { + "noData": "No network probe data available" + } + }, + "networkQuality": { + "title": "Network Quality", + "empty": { + "noData": "No network probe data available" + } + }, + "networkOverview": { + "title": "Network Overview", + "empty": { + "noData": "No network probe data available" + } } }, "common": { @@ -218,7 +248,8 @@ "7days": "7 days", "30days": "30 days", "60days": "60 days", - "90days": "90 days" + "90days": "90 days", + "realtime": "Realtime" } }, "dialogs": { diff --git a/apps/web/src/locales/zh/dashboard.json b/apps/web/src/locales/zh/dashboard.json index d8cf3b51..4c9e7d25 100644 --- a/apps/web/src/locales/zh/dashboard.json +++ b/apps/web/src/locales/zh/dashboard.json @@ -100,6 +100,18 @@ "uptime-timeline": { "label": "可用时间线", "description": "90 天可用性时间线" + }, + "network-latency": { + "label": "网络延迟", + "description": "对探测目标的延迟随时间变化" + }, + "network-quality": { + "label": "网络质量", + "description": "各目标的当前延迟与丢包" + }, + "network-overview": { + "label": "网络总览", + "description": "跨服务器的网络质量" } } }, @@ -187,6 +199,24 @@ "empty": { "noServers": "暂无可用服务器" } + }, + "networkLatency": { + "title": "网络延迟", + "empty": { + "noData": "暂无网络探测数据" + } + }, + "networkQuality": { + "title": "网络质量", + "empty": { + "noData": "暂无网络探测数据" + } + }, + "networkOverview": { + "title": "网络总览", + "empty": { + "noData": "暂无网络探测数据" + } } }, "common": { @@ -218,7 +248,8 @@ "7days": "7 天", "30days": "30 天", "60days": "60 天", - "90days": "90 天" + "90days": "90 天", + "realtime": "实时" } }, "dialogs": { diff --git a/apps/web/src/routes/_authed/network/$serverId.tsx b/apps/web/src/routes/_authed/network/$serverId.tsx index c818c99f..3e0cd81e 100644 --- a/apps/web/src/routes/_authed/network/$serverId.tsx +++ b/apps/web/src/routes/_authed/network/$serverId.tsx @@ -33,7 +33,6 @@ import { useClearTracerouteHistory, useDeleteTraceroute, useNetworkAnomalies, - useNetworkRecords, useNetworkServerSummary, useNetworkTargets, useSetServerTargets, @@ -41,7 +40,7 @@ import { useTracerouteHistory, useTracerouteRecord } from '@/hooks/use-network-api' -import { useNetworkRealtime } from '@/hooks/use-network-realtime' +import { useNetworkChartRecords } from '@/hooks/use-network-chart-records' import { useTracerouteStream } from '@/hooks/use-traceroute-stream' import { CHART_COLORS } from '@/lib/chart-colors' import { @@ -51,7 +50,6 @@ import { getNetworkTargetDisplayProvider } from '@/lib/network-i18n' import type { - NetworkProbeRecord, NetworkProbeTarget, NetworkTargetSummary, TracerouteHop, @@ -646,11 +644,7 @@ export function NetworkDetailPage() { const { data: server, isLoading: serverLoading } = useServer(serverId) const { data: summary, isLoading: summaryLoading } = useNetworkServerSummary(serverId) - const { data: historicalRecords } = useNetworkRecords(serverId, hours, { enabled: !isRealtime }) - // Fetch last 10 min of data as seed for realtime chart (immediate data on first load) - const { data: seedRecords } = useNetworkRecords(serverId, 1, { enabled: isRealtime }) const { data: anomalies = [] } = useNetworkAnomalies(serverId, anomalyHours) - const { data: realtimeData } = useNetworkRealtime(serverId) const { data: allTargets = [] } = useNetworkTargets() const setServerTargets = useSetServerTargets(serverId) const language = i18n.resolvedLanguage ?? i18n.language @@ -725,46 +719,7 @@ export function NetworkDetailPage() { [targets, targetColorMap, effectiveVisible, getSummaryTargetDisplayName] ) - const records: NetworkProbeRecord[] = useMemo(() => { - if (!isRealtime) { - return historicalRecords ?? [] - } - // Transform realtime data map into flat records array - const realtimeFlat: NetworkProbeRecord[] = [] - for (const [targetId, points] of Object.entries(realtimeData)) { - for (const point of points) { - realtimeFlat.push({ - id: 0, - server_id: serverId, - target_id: targetId, - timestamp: point.timestamp, - avg_latency: point.avg_latency, - min_latency: point.min_latency, - max_latency: point.max_latency, - packet_loss: point.packet_loss, - packet_sent: point.packet_sent, - packet_received: point.packet_received - }) - } - } - // Merge seed (historical last 1h) with realtime data for immediate chart display. - // Realtime points override seed points at the same timestamp via the chart's bucketing. - const seed = seedRecords ?? [] - const merged = [...seed, ...realtimeFlat] - // Deduplicate: keep latest entry per (target_id, timestamp_bucket) - const seen = new Set() - const deduped: NetworkProbeRecord[] = [] - for (let i = merged.length - 1; i >= 0; i--) { - const r = merged[i] - const key = `${r.target_id}:${r.timestamp}` - if (!seen.has(key)) { - seen.add(key) - deduped.push(r) - } - } - deduped.reverse() - return deduped - }, [isRealtime, historicalRecords, realtimeData, serverId, seedRecords]) + const { records } = useNetworkChartRecords(serverId, isRealtime ? 0 : hours) // Stats computed from current records const stats = useMemo(() => { diff --git a/crates/server/src/service/dashboard.rs b/crates/server/src/service/dashboard.rs index 286b5061..a8f0d818 100644 --- a/crates/server/src/service/dashboard.rs +++ b/crates/server/src/service/dashboard.rs @@ -21,6 +21,9 @@ const VALID_WIDGET_TYPES: &[&str] = &[ "server-map", "markdown", "uptime-timeline", + "network-latency", + "network-quality", + "network-overview", ]; #[derive(Debug, Deserialize, utoipa::ToSchema)] @@ -990,4 +993,74 @@ mod tests { assert_eq!(list[1].name, "A"); assert_eq!(list[2].name, "B"); } + + #[tokio::test] + async fn test_update_accepts_network_widget_types() { + let (db, _tmp) = setup_db_with_fk().await; + + let dash = DashboardService::create( + &db, + CreateDashboardInput { + name: "Net".to_string(), + }, + ) + .await + .unwrap(); + + // Each network widget type must be accepted by the backend whitelist so + // the dashboard save (PUT /api/dashboards/:id) succeeds from the UI. + let widgets = vec![ + WidgetInput { + id: None, + widget_type: "network-latency".to_string(), + module_id: None, + title: None, + config_json: serde_json::json!({"server_id": "srv-1", "hours": 24}), + grid_x: 0, + grid_y: 0, + grid_w: 6, + grid_h: 4, + sort_order: 0, + }, + WidgetInput { + id: None, + widget_type: "network-quality".to_string(), + module_id: None, + title: None, + config_json: serde_json::json!({"server_id": "srv-1"}), + grid_x: 6, + grid_y: 0, + grid_w: 4, + grid_h: 4, + sort_order: 1, + }, + WidgetInput { + id: None, + widget_type: "network-overview".to_string(), + module_id: None, + title: None, + config_json: serde_json::json!({}), + grid_x: 0, + grid_y: 4, + grid_w: 8, + grid_h: 5, + sort_order: 2, + }, + ]; + + let result = DashboardService::update( + &db, + &dash.id, + UpdateDashboardInput { + name: None, + is_default: None, + sort_order: None, + widgets: Some(widgets), + }, + ) + .await + .expect("saving network widgets should succeed"); + + assert_eq!(result.widgets.len(), 3); + } } diff --git a/docs/superpowers/plans/2026-05-29-network-quality-widgets.md b/docs/superpowers/plans/2026-05-29-network-quality-widgets.md new file mode 100644 index 00000000..c845656b --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-network-quality-widgets.md @@ -0,0 +1,1323 @@ +# Network Quality Dashboard Widgets Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add three built-in dashboard widgets — `network-latency`, `network-quality`, `network-overview` — that surface the existing network-probe (tri-network ping) data. + +**Architecture:** Frontend-only. Three independent built-in widgets follow the existing 13-widget convention (one widget = one visual form). A shared `useNetworkChartRecords` hook is extracted from the network detail page so the latency widget and the detail page reuse the same records+realtime merge logic. No backend, protocol, or migration changes. + +**Tech Stack:** React 19, TanStack Query, Recharts, react-i18next, Vitest + Testing Library, Biome/Ultracite. + +--- + +## File Structure + +**Create:** +- `apps/web/src/lib/network-chart-records.ts` — pure merge/dedupe function `mergeNetworkChartRecords` +- `apps/web/src/lib/network-chart-records.test.ts` — unit tests for the pure function +- `apps/web/src/hooks/use-network-chart-records.ts` — hook wrapping the pure function + data hooks +- `apps/web/src/components/dashboard/widgets/network-quality.tsx` — single-server summary card +- `apps/web/src/components/dashboard/widgets/network-quality.test.tsx` +- `apps/web/src/components/dashboard/widgets/network-latency-widget.tsx` — single-server latency chart +- `apps/web/src/components/dashboard/widgets/network-latency-widget.test.tsx` +- `apps/web/src/components/dashboard/widgets/network-overview-widget.tsx` — multi-server table +- `apps/web/src/components/dashboard/widgets/network-overview-widget.test.tsx` + +**Modify:** +- `apps/web/src/lib/widget-types.ts` — 3 `WIDGET_TYPES` entries, 3 config interfaces, union extension +- `apps/web/src/components/dashboard/widget-render-dependencies.ts` — 3 scope cases +- `apps/web/src/components/dashboard/widget-renderer.tsx` — 3 imports + 3 switch cases +- `apps/web/src/components/dashboard/widget-config-dialog.tsx` — 3 forms + dispatch +- `apps/web/src/components/dashboard/widget-config-dialog.test.tsx` — 3 dispatch test cases +- `apps/web/src/components/dashboard/widget-picker.tsx` — 3 `WIDGET_ICONS` entries +- `apps/web/src/routes/_authed/network/$serverId.tsx` — refactor inline merge to use the new hook +- `apps/web/src/locales/en/dashboard.json` — picker + widget strings +- `apps/web/src/locales/zh/dashboard.json` — picker + widget strings + +--- + +## Task 1: Extract pure network-chart-records merge function + +**Files:** +- Create: `apps/web/src/lib/network-chart-records.ts` +- Test: `apps/web/src/lib/network-chart-records.test.ts` + +This extracts the realtime/seed merge logic currently inlined in `$serverId.tsx:728-767` into a pure, testable function. + +- [ ] **Step 1: Write the failing test** + +Create `apps/web/src/lib/network-chart-records.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { mergeNetworkChartRecords } from './network-chart-records' +import type { NetworkProbeRecord, NetworkProbeResultData } from './network-types' + +const seed: NetworkProbeRecord[] = [ + { + id: 1, + server_id: 'srv-1', + target_id: 't-1', + timestamp: '2026-05-29T10:00:00.000Z', + avg_latency: 20, + min_latency: 18, + max_latency: 25, + packet_loss: 0, + packet_sent: 10, + packet_received: 10 + } +] + +const realtime: Record = { + 't-1': [ + { + target_id: 't-1', + timestamp: '2026-05-29T10:01:00.000Z', + avg_latency: 22, + min_latency: 19, + max_latency: 28, + packet_loss: 0, + packet_sent: 10, + packet_received: 10 + } + ] +} + +describe('mergeNetworkChartRecords', () => { + it('returns historical records unchanged when not realtime', () => { + const result = mergeNetworkChartRecords({ isRealtime: false, historical: seed, seed: [], realtime: {}, serverId: 'srv-1' }) + expect(result).toEqual(seed) + }) + + it('flattens realtime map and merges with seed in realtime mode', () => { + const result = mergeNetworkChartRecords({ isRealtime: true, historical: [], seed, realtime, serverId: 'srv-1' }) + expect(result).toHaveLength(2) + expect(result.map((r) => r.timestamp)).toEqual([ + '2026-05-29T10:00:00.000Z', + '2026-05-29T10:01:00.000Z' + ]) + }) + + it('dedupes by target_id + timestamp keeping the latest entry', () => { + const dupRealtime: Record = { + 't-1': [ + { target_id: 't-1', timestamp: '2026-05-29T10:00:00.000Z', avg_latency: 99, min_latency: 99, max_latency: 99, packet_loss: 0, packet_sent: 10, packet_received: 10 } + ] + } + const result = mergeNetworkChartRecords({ isRealtime: true, historical: [], seed, realtime: dupRealtime, serverId: 'srv-1' }) + expect(result).toHaveLength(1) + expect(result[0].avg_latency).toBe(99) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/web && bun run test -- network-chart-records` +Expected: FAIL — `mergeNetworkChartRecords` is not defined / module not found. + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/web/src/lib/network-chart-records.ts`: + +```ts +import type { NetworkProbeRecord, NetworkProbeResultData } from './network-types' + +interface MergeArgs { + historical: NetworkProbeRecord[] + isRealtime: boolean + realtime: Record + seed: NetworkProbeRecord[] + serverId: string +} + +// Combine the 1h "seed" snapshot with live realtime points in realtime mode, or +// return historical records as-is otherwise. Realtime points override seed points +// at the same (target_id, timestamp) bucket. Mirrors the logic previously inlined +// in the network detail page. +export function mergeNetworkChartRecords({ historical, isRealtime, realtime, seed, serverId }: MergeArgs): NetworkProbeRecord[] { + if (!isRealtime) { + return historical + } + + const realtimeFlat: NetworkProbeRecord[] = [] + for (const [targetId, points] of Object.entries(realtime)) { + for (const point of points) { + realtimeFlat.push({ + id: 0, + server_id: serverId, + target_id: targetId, + timestamp: point.timestamp, + avg_latency: point.avg_latency, + min_latency: point.min_latency, + max_latency: point.max_latency, + packet_loss: point.packet_loss, + packet_sent: point.packet_sent, + packet_received: point.packet_received + }) + } + } + + const merged = [...seed, ...realtimeFlat] + const seen = new Set() + const deduped: NetworkProbeRecord[] = [] + for (let i = merged.length - 1; i >= 0; i--) { + const r = merged[i] + const key = `${r.target_id}:${r.timestamp}` + if (!seen.has(key)) { + seen.add(key) + deduped.push(r) + } + } + deduped.reverse() + return deduped +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/web && bun run test -- network-chart-records` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/lib/network-chart-records.ts apps/web/src/lib/network-chart-records.test.ts +git commit -m "feat(web): extract pure network chart records merge function" +``` + +--- + +## Task 2: Create useNetworkChartRecords hook and refactor detail page + +**Files:** +- Create: `apps/web/src/hooks/use-network-chart-records.ts` +- Modify: `apps/web/src/routes/_authed/network/$serverId.tsx:728-767` + +- [ ] **Step 1: Write the hook** + +Create `apps/web/src/hooks/use-network-chart-records.ts`: + +```ts +import { useMemo } from 'react' +import { useNetworkRecords } from '@/hooks/use-network-api' +import { useNetworkRealtime } from '@/hooks/use-network-realtime' +import { mergeNetworkChartRecords } from '@/lib/network-chart-records' +import type { NetworkProbeRecord } from '@/lib/network-types' + +// `hours === 0` means realtime. Returns a record series ready for LatencyChart, +// combining historical OR (seed + live) data depending on the range. +export function useNetworkChartRecords(serverId: string, hours: number): NetworkProbeRecord[] { + const isRealtime = hours === 0 + const { data: historical } = useNetworkRecords(serverId, hours, { enabled: !isRealtime && serverId.length > 0 }) + const { data: seed } = useNetworkRecords(serverId, 1, { enabled: isRealtime && serverId.length > 0 }) + const { data: realtime } = useNetworkRealtime(serverId) + + return useMemo( + () => + mergeNetworkChartRecords({ + historical: historical ?? [], + isRealtime, + realtime, + seed: seed ?? [], + serverId + }), + [historical, isRealtime, realtime, seed, serverId] + ) +} +``` + +- [ ] **Step 2: Refactor the detail page to use the hook** + +In `apps/web/src/routes/_authed/network/$serverId.tsx`, replace the `historicalRecords` / `seedRecords` / `realtimeData` declarations (lines ~649-653) and the `records` useMemo (lines ~728-767) with the hook. Keep `isRealtime` and `hours` as-is. + +Remove these lines: + +```tsx + const { data: historicalRecords } = useNetworkRecords(serverId, hours, { enabled: !isRealtime }) + // Fetch last 10 min of data as seed for realtime chart (immediate data on first load) + const { data: seedRecords } = useNetworkRecords(serverId, 1, { enabled: isRealtime }) +``` + +and + +```tsx + const { data: realtimeData } = useNetworkRealtime(serverId) +``` + +(Leave the `useNetworkRecords` / `useNetworkRealtime` imports only if still used elsewhere; they are not after this change — remove them from the import block in lines 32-44 if unused. `useNetworkRecords` is no longer used; `useNetworkRealtime` is no longer used.) + +Replace the entire `const records: NetworkProbeRecord[] = useMemo(() => { ... }, [...])` block (lines ~728-767) with: + +```tsx + const records = useNetworkChartRecords(serverId, isRealtime ? 0 : hours) +``` + +Add the import near the other hook imports: + +```tsx +import { useNetworkChartRecords } from '@/hooks/use-network-chart-records' +``` + +- [ ] **Step 3: Run the existing detail-page test + typecheck** + +Run: `cd apps/web && bun run test -- network/\\$server-id && bun run typecheck` +Expected: PASS, no type errors. (The detail-page test `routes/_authed/network/$server-id.test.tsx` exercises the chart; behavior is unchanged.) + +- [ ] **Step 4: Lint** + +Run: `cd apps/web && bun x ultracite check src/hooks/use-network-chart-records.ts src/routes/_authed/network/\\$serverId.tsx` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/hooks/use-network-chart-records.ts apps/web/src/routes/_authed/network/\$serverId.tsx +git commit -m "refactor(web): reuse shared network chart records hook in detail page" +``` + +--- + +## Task 3: Register widget types, scopes, and picker icons + +**Files:** +- Modify: `apps/web/src/lib/widget-types.ts` +- Modify: `apps/web/src/components/dashboard/widget-render-dependencies.ts:44-70` +- Modify: `apps/web/src/components/dashboard/widget-picker.tsx:35-50` + +- [ ] **Step 1: Add WIDGET_TYPES entries** + +In `apps/web/src/lib/widget-types.ts`, inside the `WIDGET_TYPES` array (before the closing `] as const satisfies ...`), add after the `uptime-timeline` entry: + +```ts + , + { + id: 'network-latency', + label: 'Network Latency', + category: 'Charts', + defaultW: 6, + defaultH: 4, + minW: 4, + minH: 3, + maxW: 12, + maxH: 8, + sizing: { kind: 'free' } + }, + { + id: 'network-quality', + label: 'Network Quality', + category: 'Real-time', + defaultW: 4, + defaultH: 4, + minW: 3, + minH: 3, + maxW: 8, + maxH: 8, + sizing: { kind: 'free' } + }, + { + id: 'network-overview', + label: 'Network Overview', + category: 'Status', + defaultW: 8, + defaultH: 5, + minW: 4, + minH: 3, + maxW: 12, + maxH: 8, + sizing: { kind: 'free' } + } +``` + +(Note: the existing array's last element `uptime-timeline` has no trailing comma; the leading `,` above attaches the new entries. Verify the final array is syntactically valid.) + +- [ ] **Step 2: Add config interfaces and extend the union** + +In `apps/web/src/lib/widget-types.ts`, after the `UptimeTimelineConfig` interface, add: + +```ts +export interface NetworkLatencyConfig { + hours?: number // 0 means realtime + server_id: string +} + +export interface NetworkQualityConfig { + server_id: string +} + +export interface NetworkOverviewConfig { + server_ids?: string[] +} +``` + +Then extend the `WidgetConfig` union — change: + +```ts + | UptimeTimelineConfig +``` + +to: + +```ts + | UptimeTimelineConfig + | NetworkLatencyConfig + | NetworkQualityConfig + | NetworkOverviewConfig +``` + +- [ ] **Step 3: Add render-dependency scopes** + +In `apps/web/src/components/dashboard/widget-render-dependencies.ts`, inside `getWidgetServerScope`'s switch, add cases before the `default`: + +```ts + case 'network-latency': + case 'network-quality': + return singleServerScope(config.server_id, 'name') + case 'network-overview': + return selectedServerScope(config.server_ids, 'name') +``` + +- [ ] **Step 4: Add picker icons** + +In `apps/web/src/components/dashboard/widget-picker.tsx`, add to the `WIDGET_ICONS` record (the `Network`, `Gauge`, `Globe` icons are already imported): + +```ts + 'network-latency': LineChart, + 'network-quality': Gauge, + 'network-overview': Network +``` + +(Add a comma after the existing `'uptime-timeline': Activity` entry first.) + +- [ ] **Step 5: Typecheck** + +Run: `cd apps/web && bun run typecheck` +Expected: PASS. (The renderer's switch is not yet exhaustive-checked at compile time — it has a `default`, so no error for the missing cases yet.) + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/lib/widget-types.ts apps/web/src/components/dashboard/widget-render-dependencies.ts apps/web/src/components/dashboard/widget-picker.tsx +git commit -m "feat(web): register network quality widget types and picker icons" +``` + +--- + +## Task 4: Network Quality widget (single-server summary card) + +**Files:** +- Create: `apps/web/src/components/dashboard/widgets/network-quality.tsx` +- Test: `apps/web/src/components/dashboard/widgets/network-quality.test.tsx` +- Modify: `apps/web/src/components/dashboard/widget-renderer.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `apps/web/src/components/dashboard/widgets/network-quality.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { NetworkServerSummary } from '@/lib/network-types' +import { NetworkQualityWidget } from './network-quality' + +const summaryMock = vi.fn<() => { data: NetworkServerSummary | undefined; isLoading: boolean }>() + +vi.mock('@/hooks/use-network-api', () => ({ + useNetworkServerSummary: () => summaryMock() +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (_k: string, fallback?: string) => fallback ?? _k }) +})) + +vi.mock('@/components/ui/scroll-area', () => ({ + ScrollArea: ({ children }: { children?: ReactNode }) =>
{children}
+})) + +const baseSummary: NetworkServerSummary = { + server_id: 'srv-1', + server_name: 'Server 1', + online: true, + last_probe_at: '2026-05-29T10:00:00.000Z', + anomaly_count: 0, + latency_sparkline: [], + loss_sparkline: [], + targets: [ + { target_id: 't-1', target_name: 'China Telecom', provider: 'ct', avg_latency: 23.1, min_latency: 20, max_latency: 30, packet_loss: 0, availability: 100 }, + { target_id: 't-2', target_name: 'International', provider: 'international', avg_latency: 142.3, min_latency: 130, max_latency: 160, packet_loss: 0.015, availability: 98 } + ] +} + +describe('NetworkQualityWidget', () => { + it('renders each target with latency and packet loss', () => { + summaryMock.mockReturnValue({ data: baseSummary, isLoading: false }) + render() + expect(screen.getByText('China Telecom')).toBeInTheDocument() + expect(screen.getByText('International')).toBeInTheDocument() + expect(screen.getByText('23.1 ms')).toBeInTheDocument() + }) + + it('renders empty state when there are no targets', () => { + summaryMock.mockReturnValue({ data: { ...baseSummary, targets: [] }, isLoading: false }) + render() + expect(screen.getByText(/no network probe data/i)).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/web && bun run test -- network-quality` +Expected: FAIL — module `./network-quality` not found. + +- [ ] **Step 3: Write the widget** + +Create `apps/web/src/components/dashboard/widgets/network-quality.tsx`: + +```tsx +import { useTranslation } from 'react-i18next' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Skeleton } from '@/components/ui/skeleton' +import { useNetworkServerSummary } from '@/hooks/use-network-api' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { CHART_COLORS } from '@/lib/chart-colors' +import { formatLatency, formatPacketLoss, getLossTextClassName } from '@/lib/network-types' +import type { NetworkQualityConfig } from '@/lib/widget-types' + +interface NetworkQualityWidgetProps { + config: NetworkQualityConfig + servers: ServerMetrics[] +} + +export function NetworkQualityWidget({ config }: NetworkQualityWidgetProps) { + const { t } = useTranslation('dashboard') + const serverId = config.server_id ?? '' + const { data: summary, isLoading } = useNetworkServerSummary(serverId) + + if (isLoading) { + return ( +
+ + +
+ ) + } + + const targets = summary?.targets ?? [] + + if (targets.length === 0) { + return ( +
+

{t('widgets.networkQuality.title', 'Network Quality')}

+
+ {t('widgets.networkQuality.empty.noData', 'No network probe data available')} +
+
+ ) + } + + return ( +
+
+

{t('widgets.networkQuality.title', 'Network Quality')}

+

{summary?.server_name}

+
+ +
    + {targets.map((target, i) => ( +
  • +
  • + ))} +
+
+
+ ) +} +``` + +- [ ] **Step 4: Wire into the renderer** + +In `apps/web/src/components/dashboard/widget-renderer.tsx`: + +Add the import (alphabetically near the other widget imports): + +```tsx +import { NetworkQualityWidget } from './widgets/network-quality' +``` + +Add the config type to the type-only import block from `@/lib/widget-types`: + +```tsx + NetworkQualityConfig, +``` + +Add the switch case (before `default`): + +```tsx + case 'network-quality': + return +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd apps/web && bun run test -- network-quality` +Expected: PASS (2 tests). + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/components/dashboard/widgets/network-quality.tsx apps/web/src/components/dashboard/widgets/network-quality.test.tsx apps/web/src/components/dashboard/widget-renderer.tsx +git commit -m "feat(web): add network quality summary widget" +``` + +--- + +## Task 5: Network Latency widget (single-server chart) + +**Files:** +- Create: `apps/web/src/components/dashboard/widgets/network-latency-widget.tsx` +- Test: `apps/web/src/components/dashboard/widgets/network-latency-widget.test.tsx` +- Modify: `apps/web/src/components/dashboard/widget-renderer.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `apps/web/src/components/dashboard/widgets/network-latency-widget.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { NetworkProbeRecord, NetworkServerSummary } from '@/lib/network-types' +import { NetworkLatencyWidget } from './network-latency-widget' + +const recordsMock = vi.fn<() => NetworkProbeRecord[]>() +const summaryMock = vi.fn<() => { data: NetworkServerSummary | undefined }>() + +vi.mock('@/hooks/use-network-chart-records', () => ({ + useNetworkChartRecords: () => recordsMock() +})) + +vi.mock('@/hooks/use-network-api', () => ({ + useNetworkServerSummary: () => summaryMock() +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (_k: string, fallback?: string) => fallback ?? _k }) +})) + +// LatencyChart is exercised in its own context; stub it so this test focuses on the widget shell. +vi.mock('@/components/network/latency-chart', () => ({ + LatencyChart: ({ records }: { records: NetworkProbeRecord[] }) => ( +
{records.length} points
+ ) +})) + +const summary: NetworkServerSummary = { + server_id: 'srv-1', + server_name: 'Server 1', + online: true, + last_probe_at: null, + anomaly_count: 0, + latency_sparkline: [], + loss_sparkline: [], + targets: [{ target_id: 't-1', target_name: 'China Telecom', provider: 'ct', avg_latency: 20, min_latency: 18, max_latency: 25, packet_loss: 0, availability: 100 }] +} + +describe('NetworkLatencyWidget', () => { + it('renders the latency chart with merged records', () => { + summaryMock.mockReturnValue({ data: summary }) + recordsMock.mockReturnValue([ + { id: 1, server_id: 'srv-1', target_id: 't-1', timestamp: '2026-05-29T10:00:00.000Z', avg_latency: 20, min_latency: 18, max_latency: 25, packet_loss: 0, packet_sent: 10, packet_received: 10 } + ]) + render() + expect(screen.getByTestId('latency-chart')).toHaveTextContent('1 points') + }) + + it('renders empty state when there are no records', () => { + summaryMock.mockReturnValue({ data: summary }) + recordsMock.mockReturnValue([]) + render() + expect(screen.getByText(/no network probe data/i)).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/web && bun run test -- network-latency-widget` +Expected: FAIL — module `./network-latency-widget` not found. + +- [ ] **Step 3: Write the widget** + +Create `apps/web/src/components/dashboard/widgets/network-latency-widget.tsx`: + +```tsx +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { LatencyChart } from '@/components/network/latency-chart' +import { useNetworkServerSummary } from '@/hooks/use-network-api' +import { useNetworkChartRecords } from '@/hooks/use-network-chart-records' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { CHART_COLORS } from '@/lib/chart-colors' +import type { NetworkLatencyConfig } from '@/lib/widget-types' + +interface NetworkLatencyWidgetProps { + config: NetworkLatencyConfig + servers: ServerMetrics[] +} + +export function NetworkLatencyWidget({ config }: NetworkLatencyWidgetProps) { + const { t } = useTranslation('dashboard') + const serverId = config.server_id ?? '' + const hours = config.hours ?? 24 + const isRealtime = hours === 0 + + const records = useNetworkChartRecords(serverId, hours) + const { data: summary } = useNetworkServerSummary(serverId) + + const chartTargets = useMemo( + () => + (summary?.targets ?? []).map((target, i) => ({ + id: target.target_id, + name: target.target_name, + color: CHART_COLORS[i % CHART_COLORS.length], + visible: true + })), + [summary] + ) + + if (records.length === 0) { + return ( +
+

{t('widgets.networkLatency.title', 'Network Latency')}

+
+ {t('widgets.networkLatency.empty.noData', 'No network probe data available')} +
+
+ ) + } + + return ( +
+
+

{t('widgets.networkLatency.title', 'Network Latency')}

+

{summary?.server_name}

+
+
+ +
+
+ ) +} +``` + +- [ ] **Step 4: Wire into the renderer** + +In `apps/web/src/components/dashboard/widget-renderer.tsx`: + +Add the import: + +```tsx +import { NetworkLatencyWidget } from './widgets/network-latency-widget' +``` + +Add to the type-only import block from `@/lib/widget-types`: + +```tsx + NetworkLatencyConfig, +``` + +Add the switch case (before `default`): + +```tsx + case 'network-latency': + return +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd apps/web && bun run test -- network-latency-widget` +Expected: PASS (2 tests). + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/components/dashboard/widgets/network-latency-widget.tsx apps/web/src/components/dashboard/widgets/network-latency-widget.test.tsx apps/web/src/components/dashboard/widget-renderer.tsx +git commit -m "feat(web): add network latency chart widget" +``` + +--- + +## Task 6: Network Overview widget (multi-server table) + +**Files:** +- Create: `apps/web/src/components/dashboard/widgets/network-overview-widget.tsx` +- Test: `apps/web/src/components/dashboard/widgets/network-overview-widget.test.tsx` +- Modify: `apps/web/src/components/dashboard/widget-renderer.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `apps/web/src/components/dashboard/widgets/network-overview-widget.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { NetworkServerSummary } from '@/lib/network-types' +import { NetworkOverviewWidget } from './network-overview-widget' + +const overviewMock = vi.fn<() => { data: NetworkServerSummary[]; isLoading: boolean }>() + +vi.mock('@/hooks/use-network-api', () => ({ + useNetworkOverview: () => overviewMock() +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (_k: string, fallback?: string) => fallback ?? _k }) +})) + +vi.mock('@/components/ui/scroll-area', () => ({ + ScrollArea: ({ children }: { children?: ReactNode }) =>
{children}
+})) + +// Render TanStack Router Link as a plain anchor so the widget can be tested in isolation. +vi.mock('@tanstack/react-router', () => ({ + Link: ({ children, to, params }: { children?: ReactNode; to?: string; params?: Record }) => ( + {children} + ) +})) + +const summaries: NetworkServerSummary[] = [ + { server_id: 'srv-1', server_name: 'Server 1', online: true, last_probe_at: null, anomaly_count: 2, latency_sparkline: [10, 12], loss_sparkline: [0, 0], targets: [{ target_id: 't-1', target_name: 'CT', provider: 'ct', avg_latency: 20, min_latency: 18, max_latency: 25, packet_loss: 0.012, availability: 99 }] }, + { server_id: 'srv-2', server_name: 'Server 2', online: false, last_probe_at: null, anomaly_count: 0, latency_sparkline: [], loss_sparkline: [], targets: [] } +] + +describe('NetworkOverviewWidget', () => { + it('renders one row per server with a link to the network detail page', () => { + overviewMock.mockReturnValue({ data: summaries, isLoading: false }) + render() + expect(screen.getByText('Server 1')).toBeInTheDocument() + expect(screen.getByText('Server 2')).toBeInTheDocument() + const link = screen.getByText('Server 1').closest('a') + expect(link).toHaveAttribute('href', '/network/srv-1') + }) + + it('filters to configured server_ids', () => { + overviewMock.mockReturnValue({ data: summaries, isLoading: false }) + render() + expect(screen.queryByText('Server 1')).not.toBeInTheDocument() + expect(screen.getByText('Server 2')).toBeInTheDocument() + }) + + it('renders empty state when there is no data', () => { + overviewMock.mockReturnValue({ data: [], isLoading: false }) + render() + expect(screen.getByText(/no network probe data/i)).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/web && bun run test -- network-overview-widget` +Expected: FAIL — module `./network-overview-widget` not found. + +- [ ] **Step 3: Write the widget** + +Create `apps/web/src/components/dashboard/widgets/network-overview-widget.tsx`: + +```tsx +import { Link } from '@tanstack/react-router' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Skeleton } from '@/components/ui/skeleton' +import { useNetworkOverview } from '@/hooks/use-network-api' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { formatLatency, type NetworkServerSummary } from '@/lib/network-types' +import type { NetworkOverviewConfig } from '@/lib/widget-types' + +interface NetworkOverviewWidgetProps { + config: NetworkOverviewConfig + servers: ServerMetrics[] +} + +// Average latency across a server's targets, ignoring targets with no reading. +function avgLatency(summary: NetworkServerSummary): number | null { + const values = summary.targets.map((target) => target.avg_latency).filter((v): v is number => v != null) + if (values.length === 0) { + return null + } + return values.reduce((a, b) => a + b, 0) / values.length +} + +export function NetworkOverviewWidget({ config }: NetworkOverviewWidgetProps) { + const { t } = useTranslation('dashboard') + const { data: overview = [], isLoading } = useNetworkOverview() + + const rows = useMemo(() => { + const ids = config.server_ids + if (!ids || ids.length === 0) { + return overview + } + const allow = new Set(ids) + return overview.filter((summary) => allow.has(summary.server_id)) + }, [overview, config.server_ids]) + + if (isLoading) { + return ( +
+ + +
+ ) + } + + if (rows.length === 0) { + return ( +
+

{t('widgets.networkOverview.title', 'Network Overview')}

+
+ {t('widgets.networkOverview.empty.noData', 'No network probe data available')} +
+
+ ) + } + + return ( +
+

{t('widgets.networkOverview.title', 'Network Overview')}

+ +
    + {rows.map((summary) => { + const latency = avgLatency(summary) + return ( +
  • + +
  • + ) + })} +
+
+
+ ) +} +``` + +- [ ] **Step 4: Wire into the renderer** + +In `apps/web/src/components/dashboard/widget-renderer.tsx`: + +Add the import: + +```tsx +import { NetworkOverviewWidget } from './widgets/network-overview-widget' +``` + +Add to the type-only import block from `@/lib/widget-types`: + +```tsx + NetworkOverviewConfig, +``` + +Add the switch case (before `default`): + +```tsx + case 'network-overview': + return +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd apps/web && bun run test -- network-overview-widget` +Expected: PASS (3 tests). + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/components/dashboard/widgets/network-overview-widget.tsx apps/web/src/components/dashboard/widgets/network-overview-widget.test.tsx apps/web/src/components/dashboard/widget-renderer.tsx +git commit -m "feat(web): add network overview widget" +``` + +--- + +## Task 7: Config dialog forms for the three widgets + +**Files:** +- Modify: `apps/web/src/components/dashboard/widget-config-dialog.tsx` +- Modify: `apps/web/src/components/dashboard/widget-config-dialog.test.tsx` + +The latency form needs a range select that includes a **Realtime** option (value `'0'`). Quality form picks a server only. Overview form is a server multi-select. + +- [ ] **Step 1: Write the failing tests** + +In `apps/web/src/components/dashboard/widget-config-dialog.test.tsx`, add to the `translations` map (inside the existing object): + +```ts + 'widgets.common.placeholders.selectServer': 'Select server', + 'widgets.common.empty.noServers': 'No servers', + 'common.timeRange.realtime': 'Realtime', + 'common.timeRange.6hours': '6 hours', + 'common.timeRange.7days': '7 days', +``` + +Then add these test cases inside the top-level `describe('WidgetConfigDialog', ...)` block: + +```tsx + it('renders server + range (with realtime) for network-latency widget', () => { + render( + + ) + + expect(screen.getByText('Server')).toBeInTheDocument() + expect(screen.getByText('Time Range')).toBeInTheDocument() + expect(screen.getByText('Realtime')).toBeInTheDocument() + expect(screen.getByText('Server 1')).toBeInTheDocument() + }) + + it('renders a server select for network-quality widget', () => { + render( + + ) + + expect(screen.getByText('Server')).toBeInTheDocument() + expect(screen.getByText('Server 1')).toBeInTheDocument() + }) + + it('renders a server multi-select for network-overview widget', () => { + render( + + ) + + expect(screen.getByText('Servers')).toBeInTheDocument() + expect(screen.getByText('Server 1')).toBeInTheDocument() + }) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/web && bun run test -- widget-config-dialog` +Expected: FAIL — the new widget types fall through to no form; `'Realtime'` / `'Servers'` not found for these types. + +- [ ] **Step 3: Add the forms and dispatch** + +In `apps/web/src/components/dashboard/widget-config-dialog.tsx`: + +Add to the type-only import from `@/lib/widget-types`: + +```ts + NetworkLatencyConfig, + NetworkOverviewConfig, + NetworkQualityConfig, +``` + +Add a network range options hook near `useRangeOptions` (includes realtime as `'0'`): + +```ts +function useNetworkRangeOptions(t: (key: string) => string): { label: string; value: string }[] { + return [ + { label: t('common.timeRange.realtime'), value: '0' }, + { label: t('common.timeRange.1hour'), value: '1' }, + { label: t('common.timeRange.6hours'), value: '6' }, + { label: t('common.timeRange.24hours'), value: '24' }, + { label: t('common.timeRange.7days'), value: '168' } + ] +} +``` + +Add the three form components after `UptimeTimelineForm`: + +```tsx +function NetworkLatencyForm({ + config, + servers, + onChange, + t +}: { + config: Partial + onChange: (c: Partial) => void + servers: ServerMetrics[] + t: (key: string) => string +}) { + const NETWORK_RANGE_OPTIONS = useNetworkRangeOptions(t) + return ( + <> + onChange({ ...config, server_id: v })} + placeholder={t('widgets.common.placeholders.selectServer')} + servers={servers} + value={config.server_id ?? ''} + /> +
+ + +
+ + ) +} + +function NetworkQualityForm({ + config, + servers, + onChange, + t +}: { + config: Partial + onChange: (c: Partial) => void + servers: ServerMetrics[] + t: (key: string) => string +}) { + return ( + onChange({ ...config, server_id: v })} + placeholder={t('widgets.common.placeholders.selectServer')} + servers={servers} + value={config.server_id ?? ''} + /> + ) +} + +function NetworkOverviewForm({ + config, + servers, + onChange, + t +}: { + config: Partial + onChange: (c: Partial) => void + servers: ServerMetrics[] + t: (key: string) => string +}) { + return ( + onChange({ ...config, server_ids: ids })} + selected={config.server_ids ?? []} + servers={servers} + /> + ) +} +``` + +Add the dispatch entries inside the dialog body (after the `uptime-timeline` block, before the `isModule` block): + +```tsx + {widgetType === 'network-latency' && ( + } + onChange={setConfig} + servers={servers} + t={t} + /> + )} + {widgetType === 'network-quality' && ( + } + onChange={setConfig} + servers={servers} + t={t} + /> + )} + {widgetType === 'network-overview' && ( + } + onChange={setConfig} + servers={servers} + t={t} + /> + )} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/web && bun run test -- widget-config-dialog` +Expected: PASS (all existing + 3 new cases). + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/components/dashboard/widget-config-dialog.tsx apps/web/src/components/dashboard/widget-config-dialog.test.tsx +git commit -m "feat(web): add config forms for network quality widgets" +``` + +--- + +## Task 8: i18n strings (en + zh) + +**Files:** +- Modify: `apps/web/src/locales/en/dashboard.json` +- Modify: `apps/web/src/locales/zh/dashboard.json` + +The widgets use `t(key, fallback)` so missing keys won't crash, but real strings are required for production. Add picker entries, widget titles/empty states, and the `common.timeRange.realtime` key (other timeRange keys already exist). + +- [ ] **Step 1: Add English strings** + +In `apps/web/src/locales/en/dashboard.json`: + +Under `widgetPicker.types`, add (after `uptime-timeline`): + +```json + "network-latency": { "label": "Network Latency", "description": "Latency over time to network probe targets" }, + "network-quality": { "label": "Network Quality", "description": "Current latency and packet loss per target" }, + "network-overview": { "label": "Network Overview", "description": "Network quality across servers" } +``` + +Under `widgets`, add (sibling of `diskIo`): + +```json + "networkLatency": { "title": "Network Latency", "empty": { "noData": "No network probe data available" } }, + "networkQuality": { "title": "Network Quality", "empty": { "noData": "No network probe data available" } }, + "networkOverview": { "title": "Network Overview", "empty": { "noData": "No network probe data available" } } +``` + +Under `common.timeRange`, add if missing: + +```json + "realtime": "Realtime" +``` + +- [ ] **Step 2: Add Chinese strings** + +In `apps/web/src/locales/zh/dashboard.json`, mirror the same structure: + +Under `widgetPicker.types`: + +```json + "network-latency": { "label": "网络延迟", "description": "对探测目标的延迟随时间变化" }, + "network-quality": { "label": "网络质量", "description": "各目标的当前延迟与丢包" }, + "network-overview": { "label": "网络总览", "description": "跨服务器的网络质量" } +``` + +Under `widgets`: + +```json + "networkLatency": { "title": "网络延迟", "empty": { "noData": "暂无网络探测数据" } }, + "networkQuality": { "title": "网络质量", "empty": { "noData": "暂无网络探测数据" } }, + "networkOverview": { "title": "网络总览", "empty": { "noData": "暂无网络探测数据" } } +``` + +Under `common.timeRange`, add if missing: + +```json + "realtime": "实时" +``` + +- [ ] **Step 3: Validate JSON + typecheck** + +Run: `cd apps/web && python3 -c "import json; json.load(open('src/locales/en/dashboard.json')); json.load(open('src/locales/zh/dashboard.json')); print('valid')" && bun run typecheck` +Expected: prints `valid`, no type errors. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/locales/en/dashboard.json apps/web/src/locales/zh/dashboard.json +git commit -m "feat(web): add i18n strings for network quality widgets" +``` + +--- + +## Task 9: Full verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full frontend test suite** + +Run: `cd apps/web && bun run test` +Expected: PASS — all suites green, including the new network widget tests. + +- [ ] **Step 2: Typecheck** + +Run: `cd apps/web && bun run typecheck` +Expected: no errors. + +- [ ] **Step 3: Lint** + +Run: `cd apps/web && bun x ultracite check` +Expected: no errors. If any auto-fixable issues exist, run `bun x ultracite fix` and re-run check, then amend the relevant commit. + +- [ ] **Step 4: Build (confirms widgets compile into the bundle)** + +Run: `cd apps/web && bun run build` +Expected: build succeeds. + +- [ ] **Step 5: Final commit (only if lint/fix changed files)** + +```bash +git add -A +git commit -m "chore(web): lint pass for network quality widgets" +``` + +(Skip if nothing changed in steps 1-4.) + +--- + +## Notes for the implementer + +- **Do not push.** The goal is to commit locally only. +- The `servers` prop is accepted by all three widgets for renderer/memoization uniformity even though the network widgets fetch their own data; this matches the existing widget signatures and the `areWidgetServerDependenciesEqual` contract. +- `getLossTextClassName` takes a loss **ratio** (0-1), which matches `NetworkTargetSummary.packet_loss`. +- `formatLatency` / `formatPacketLoss` already handle `null` and ratio→percent formatting. +- If `bun run test -- ` does not filter as expected in this repo's vitest config, fall back to `bun run test` and read the relevant suite output. diff --git a/docs/superpowers/specs/2026-05-29-network-quality-widgets-design.md b/docs/superpowers/specs/2026-05-29-network-quality-widgets-design.md new file mode 100644 index 00000000..4a4fb457 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-network-quality-widgets-design.md @@ -0,0 +1,97 @@ +# Network Quality Dashboard Widgets — Design + +Date: 2026-05-29 + +## Goal + +Surface the existing network-probe (tri-network ping) data as dashboard widgets so +users can compose latency/packet-loss views into their dashboards. The data layer +already exists, so this is **predominantly a frontend feature** — no protocol or +migration changes. One backend touch point is required: each new widget type must be +added to the `VALID_WIDGET_TYPES` whitelist in `crates/server/src/service/dashboard.rs`, +otherwise the dashboard save endpoint rejects it with `400 Unknown widget_type`. +Registering a new widget type therefore spans both the frontend registry and the +backend whitelist. + +## Background + +Network quality data comes from the network-probe subsystem (P13): each server pings +a set of targets (China Telecom / Unicom / Mobile / International) and records +`avg/min/max_latency` and `packet_loss`. The data is already exposed through: + +- `useNetworkOverview()` — all servers, per-target summary + latency/loss sparklines + anomaly count +- `useNetworkServerSummary(serverId)` — one server's per-target summary (auto-refresh 60s) +- `useNetworkRecords(serverId, hours, { targetId })` — historical records for charts +- `useNetworkRealtime(serverId)` — realtime points via the global `network-probe-update` window event + +The network detail page (`routes/_authed/network/$serverId.tsx`) already combines +records + realtime + a 1h seed into a single record series and renders `LatencyChart`. + +## Approach + +Three independent built-in widgets (matches the existing 13-widget convention; one +widget = one visual form). Rejected alternatives: a single mode-switching `network` +widget (breaks the one-form-per-widget convention, poor discoverability in the picker), +and the third-party module system (app-internal network hooks aren't reachable from modules). + +## Widgets + +| id | category | binding | default size | data | +|---|---|---|---|---| +| `network-latency` | Charts | single server | 6×4 | `useNetworkRecords` + realtime merge → `LatencyChart` | +| `network-quality` | Real-time | single server | 4×4 | `useNetworkServerSummary` (60s refresh) → per-target latency/loss list | +| `network-overview` | Status | many / all servers | 8×5 | `useNetworkOverview` → server×target summary table + sparkline; rows link to `/network/$serverId` | + +### Config (`config_json`) + +```ts +interface NetworkLatencyConfig { server_id: string; hours?: number } // hours === 0 means realtime +interface NetworkQualityConfig { server_id: string } +interface NetworkOverviewConfig { server_ids?: string[] } // empty/undefined = all servers +``` + +- Target selection (v1): widgets render **all** of the bound server's probe targets. Per-target + filtering is deliberately left out of the config UI so the config dialog stays a pure, + data-fetch-free form (its tests render without a QueryClient). Per-target selection is out of scope. +- The latency widget's time-range dropdown gains a **Realtime** option alongside + 1h / 6h / 24h / 7d. Realtime uses `useNetworkRealtime`'s sliding window; other ranges + use `useNetworkRecords`. Encode realtime as `hours === 0` in config to keep the field numeric. + +## Shared hook (incidental improvement) + +Extract the "records + realtime + 1h seed merge & dedupe" logic currently inlined in +`$serverId.tsx` (the `records` useMemo) into a reusable +`useNetworkChartRecords(serverId, range)` hook next to `use-network-realtime.ts`. Both +the detail page and the `network-latency` widget consume it, removing duplication. The +detail page is refactored to use the hook with no behavior change. + +## Registration surface (per new widget) + +1. `lib/widget-types.ts` — add 3 entries to `WIDGET_TYPES`, 3 config interfaces, extend the `WidgetConfig` union. +2. `dashboard/widget-renderer.tsx` — 3 imports + 3 `switch` cases. +3. `dashboard/widget-config-dialog.tsx` — 3 config forms + dispatch entries. +4. `dashboard/widget-picker.tsx` — add 3 icons to `WIDGET_ICONS` (e.g. `Network`, `Gauge`, `Globe`). +5. `dashboard/widget-render-dependencies.ts` — single-server widgets use `singleServerScope(server_id, 'name')`; overview uses `selectedServerScope(server_ids, 'name')`. +6. New components: `widgets/network-latency-widget.tsx`, `widgets/network-quality.tsx`, `widgets/network-overview-widget.tsx`. +7. i18n: `locales/{en,zh}/dashboard.json` — picker labels/descriptions + config-form labels. Network-specific copy reuses the existing `network` namespace. + +## Error / empty states + +- Server has no probe targets configured → empty-state message (reuse `network` namespace no-data copy). +- `server_id` points to a deleted server → `WidgetErrorBoundary` fallback + friendly empty state. +- Overview with no data → empty table message. + +## Testing + +- Follow existing `gauge.test.tsx` / `widget-config-dialog.test.tsx` patterns. +- Add cases for the 3 new config-form dispatches in the config-dialog test. +- Add at least one render test per widget covering the no-data fallback. +- Add a Rust unit test in `dashboard.rs` asserting `DashboardService::update` accepts + the new widget types (guards the `VALID_WIDGET_TYPES` whitelist regression). + +## Out of scope + +- New backend endpoints or aggregation. +- Traceroute / anomaly widgets (latency + quality + overview only). +- Per-target filtering in the widget config UI. +- Changes to the existing network pages beyond the shared-hook extraction.