From 015cb84115745c23f7f846fcd1a04fcb2cf29f0a Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 29 May 2026 20:03:17 +0800 Subject: [PATCH 01/14] docs: add network quality dashboard widgets design spec --- ...26-05-29-network-quality-widgets-design.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-29-network-quality-widgets-design.md 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..9e0453b5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-network-quality-widgets-design.md @@ -0,0 +1,91 @@ +# 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; this is a **frontend-only** feature — no backend, protocol, or +migration changes. + +## 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; target_ids?: string[] } // hours === 0 means realtime +interface NetworkQualityConfig { server_id: string; target_ids?: string[] } +interface NetworkOverviewConfig { server_ids?: string[] } // empty/undefined = all servers +``` + +- Target selection: `target_ids` empty/undefined → show all targets; the config dialog + offers checkboxes to restrict to specific targets (reusing the detail page's + target-visibility idea). +- 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. +- No backend changes → no cargo tests required. + +## Out of scope + +- New backend endpoints or aggregation. +- Traceroute / anomaly widgets (latency + quality + overview only). +- Changes to the existing network pages beyond the shared-hook extraction. From 691b41367a90e79b394a60f60f6966988eafa99c Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 29 May 2026 20:21:44 +0800 Subject: [PATCH 02/14] docs: refine network quality spec and add implementation plan --- .../2026-05-29-network-quality-widgets.md | 1323 +++++++++++++++++ ...26-05-29-network-quality-widgets-design.md | 11 +- 2 files changed, 1329 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-29-network-quality-widgets.md 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 index 9e0453b5..f2789e79 100644 --- a/docs/superpowers/specs/2026-05-29-network-quality-widgets-design.md +++ b/docs/superpowers/specs/2026-05-29-network-quality-widgets-design.md @@ -41,14 +41,14 @@ and the third-party module system (app-internal network hooks aren't reachable f ### Config (`config_json`) ```ts -interface NetworkLatencyConfig { server_id: string; hours?: number; target_ids?: string[] } // hours === 0 means realtime -interface NetworkQualityConfig { server_id: string; target_ids?: string[] } +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: `target_ids` empty/undefined → show all targets; the config dialog - offers checkboxes to restrict to specific targets (reusing the detail page's - target-visibility idea). +- 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. @@ -88,4 +88,5 @@ detail page is refactored to use the hook with no behavior change. - 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. From f6bb2276bc80a6ea3ff3fc86da5e11701fd94426 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 29 May 2026 20:39:05 +0800 Subject: [PATCH 03/14] feat(web): extract pure network chart records merge function --- .../web/src/lib/network-chart-records.test.ts | 78 +++++++++++++++++++ apps/web/src/lib/network-chart-records.ts | 57 ++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 apps/web/src/lib/network-chart-records.test.ts create mode 100644 apps/web/src/lib/network-chart-records.ts 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 +} From b7174d9a8d44627b09a09cf2a0967511e75617cd Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 29 May 2026 21:02:58 +0800 Subject: [PATCH 04/14] refactor(web): reuse shared network chart records hook in detail page --- .../src/hooks/use-network-chart-records.ts | 26 ++++++++++ .../src/routes/_authed/network/$serverId.tsx | 49 +------------------ 2 files changed, 28 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/hooks/use-network-chart-records.ts 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..9998c13a --- /dev/null +++ b/apps/web/src/hooks/use-network-chart-records.ts @@ -0,0 +1,26 @@ +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] + ) +} diff --git a/apps/web/src/routes/_authed/network/$serverId.tsx b/apps/web/src/routes/_authed/network/$serverId.tsx index c818c99f..4aea1722 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(() => { From 8aaf0f084340095e0f0aa4071f80ff4531292dc3 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 29 May 2026 21:15:33 +0800 Subject: [PATCH 05/14] feat(web): register network quality widget types and picker icons --- .../components/dashboard/widget-picker.tsx | 5 +- .../dashboard/widget-render-dependencies.ts | 5 ++ apps/web/src/lib/widget-types.ts | 52 +++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) 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/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 From f43f24a3d03564d9a90879bcecbbad51276c06c4 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 29 May 2026 21:28:49 +0800 Subject: [PATCH 06/14] feat(web): add network quality summary widget --- .../components/dashboard/widget-renderer.tsx | 4 ++ .../widgets/network-quality.test.tsx | 69 +++++++++++++++++++ .../dashboard/widgets/network-quality.tsx | 68 ++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 apps/web/src/components/dashboard/widgets/network-quality.test.tsx create mode 100644 apps/web/src/components/dashboard/widgets/network-quality.tsx diff --git a/apps/web/src/components/dashboard/widget-renderer.tsx b/apps/web/src/components/dashboard/widget-renderer.tsx index 7909f084..a7e8e68f 100644 --- a/apps/web/src/components/dashboard/widget-renderer.tsx +++ b/apps/web/src/components/dashboard/widget-renderer.tsx @@ -10,6 +10,7 @@ import type { MarkdownConfig, MetricCardConfig, MultiLineConfig, + NetworkQualityConfig, ServerCardsConfig, ServerMapConfig, ServiceStatusConfig, @@ -27,6 +28,7 @@ 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 { 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 +121,8 @@ function WidgetContent({ widget, servers }: WidgetRendererProps) { return case 'uptime-timeline': return + case 'network-quality': + return default: 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) => ( +
  • +
  • + ))} +
+
+
+ ) +} From c9c7d1731fe7b7573582a245d6429089ec6cd74a Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 29 May 2026 21:47:12 +0800 Subject: [PATCH 07/14] feat(web): add network latency chart widget --- .../components/dashboard/widget-renderer.tsx | 4 + .../widgets/network-latency-widget.test.tsx | 79 +++++++++++++++++++ .../widgets/network-latency-widget.tsx | 57 +++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 apps/web/src/components/dashboard/widgets/network-latency-widget.test.tsx create mode 100644 apps/web/src/components/dashboard/widgets/network-latency-widget.tsx diff --git a/apps/web/src/components/dashboard/widget-renderer.tsx b/apps/web/src/components/dashboard/widget-renderer.tsx index a7e8e68f..2d3a2b31 100644 --- a/apps/web/src/components/dashboard/widget-renderer.tsx +++ b/apps/web/src/components/dashboard/widget-renderer.tsx @@ -10,6 +10,7 @@ import type { MarkdownConfig, MetricCardConfig, MultiLineConfig, + NetworkLatencyConfig, NetworkQualityConfig, ServerCardsConfig, ServerMapConfig, @@ -28,6 +29,7 @@ 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 { NetworkQualityWidget } from './widgets/network-quality' import { ServerCardsWidget } from './widgets/server-cards' import { ServerMapWidget } from './widgets/server-map' @@ -123,6 +125,8 @@ function WidgetContent({ widget, servers }: WidgetRendererProps) { return case 'network-quality': return + case 'network-latency': + 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..af382219 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/network-latency-widget.test.tsx @@ -0,0 +1,79 @@ +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<() => 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_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..8fa3195e --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/network-latency-widget.tsx @@ -0,0 +1,57 @@ +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}

+
+
+ +
+
+ ) +} From 91a3764d9c06a8b132f46b11b4d0039188e63cad Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 29 May 2026 22:04:36 +0800 Subject: [PATCH 08/14] feat(web): add network overview widget --- .../components/dashboard/widget-renderer.tsx | 4 + .../widgets/network-overview-widget.test.tsx | 86 +++++++++++++++++ .../widgets/network-overview-widget.tsx | 92 +++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 apps/web/src/components/dashboard/widgets/network-overview-widget.test.tsx create mode 100644 apps/web/src/components/dashboard/widgets/network-overview-widget.tsx diff --git a/apps/web/src/components/dashboard/widget-renderer.tsx b/apps/web/src/components/dashboard/widget-renderer.tsx index 2d3a2b31..9bd996bd 100644 --- a/apps/web/src/components/dashboard/widget-renderer.tsx +++ b/apps/web/src/components/dashboard/widget-renderer.tsx @@ -11,6 +11,7 @@ import type { MetricCardConfig, MultiLineConfig, NetworkLatencyConfig, + NetworkOverviewConfig, NetworkQualityConfig, ServerCardsConfig, ServerMapConfig, @@ -30,6 +31,7 @@ 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' @@ -127,6 +129,8 @@ function WidgetContent({ widget, servers }: WidgetRendererProps) { return case 'network-latency': return + case 'network-overview': + return default: return (
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 ( +
  • + +
  • + ) + })} +
+
+
+ ) +} From 171805ba07f3bbb731a86f2b5a212c532dbb0cce Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 29 May 2026 22:19:50 +0800 Subject: [PATCH 09/14] feat(web): add config forms for network quality widgets --- .../dashboard/widget-config-dialog.test.tsx | 52 ++++++++ .../dashboard/widget-config-dialog.tsx | 125 ++++++++++++++++++ 2 files changed, 177 insertions(+) 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 && (

From 10c3aa69d1cbcc9a358e66a8366020d994183992 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 29 May 2026 22:33:27 +0800 Subject: [PATCH 10/14] feat(web): add i18n strings for network quality widgets --- apps/web/src/locales/en/dashboard.json | 33 +++++++++++++++++++++++++- apps/web/src/locales/zh/dashboard.json | 33 +++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) 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": { From 3e2f750846a8db0109af186384fc3f1ee4f3331a Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 29 May 2026 22:48:09 +0800 Subject: [PATCH 11/14] fix(server): allow network quality widget types in dashboard save The dashboard save endpoint validates widget_type against the VALID_WIDGET_TYPES whitelist, which omitted the three network quality widgets. Saving a dashboard containing them returned 400 Unknown widget_type. Add network-latency, network-quality and network-overview to the whitelist and cover the regression with a unit test. --- crates/server/src/service/dashboard.rs | 73 ++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) 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); + } } From 45fb01308ceb38940a76c8659921e6872d3b1859 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 29 May 2026 23:06:41 +0800 Subject: [PATCH 12/14] docs: correct network widget spec to note backend whitelist touch point The spec claimed the feature was frontend-only with no backend changes, but registering a widget type also requires adding it to the VALID_WIDGET_TYPES whitelist in dashboard.rs. Document that touch point and the corresponding cargo regression test. --- .../2026-05-29-network-quality-widgets-design.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 index f2789e79..4a4fb457 100644 --- a/docs/superpowers/specs/2026-05-29-network-quality-widgets-design.md +++ b/docs/superpowers/specs/2026-05-29-network-quality-widgets-design.md @@ -6,8 +6,12 @@ Date: 2026-05-29 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; this is a **frontend-only** feature — no backend, protocol, or -migration changes. +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 @@ -82,7 +86,8 @@ detail page is refactored to use the hook with no behavior change. - 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. -- No backend changes → no cargo tests required. +- 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 From 448c29c0054efd182bf84e5beaf05ba8ebce23c6 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 29 May 2026 23:24:15 +0800 Subject: [PATCH 13/14] fix(web): make network latency widget chart fill its grid cell LatencyChart was built for the detail page: it wraps itself in a card with its own title and hard-codes a 300px height. Embedded in the network-latency dashboard widget (which already provides the card and header), that produced a redundant nested card and overflowed the grid cell, visually overlapping the widget below. Add an opt-in embedded prop that drops the card chrome/title and fills the parent height, matching the line-chart widget pattern. The detail page keeps the default. --- .../widgets/network-latency-widget.test.tsx | 26 +++++- .../widgets/network-latency-widget.tsx | 8 +- .../src/components/network/latency-chart.tsx | 84 +++++++++++-------- 3 files changed, 81 insertions(+), 37 deletions(-) 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 index af382219..34099461 100644 --- a/apps/web/src/components/dashboard/widgets/network-latency-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/network-latency-widget.test.tsx @@ -22,8 +22,10 @@ vi.mock('react-i18next', () => ({ // 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
+ LatencyChart: ({ records, embedded }: { records: NetworkProbeRecord[]; embedded?: boolean }) => ( +
+ {records.length} points +
) })) @@ -70,6 +72,26 @@ describe('NetworkLatencyWidget', () => { 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 }) + 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')).toHaveAttribute('data-embedded', 'true') + }) + it('renders empty state when there are no records', () => { summaryMock.mockReturnValue({ data: summary }) recordsMock.mockReturnValue([]) diff --git a/apps/web/src/components/dashboard/widgets/network-latency-widget.tsx b/apps/web/src/components/dashboard/widgets/network-latency-widget.tsx index 8fa3195e..a8c5a65c 100644 --- a/apps/web/src/components/dashboard/widgets/network-latency-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/network-latency-widget.tsx @@ -50,7 +50,13 @@ export function NetworkLatencyWidget({ config }: NetworkLatencyWidgetProps) {

{summary?.server_name}

- +
) 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}
) } From df13649bbd58fdc16bc5a08e4422915b47c0db55 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 29 May 2026 23:43:52 +0800 Subject: [PATCH 14/14] fix(web): show skeleton while network latency widget loads Expose isLoading from useNetworkChartRecords and gate the latency widget on both the records and summary queries, so it renders a skeleton instead of flashing the empty state or an axis-only chart while data loads. This matches the loading behavior of the quality and overview widgets. --- .../widgets/network-latency-widget.test.tsx | 74 ++++++++++--------- .../widgets/network-latency-widget.tsx | 16 +++- .../src/hooks/use-network-chart-records.ts | 28 +++++-- .../src/routes/_authed/network/$serverId.tsx | 2 +- 4 files changed, 75 insertions(+), 45 deletions(-) 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 index 34099461..04126143 100644 --- a/apps/web/src/components/dashboard/widgets/network-latency-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/network-latency-widget.test.tsx @@ -5,8 +5,8 @@ import { NetworkLatencyWidget } from './network-latency-widget' const NO_DATA_RE = /no network probe data/i -const recordsMock = vi.fn<() => NetworkProbeRecord[]>() -const summaryMock = vi.fn<() => { data: NetworkServerSummary | undefined }>() +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() @@ -51,50 +51,56 @@ const summary: NetworkServerSummary = { ] } +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 }) - 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 - } - ]) + 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 }) - 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 - } - ]) + 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 }) - recordsMock.mockReturnValue([]) + 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 index a8c5a65c..674a9a88 100644 --- a/apps/web/src/components/dashboard/widgets/network-latency-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/network-latency-widget.tsx @@ -1,6 +1,7 @@ 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' @@ -18,8 +19,8 @@ export function NetworkLatencyWidget({ config }: NetworkLatencyWidgetProps) { const hours = config.hours ?? 24 const isRealtime = hours === 0 - const records = useNetworkChartRecords(serverId, hours) - const { data: summary } = useNetworkServerSummary(serverId) + const { records, isLoading: recordsLoading } = useNetworkChartRecords(serverId, hours) + const { data: summary, isLoading: summaryLoading } = useNetworkServerSummary(serverId) const chartTargets = useMemo( () => @@ -32,6 +33,17 @@ export function NetworkLatencyWidget({ config }: NetworkLatencyWidgetProps) { [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 (
diff --git a/apps/web/src/hooks/use-network-chart-records.ts b/apps/web/src/hooks/use-network-chart-records.ts index 9998c13a..d91e420d 100644 --- a/apps/web/src/hooks/use-network-chart-records.ts +++ b/apps/web/src/hooks/use-network-chart-records.ts @@ -4,23 +4,35 @@ 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. -export function useNetworkChartRecords(serverId: string, hours: number): NetworkProbeRecord[] { +// 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 { data: historical } = useNetworkRecords(serverId, hours, { enabled: !isRealtime && serverId.length > 0 }) - const { data: seed } = useNetworkRecords(serverId, 1, { enabled: isRealtime && serverId.length > 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) - return useMemo( + const records = useMemo( () => mergeNetworkChartRecords({ - historical: historical ?? [], + historical: historicalQuery.data ?? [], isRealtime, realtime, - seed: seed ?? [], + seed: seedQuery.data ?? [], serverId }), - [historical, isRealtime, realtime, seed, serverId] + [historicalQuery.data, isRealtime, realtime, seedQuery.data, serverId] ) + + const isLoading = isRealtime ? seedQuery.isLoading : historicalQuery.isLoading + + return { isLoading, records } } diff --git a/apps/web/src/routes/_authed/network/$serverId.tsx b/apps/web/src/routes/_authed/network/$serverId.tsx index 4aea1722..3e0cd81e 100644 --- a/apps/web/src/routes/_authed/network/$serverId.tsx +++ b/apps/web/src/routes/_authed/network/$serverId.tsx @@ -719,7 +719,7 @@ export function NetworkDetailPage() { [targets, targetColorMap, effectiveVisible, getSummaryTargetDisplayName] ) - const records = useNetworkChartRecords(serverId, isRealtime ? 0 : hours) + const { records } = useNetworkChartRecords(serverId, isRealtime ? 0 : hours) // Stats computed from current records const stats = useMemo(() => {