From ec9403ccc395d944acd001731819eaf6a6e2122c Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:04:30 +0800 Subject: [PATCH 01/21] docs: spec for metric-card dashboard widget --- .../2026-05-27-metric-card-widget-design.md | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-metric-card-widget-design.md diff --git a/docs/superpowers/specs/2026-05-27-metric-card-widget-design.md b/docs/superpowers/specs/2026-05-27-metric-card-widget-design.md new file mode 100644 index 00000000..b8e42bdc --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-metric-card-widget-design.md @@ -0,0 +1,226 @@ +# Metric Card Widget — Design + +**Date:** 2026-05-27 +**Status:** Draft +**Surface:** `apps/web` dashboard + +## 1. Motivation + +The dashboard currently offers a thin `stat-number` widget (one value + an icon) and a much larger `line-chart` widget (full trend chart). There is no middle-weight widget that shows current load **plus** a short-term trend **plus** a long-term reference at a glance. + +This spec adds `metric-card`, a square-ish info-dense card inspired by the iOS Bitcoin widget: header, large current value, short-term delta, sparkline, and two long-term sub-stats. Four metrics are supported on a single widget type, configurable per instance: CPU, memory, network throughput, and disk I/O throughput. + +## 2. Scope + +**In scope** + +- A new dashboard widget type `metric-card` registered in `WIDGET_TYPES`. +- One widget instance binds to a single server and a single metric. +- Four metrics: `cpu`, `memory`, `network`, `disk_io`. +- 24-hour history-derived peak and average sub-stats. +- 1-hour delta indicator below the main value. +- Sparkline rendered from the same 24h history series, with the most recent live tick spliced in for liveness. + +**Out of scope** + +- Aggregating across all servers (`stat-number` already covers fleet-wide summaries). +- Configurable sparkline window or interval (fixed at 24h / 5m bucketing so all derived stats line up). +- Per-card alert thresholds or threshold lines (line-chart widget territory). +- Backend changes — all required endpoints already exist. + +## 3. Visual Design + +Default grid footprint: **4 columns × 4 rows** on the 12-col dashboard. Min 3×3, max 6×6. + +``` +┌──────────────────────────────────┐ +│ [icon] CPU · server-A │ header +│ │ +│ 45.2% │ primary value (large) +│ ▲ +2.1pp · past 1h │ delta (small) +│ │ +│ ╱╲ ╱─╲ │ sparkline area +│ ────╯ ╲──╯ ╲──── │ +│ │ +│ ┌────────────┬────────────┐ │ sub-cards +│ │ 24H PEAK │ 24H AVG │ │ +│ │ 78.4% │ 41.7% │ │ +│ └────────────┴────────────┘ │ +└──────────────────────────────────┘ +``` + +**Sections (top to bottom):** + +1. **Header** — metric icon (lucide: `Cpu` / `MemoryStick` / `Network` / `HardDriveDownload`), metric label, server name on the right. +2. **Primary value** — current reading, large display font. +3. **Delta line** — `▲/▼ value · past 1h`, color-coded. +4. **Sparkline** — recharts `` filling the metric color at low opacity, ~60px tall in the default size. +5. **Sub-cards** — two flex-equal rounded surfaces with uppercase caption + value: `24H PEAK`, `24H AVG`. + +**Color tokens** (Tailwind / shadcn theme): + +| Metric | Accent token | +|---|---| +| cpu | `--chart-4` (existing CPU accent) | +| memory | `--chart-3` | +| network | `--chart-1` | +| disk_io | `--chart-2` | + +**Delta colors:** + +- `cpu` / `memory` — semantic: up = `text-destructive`, down = `text-emerald-500` (rising utilization is a stress signal). +- `network` / `disk_io` — neutral: always `text-muted-foreground`, just an arrow for direction (throughput swings are neither good nor bad). + +## 4. Data Model + +### Per-metric formatting + +| Metric | Value source (live) | Value source (history) | Display | +|---|---|---|---| +| `cpu` | `server.cpu` | `record.cpu` | `45.2%` | +| `memory` | `mem_used / mem_total * 100` | `record.mem_used / server.mem_total * 100` | `67.0%` | +| `network` | `net_in_speed + net_out_speed` | `record.net_in_speed + record.net_out_speed` | `124 MB/s` (via `formatSpeed`) | +| `disk_io` | `disk_read_speed + disk_write_speed` | from `buildMergedDiskIoSeries` summed | `34 MB/s` | + +Reuse `extractLiveMetric` / `extractRecordMetric` in `lib/widget-helpers.ts`; extend both with `network` (alias of existing `bandwidth`) and `disk_io` cases. + +### Delta calculation + +``` +delta = current_value - value_1h_ago +``` + +- For `cpu` / `memory` the unit is `pp` (percentage points): `▲ +2.1pp`. +- For `network` / `disk_io` the unit is relative percent: `▲ +18%`. +- If the 1h-ago sample is missing (server only just came online), show `—` instead of a delta. + +### Peak / Avg + +Computed over the same 24h history series used for the sparkline: + +- `peak = max(series.value)` +- `avg = sum(series.value) / series.length` + +When the series is empty (server offline / no data) show `—` for both. + +## 5. Data Flow + +``` +useServerRecords(server_id, 24, '5m') ← 24h history, refetch every 5 min + │ + ▼ +useMetricSeries(records, metric, server) ← derives sparkline points, + │ peak, avg, 1h-ago value + ▼ +MetricCardWidget renders ← splices the live tick from + useServersWs onto the tail +``` + +**Live tail splicing:** `useServersWs` already pushes `ServerMetrics` updates every second. The metric card appends `{ timestamp: now, value: extractLiveMetric(server, metric) }` to the series tail in memo'd derived state. This keeps the sparkline visibly moving without re-fetching history every second. + +**Loading state:** while `useServerRecords` is loading, show skeleton placeholders for value, delta, sparkline, and sub-cards. + +**Offline state:** if the bound server is `online === false`, dim the card and render `—` for current value/delta but keep peak/avg from history (since the last 24h may still contain data). + +## 6. Configuration + +### Type + +```ts +export interface MetricCardConfig { + metric: 'cpu' | 'memory' | 'network' | 'disk_io' + server_id: string + label?: string // optional override of the default metric label +} +``` + +### Widget registration + +Add to `WIDGET_TYPES` in `lib/widget-types.ts`: + +```ts +{ + id: 'metric-card', + label: 'Metric Card', + category: 'Real-time', + defaultW: 4, defaultH: 4, + minW: 3, minH: 3, + maxW: 6, maxH: 6 +} +``` + +### Picker entry + +Appears under the "Real-time" group in `widget-picker.tsx` with a thumbnail/preview matching the layout in §3. + +### Config dialog + +Extend `widget-config-dialog.tsx` with a new case for `metric-card`: + +- **Server** — select existing servers, required. +- **Metric** — radio group of `cpu` / `memory` / `network` / `disk_io`, required, default `cpu`. +- **Label** — optional text input, placeholder is the localized metric label. + +## 7. Component Structure + +``` +apps/web/src/components/dashboard/widgets/ + metric-card.tsx ← MetricCardWidget (entrypoint) + metric-card/ + metric-card-header.tsx ← icon + label + server name + metric-card-value.tsx ← large value + delta line + metric-card-sparkline.tsx ← recharts Area, color-token driven + metric-card-stats.tsx ← 24h peak / avg sub-cards + metric-card-config.ts ← per-metric accent, icon, formatter map + +apps/web/src/hooks/ + use-metric-series.ts ← derives sparkline + peak/avg/1h-delta + from useServerRecords + live tail + +apps/web/src/components/dashboard/widgets/ + metric-card.test.tsx ← snapshot + computed-value tests +``` + +Files inside `metric-card/` are co-located fragments small enough that each one has a single responsibility; they are not exported elsewhere. + +## 8. Integration Points + +| File | Change | +|---|---| +| `lib/widget-types.ts` | Add `MetricCardConfig` interface; register `metric-card` in `WIDGET_TYPES`; include in `WidgetConfig` union. | +| `lib/widget-helpers.ts` | Add `network` and `disk_io` cases to `extractLiveMetric` and `extractRecordMetric`; add their labels and unit hints. | +| `components/dashboard/widget-renderer.tsx` | Add `case 'metric-card'` rendering ``. | +| `components/dashboard/widget-picker.tsx` | Add picker entry under Real-time. | +| `components/dashboard/widget-config-dialog.tsx` | Add config form section for `metric-card`. | +| `apps/web/public/locales/{en,zh}/dashboard.json` | Add `metric_card.*` keys for labels, sub-card captions, delta suffixes. | +| `components/dashboard/dashboard-editor-view.test.tsx` | Add the new widget id to existing fixtures if they enumerate types. | + +No backend or `crates/` changes. Existing `/api/records` (consumed by `useServerRecords`) provides all needed data. + +## 9. Testing + +Unit (`metric-card.test.tsx`): + +- Renders header, value, delta, sparkline, and sub-cards for each of the four metrics. +- Computes `peak` and `avg` correctly from a stubbed records list. +- Computes 1h `delta` and chooses correct unit (`pp` for cpu/memory, `%` for network/disk_io). +- Shows skeleton during loading. +- Shows `—` placeholders when the server is offline and history is empty. + +`use-metric-series.test.ts`: + +- Splices a live tick onto the history tail. +- Handles empty / sparse records without crashing. + +Manual visual verification (per the project convention for UI work): open the dashboard, add four `metric-card` instances (one per metric) bound to the test VPS, and confirm: + +- Value updates every second. +- Sparkline scrolls. +- Peak/avg stay stable across renders. +- Delta direction matches recent load change. + +## 10. Open Questions / Risks + +- **Sparkline density at min size (3×3):** at the smallest allowed footprint the sub-cards may crowd the chart. Mitigation: collapse the two sub-cards to a single line of `peak · avg` text when computed inner height drops below a threshold. +- **`disk_io` history shape:** confirm during implementation that `buildMergedDiskIoSeries` is reusable as a single-series source (sum of read+write); if not, add a small `extractDiskIoTotal(record)` helper next to it. +- **Locale-specific unit formatting:** `formatSpeed` already handles `MB/s` style display; verify it matches the typography weight used by the primary value or override with a tabular-nums class. From 7303874bf372fdcbfcd4eeea3864bd8f94cc2aeb Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:05:58 +0800 Subject: [PATCH 02/21] docs: implementation plan for metric-card widget --- .../plans/2026-05-27-metric-card-widget.md | 1276 +++++++++++++++++ 1 file changed, 1276 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-27-metric-card-widget.md diff --git a/docs/superpowers/plans/2026-05-27-metric-card-widget.md b/docs/superpowers/plans/2026-05-27-metric-card-widget.md new file mode 100644 index 00000000..deb26aa2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-metric-card-widget.md @@ -0,0 +1,1276 @@ +# Metric Card Widget 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 a new `metric-card` dashboard widget that shows current value, 1h delta, sparkline, and 24h peak/avg sub-stats for CPU / memory / network / disk I/O on a single server. + +**Architecture:** New React widget under `apps/web/src/components/dashboard/widgets/metric-card/`. Data comes from existing `useServerRecords(server_id, 24, '5m')` history endpoint plus live `ServerMetrics` ticks spliced onto the tail. Peak / avg / 1h-delta derived in a dedicated hook. Wired into the existing widget renderer / picker / config-dialog system. No backend changes. + +**Tech Stack:** React 19, TanStack Query, recharts ``, shadcn/ui primitives, vitest, react-i18next, Tailwind v4. + +**Spec:** `docs/superpowers/specs/2026-05-27-metric-card-widget-design.md` + +--- + +## File Structure + +**Create:** +- `apps/web/src/components/dashboard/widgets/metric-card/metric-card-config.ts` — per-metric accent / icon / formatter map +- `apps/web/src/components/dashboard/widgets/metric-card/metric-card-header.tsx` +- `apps/web/src/components/dashboard/widgets/metric-card/metric-card-value.tsx` +- `apps/web/src/components/dashboard/widgets/metric-card/metric-card-sparkline.tsx` +- `apps/web/src/components/dashboard/widgets/metric-card/metric-card-stats.tsx` +- `apps/web/src/components/dashboard/widgets/metric-card.tsx` — entry component +- `apps/web/src/components/dashboard/widgets/metric-card.test.tsx` +- `apps/web/src/hooks/use-metric-series.ts` — derives sparkline + peak/avg/delta +- `apps/web/src/hooks/use-metric-series.test.ts` + +**Modify:** +- `apps/web/src/lib/widget-types.ts` — add `MetricCardConfig`, register in `WIDGET_TYPES`, add to `WidgetConfig` union +- `apps/web/src/lib/widget-helpers.ts` — add `network` / `disk_io` cases to `extractLiveMetric` and `extractRecordMetric` +- `apps/web/src/components/dashboard/widget-renderer.tsx` — add `case 'metric-card'` +- `apps/web/src/components/dashboard/widget-picker.tsx` — register icon +- `apps/web/src/components/dashboard/widget-config-dialog.tsx` — add `MetricCardForm` +- `apps/web/src/locales/en/dashboard.json` — `widgetPicker.types.metric-card.*` + `metricCard.*` keys +- `apps/web/src/locales/zh/dashboard.json` — same keys (Chinese) + +--- + +## Task 1: Extend widget-helpers with network / disk_io metrics + +**Files:** +- Modify: `apps/web/src/lib/widget-helpers.ts` + +The metric-card widget needs `network` (in+out throughput) and `disk_io` (read+write throughput) as first-class metric keys. The codebase already has `bandwidth` as an alias on live data and `net_in_speed` / `net_out_speed` on records; for disk I/O we need `disk_read_speed` + `disk_write_speed` from the record. We add these so downstream code (and any future widget) can share them. + +- [ ] **Step 1: Inspect the record schema** + +Run: `grep -n "disk_read_speed\|disk_write_speed\|net_in_speed\|net_out_speed" apps/web/src/lib/api-schema.ts` +Expected: confirm `ServerMetricRecord` includes those four fields. If `disk_read_speed` / `disk_write_speed` are absent, check `apps/web/src/lib/disk-io.ts` to see how `buildMergedDiskIoSeries` reads them and use the same field names. + +- [ ] **Step 2: Add `network` and `disk_io` to `extractLiveMetric`** + +In `apps/web/src/lib/widget-helpers.ts`, extend the switch in `extractLiveMetric`: + +```ts +case 'network': +case 'bandwidth': + return server.net_in_speed + server.net_out_speed +case 'disk_io': + return (server.disk_read_speed ?? 0) + (server.disk_write_speed ?? 0) +``` + +Place the `'network'` case alongside `'bandwidth'` so both keys behave identically. The `disk_io` case uses nullish coalescing because older agents may omit those fields. + +- [ ] **Step 3: Add `network` and `disk_io` to `extractRecordMetric`** + +Extend the switch in `extractRecordMetric`: + +```ts +case 'network': + return record.net_in_speed + record.net_out_speed +case 'disk_io': + return (record.disk_read_speed ?? 0) + (record.disk_write_speed ?? 0) +``` + +- [ ] **Step 4: Add labels** + +Extend `METRIC_LABELS` with: + +```ts +network: 'Network', +disk_io: 'Disk I/O' +``` + +- [ ] **Step 5: Run type check** + +Run: `cd apps/web && bun run typecheck` +Expected: no errors. If `ServerMetrics` is missing `disk_read_speed` / `disk_write_speed`, add the optional fields to its type (`apps/web/src/hooks/use-servers-ws.ts`) but DO NOT change the WS payload — they were already being sent by the agent for the disk-io widget, this is just declaring them in the TS type. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/lib/widget-helpers.ts apps/web/src/hooks/use-servers-ws.ts +git commit -m "feat(web): add network and disk_io metric extractors" +``` + +--- + +## Task 2: Register widget type and config schema + +**Files:** +- Modify: `apps/web/src/lib/widget-types.ts` + +- [ ] **Step 1: Add `MetricCardConfig` interface** + +In `apps/web/src/lib/widget-types.ts` (after `StatNumberConfig` so related types are co-located), add: + +```ts +export type MetricCardMetric = 'cpu' | 'memory' | 'network' | 'disk_io' + +export interface MetricCardConfig { + metric: MetricCardMetric + server_id: string + label?: string +} +``` + +- [ ] **Step 2: Register `metric-card` in `WIDGET_TYPES`** + +Add an entry (preserving the existing `as const satisfies` shape): + +```ts +{ + id: 'metric-card', + label: 'Metric Card', + category: 'Real-time', + defaultW: 4, + defaultH: 4, + minW: 3, + minH: 3, + maxW: 6, + maxH: 6 +} +``` + +Place it right after the `stat-number` entry so picker ordering puts the richer card next to its lighter sibling. + +- [ ] **Step 3: Add to `WidgetConfig` union** + +Update the `WidgetConfig` union to include `| MetricCardConfig`. + +- [ ] **Step 4: Run type check** + +Run: `cd apps/web && bun run typecheck` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/lib/widget-types.ts +git commit -m "feat(web): register metric-card widget type" +``` + +--- + +## Task 3: Per-metric configuration map + +**Files:** +- Create: `apps/web/src/components/dashboard/widgets/metric-card/metric-card-config.ts` + +This module owns everything that varies per metric: icon, accent color token, value formatter, delta unit, and whether deltas use semantic (high=bad) or neutral coloring. Keeping it isolated means rendering components stay metric-agnostic. + +- [ ] **Step 1: Create the file** + +```ts +import { Cpu, HardDriveDownload, MemoryStick, Network } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import { formatSpeed } from '@/lib/utils' +import type { MetricCardMetric } from '@/lib/widget-types' + +export type DeltaUnit = 'pp' | 'percent' +export type DeltaTone = 'semantic' | 'neutral' + +export interface MetricCardSpec { + icon: LucideIcon + accent: string // CSS variable name e.g. '--chart-4' + formatValue: (n: number) => string + deltaUnit: DeltaUnit + deltaTone: DeltaTone + labelKey: string // i18n key under 'metricCard.metric.*' +} + +const formatPercent = (n: number) => `${n.toFixed(1)}%` + +export const METRIC_CARD_SPECS: Record = { + cpu: { + icon: Cpu, + accent: '--chart-4', + formatValue: formatPercent, + deltaUnit: 'pp', + deltaTone: 'semantic', + labelKey: 'metricCard.metric.cpu' + }, + memory: { + icon: MemoryStick, + accent: '--chart-3', + formatValue: formatPercent, + deltaUnit: 'pp', + deltaTone: 'semantic', + labelKey: 'metricCard.metric.memory' + }, + network: { + icon: Network, + accent: '--chart-1', + formatValue: formatSpeed, + deltaUnit: 'percent', + deltaTone: 'neutral', + labelKey: 'metricCard.metric.network' + }, + disk_io: { + icon: HardDriveDownload, + accent: '--chart-2', + formatValue: formatSpeed, + deltaUnit: 'percent', + deltaTone: 'neutral', + labelKey: 'metricCard.metric.diskIo' + } +} +``` + +Verify `formatSpeed` exists in `lib/utils.ts` (it is used by `disk-io.tsx`). If absent, fall back to `formatBytes` and append `/s`. + +- [ ] **Step 2: Run type check** + +Run: `cd apps/web && bun run typecheck` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/dashboard/widgets/metric-card/metric-card-config.ts +git commit -m "feat(web): metric-card per-metric spec map" +``` + +--- + +## Task 4: `use-metric-series` hook (TDD) + +**Files:** +- Create: `apps/web/src/hooks/use-metric-series.ts` +- Test: `apps/web/src/hooks/use-metric-series.test.ts` + +Pure derivation hook. Takes records + live server + metric; returns `{ points, current, peak, avg, oneHourDelta }` where: + +- `points`: `Array<{ t: number; v: number }>` sorted by `t`, with the most recent live tick appended if newer than the last record +- `current`: the most recent value (live tick if available, else last record) +- `peak`: `Math.max(points.value)` or `null` for empty series +- `avg`: arithmetic mean or `null` +- `oneHourDelta`: `current - value_at(now - 1h)` or `null` if no sample is older than 55 minutes + +- [ ] **Step 1: Write the failing tests** + +Create `apps/web/src/hooks/use-metric-series.test.ts`: + +```ts +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { ServerMetricRecord } from '@/lib/api-schema' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { useMetricSeries } from './use-metric-series' + +function record(time: string, cpu: number): ServerMetricRecord { + return { + time, + cpu, + mem_used: 0, + disk_used: 0, + load1: 0, + load5: 0, + load15: 0, + net_in_speed: 0, + net_out_speed: 0, + disk_read_speed: 0, + disk_write_speed: 0 + } as unknown as ServerMetricRecord +} + +function server(overrides: Partial = {}): ServerMetrics { + return { + id: 's1', + name: 'srv', + online: true, + cpu: 50, + mem_used: 0, + mem_total: 0, + disk_used: 0, + disk_total: 0, + swap_used: 0, + swap_total: 0, + net_in_speed: 0, + net_out_speed: 0, + disk_read_speed: 0, + disk_write_speed: 0, + ...overrides + } as unknown as ServerMetrics +} + +describe('useMetricSeries', () => { + it('returns null stats when records are empty', () => { + const { result } = renderHook(() => + useMetricSeries({ records: [], server: server(), metric: 'cpu' }) + ) + expect(result.current.points).toHaveLength(1) // live tick still appended + expect(result.current.peak).toBe(50) + expect(result.current.avg).toBe(50) + expect(result.current.oneHourDelta).toBeNull() + }) + + it('computes peak and avg from records + live tick', () => { + const now = Date.now() + const records = [ + record(new Date(now - 60 * 60_000).toISOString(), 20), + record(new Date(now - 30 * 60_000).toISOString(), 60), + record(new Date(now - 5 * 60_000).toISOString(), 40) + ] + const { result } = renderHook(() => + useMetricSeries({ records, server: server({ cpu: 80 }), metric: 'cpu' }) + ) + expect(result.current.current).toBe(80) + expect(result.current.peak).toBe(80) + expect(result.current.avg).toBeCloseTo((20 + 60 + 40 + 80) / 4) + }) + + it('computes 1h delta when a sample exists near 1h ago', () => { + const now = Date.now() + const records = [ + record(new Date(now - 62 * 60_000).toISOString(), 30), + record(new Date(now - 1 * 60_000).toISOString(), 45) + ] + const { result } = renderHook(() => + useMetricSeries({ records, server: server({ cpu: 50 }), metric: 'cpu' }) + ) + expect(result.current.oneHourDelta).toBeCloseTo(50 - 30) + }) + + it('returns null delta when no sample is old enough', () => { + const now = Date.now() + const records = [record(new Date(now - 5 * 60_000).toISOString(), 30)] + const { result } = renderHook(() => + useMetricSeries({ records, server: server({ cpu: 32 }), metric: 'cpu' }) + ) + expect(result.current.oneHourDelta).toBeNull() + }) + + it('aggregates network as in+out', () => { + const { result } = renderHook(() => + useMetricSeries({ + records: [], + server: server({ net_in_speed: 1000, net_out_speed: 2000 }), + metric: 'network' + }) + ) + expect(result.current.current).toBe(3000) + }) +}) +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +Run: `cd apps/web && bun run test use-metric-series` +Expected: FAIL with module-not-found for `./use-metric-series`. + +- [ ] **Step 3: Implement the hook** + +Create `apps/web/src/hooks/use-metric-series.ts`: + +```ts +import { useMemo } from 'react' +import type { ServerMetricRecord } from '@/lib/api-schema' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { extractLiveMetric, extractRecordMetric } from '@/lib/widget-helpers' + +export interface MetricSeriesPoint { + t: number + v: number +} + +export interface MetricSeries { + points: MetricSeriesPoint[] + current: number + peak: number | null + avg: number | null + oneHourDelta: number | null +} + +interface Params { + records: ServerMetricRecord[] | undefined + server: ServerMetrics | undefined + metric: string +} + +const ONE_HOUR_MS = 60 * 60_000 +const DELTA_WINDOW_MS = 5 * 60_000 // accept samples within ±5 min of the 1h mark + +export function useMetricSeries({ records, server, metric }: Params): MetricSeries { + return useMemo(() => { + const points: MetricSeriesPoint[] = [] + + if (records) { + for (const r of records) { + const t = new Date(r.time).getTime() + if (Number.isFinite(t)) { + points.push({ t, v: extractRecordMetric(r, metric, server) }) + } + } + } + + points.sort((a, b) => a.t - b.t) + + const liveValue = server ? extractLiveMetric(server, metric) : 0 + const liveTick: MetricSeriesPoint = { t: Date.now(), v: liveValue } + + const last = points.at(-1) + if (!last || liveTick.t > last.t) { + points.push(liveTick) + } + + if (points.length === 0) { + return { points, current: 0, peak: null, avg: null, oneHourDelta: null } + } + + const current = points.at(-1)?.v ?? 0 + let peak = points[0].v + let sum = 0 + for (const p of points) { + if (p.v > peak) peak = p.v + sum += p.v + } + const avg = sum / points.length + + const target = liveTick.t - ONE_HOUR_MS + let oneHourDelta: number | null = null + let bestDist = Number.POSITIVE_INFINITY + for (const p of points) { + const dist = Math.abs(p.t - target) + if (dist <= DELTA_WINDOW_MS && dist < bestDist) { + bestDist = dist + oneHourDelta = current - p.v + } + } + + return { points, current, peak, avg, oneHourDelta } + }, [records, server, metric]) +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +Run: `cd apps/web && bun run test use-metric-series` +Expected: PASS (5/5). + +- [ ] **Step 5: Run lint + typecheck** + +Run: `cd apps/web && bun run typecheck && bun x ultracite check src/hooks/use-metric-series.ts src/hooks/use-metric-series.test.ts` +Expected: no errors / warnings. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/hooks/use-metric-series.ts apps/web/src/hooks/use-metric-series.test.ts +git commit -m "feat(web): use-metric-series hook for metric-card" +``` + +--- + +## Task 5: Subcomponent — `MetricCardHeader` + +**Files:** +- Create: `apps/web/src/components/dashboard/widgets/metric-card/metric-card-header.tsx` + +Layout: icon chip (left) → metric label (left, bold) → server name (right, muted, truncated). + +- [ ] **Step 1: Implement** + +```tsx +import type { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface MetricCardHeaderProps { + Icon: LucideIcon + label: string + serverName: string + accent: string +} + +export function MetricCardHeader({ Icon, label, serverName, accent }: MetricCardHeaderProps) { + return ( +
+
+ +
+ {label} + {serverName} +
+ ) +} +``` + +- [ ] **Step 2: Typecheck** + +Run: `cd apps/web && bun run typecheck` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/dashboard/widgets/metric-card/metric-card-header.tsx +git commit -m "feat(web): metric-card header subcomponent" +``` + +--- + +## Task 6: Subcomponent — `MetricCardValue` + +**Files:** +- Create: `apps/web/src/components/dashboard/widgets/metric-card/metric-card-value.tsx` + +Big formatted value + delta row. Delta row is `▲/▼ {magnitude}{unit} · {pastLabel}` or `—` if delta is null. + +- [ ] **Step 1: Implement** + +```tsx +import { TrendingDown, TrendingUp } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { DeltaTone, DeltaUnit } from './metric-card-config' + +interface MetricCardValueProps { + formattedValue: string + delta: number | null + deltaUnit: DeltaUnit + deltaTone: DeltaTone + pastLabel: string // localized e.g. "past 1h" +} + +function formatDelta(delta: number, unit: DeltaUnit): string { + const sign = delta >= 0 ? '+' : '−' + const magnitude = Math.abs(delta) + if (unit === 'pp') { + return `${sign}${magnitude.toFixed(1)}pp` + } + return `${sign}${magnitude.toFixed(0)}%` +} + +function deltaColor(delta: number, tone: DeltaTone): string { + if (tone === 'neutral') return 'text-muted-foreground' + if (delta === 0) return 'text-muted-foreground' + return delta > 0 ? 'text-destructive' : 'text-emerald-500' +} + +export function MetricCardValue({ + formattedValue, + delta, + deltaUnit, + deltaTone, + pastLabel +}: MetricCardValueProps) { + const Trend = delta !== null && delta < 0 ? TrendingDown : TrendingUp + return ( +
+

+ {formattedValue} +

+

+ {delta === null ? ( + + ) : ( + <> + + {formatDelta(delta, deltaUnit)} + + )} + · {pastLabel} +

+
+ ) +} +``` + +- [ ] **Step 2: Typecheck** + +Run: `cd apps/web && bun run typecheck` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/dashboard/widgets/metric-card/metric-card-value.tsx +git commit -m "feat(web): metric-card value + delta subcomponent" +``` + +--- + +## Task 7: Subcomponent — `MetricCardSparkline` + +**Files:** +- Create: `apps/web/src/components/dashboard/widgets/metric-card/metric-card-sparkline.tsx` + +Recharts `` with no axes, no grid, no tooltip — pure decorative trend. Color via CSS var. + +- [ ] **Step 1: Implement** + +```tsx +import { useId } from 'react' +import { Area, AreaChart, ResponsiveContainer } from 'recharts' +import type { MetricSeriesPoint } from '@/hooks/use-metric-series' + +interface MetricCardSparklineProps { + points: MetricSeriesPoint[] + accent: string +} + +export function MetricCardSparkline({ points, accent }: MetricCardSparklineProps) { + const gradientId = useId() + const color = `var(${accent})` + + if (points.length < 2) { + return
+ } + + return ( + + + + + + + + + + + + ) +} +``` + +- [ ] **Step 2: Typecheck** + +Run: `cd apps/web && bun run typecheck` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/dashboard/widgets/metric-card/metric-card-sparkline.tsx +git commit -m "feat(web): metric-card sparkline subcomponent" +``` + +--- + +## Task 8: Subcomponent — `MetricCardStats` + +**Files:** +- Create: `apps/web/src/components/dashboard/widgets/metric-card/metric-card-stats.tsx` + +Two equal rounded surfaces side by side. Each shows caption + value. Falls back to `—` for null. + +- [ ] **Step 1: Implement** + +```tsx +interface StatProps { + caption: string + value: string +} + +function Stat({ caption, value }: StatProps) { + return ( +
+

{caption}

+

+ {value} +

+
+ ) +} + +interface MetricCardStatsProps { + peakCaption: string + avgCaption: string + peak: string + avg: string +} + +export function MetricCardStats({ peakCaption, avgCaption, peak, avg }: MetricCardStatsProps) { + return ( +
+ + +
+ ) +} +``` + +- [ ] **Step 2: Typecheck** + +Run: `cd apps/web && bun run typecheck` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/dashboard/widgets/metric-card/metric-card-stats.tsx +git commit -m "feat(web): metric-card stats subcomponent" +``` + +--- + +## Task 9: Top-level `MetricCardWidget` (TDD) + +**Files:** +- Create: `apps/web/src/components/dashboard/widgets/metric-card.tsx` +- Test: `apps/web/src/components/dashboard/widgets/metric-card.test.tsx` + +Composes the four subcomponents, wires data via `useServerRecords` + `useMetricSeries`, handles loading / offline states. + +- [ ] **Step 1: Write tests** + +Create `apps/web/src/components/dashboard/widgets/metric-card.test.tsx`: + +```tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { MetricCardWidget } from './metric-card' + +const translations: Record = { + 'metricCard.metric.cpu': 'CPU', + 'metricCard.metric.memory': 'Memory', + 'metricCard.metric.network': 'Network', + 'metricCard.metric.diskIo': 'Disk I/O', + 'metricCard.past1h': 'past 1h', + 'metricCard.peak': '24H PEAK', + 'metricCard.avg': '24H AVG', + 'metricCard.unknownServer': 'Unknown server' +} + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => translations[key] ?? key + }) +})) + +vi.mock('@/hooks/use-api', () => ({ + useServerRecords: () => ({ data: [], isLoading: false }) +})) + +function makeServer(overrides: Partial = {}): ServerMetrics { + return { + id: 's1', + name: 'web-1', + online: true, + cpu: 42.5, + mem_used: 4_000_000_000, + mem_total: 8_000_000_000, + disk_used: 0, + disk_total: 0, + swap_used: 0, + swap_total: 0, + net_in_speed: 0, + net_out_speed: 0, + disk_read_speed: 0, + disk_write_speed: 0, + ...overrides + } as unknown as ServerMetrics +} + +function wrap(node: ReactNode) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return {node} +} + +describe('MetricCardWidget', () => { + it('renders the CPU value', () => { + render( + wrap( + + ) + ) + expect(screen.getByTestId('metric-card-value')).toHaveTextContent('42.5%') + }) + + it('shows unknown server placeholder when server_id is missing', () => { + render( + wrap( + + ) + ) + expect(screen.getByText('Unknown server')).toBeInTheDocument() + }) + + it('renders dash for delta when no history is available', () => { + render( + wrap( + + ) + ) + expect(screen.getByTestId('metric-card-delta')).toHaveTextContent('—') + }) + + it('uses the custom label override', () => { + render( + wrap( + + ) + ) + expect(screen.getByText('RAM Pressure')).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +Run: `cd apps/web && bun run test metric-card.test` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement the widget** + +Create `apps/web/src/components/dashboard/widgets/metric-card.tsx`: + +```tsx +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useServerRecords } from '@/hooks/use-api' +import { useMetricSeries } from '@/hooks/use-metric-series' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { cn } from '@/lib/utils' +import type { MetricCardConfig } from '@/lib/widget-types' +import { METRIC_CARD_SPECS } from './metric-card/metric-card-config' +import { MetricCardHeader } from './metric-card/metric-card-header' +import { MetricCardSparkline } from './metric-card/metric-card-sparkline' +import { MetricCardStats } from './metric-card/metric-card-stats' +import { MetricCardValue } from './metric-card/metric-card-value' + +interface MetricCardWidgetProps { + config: MetricCardConfig + servers: ServerMetrics[] +} + +const HISTORY_HOURS = 24 +const HISTORY_INTERVAL = '5m' + +export function MetricCardWidget({ config, servers }: MetricCardWidgetProps) { + const { t } = useTranslation('dashboard') + const spec = METRIC_CARD_SPECS[config.metric] + const server = useMemo(() => servers.find((s) => s.id === config.server_id), [servers, config.server_id]) + + const { data: records } = useServerRecords(config.server_id, HISTORY_HOURS, HISTORY_INTERVAL, { + enabled: Boolean(config.server_id) && Boolean(server) + }) + + const series = useMetricSeries({ records, server, metric: config.metric }) + + if (!server) { + return ( +
+ {t('metricCard.unknownServer')} +
+ ) + } + + const label = config.label ?? t(spec.labelKey) + const formattedValue = spec.formatValue(series.current) + const formattedPeak = series.peak === null ? '—' : spec.formatValue(series.peak) + const formattedAvg = series.avg === null ? '—' : spec.formatValue(series.avg) + + const dimmed = !server.online + + return ( +
+ + +
+ +
+ +
+ ) +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +Run: `cd apps/web && bun run test metric-card.test` +Expected: PASS (4/4). + +- [ ] **Step 5: Typecheck + lint** + +Run: `cd apps/web && bun run typecheck && bun x ultracite check src/components/dashboard/widgets/metric-card.tsx src/components/dashboard/widgets/metric-card.test.tsx src/components/dashboard/widgets/metric-card/` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/components/dashboard/widgets/metric-card.tsx apps/web/src/components/dashboard/widgets/metric-card.test.tsx apps/web/src/components/dashboard/widgets/metric-card/ +git commit -m "feat(web): MetricCardWidget composing subcomponents" +``` + +--- + +## Task 10: Wire into widget-renderer + +**Files:** +- Modify: `apps/web/src/components/dashboard/widget-renderer.tsx` + +- [ ] **Step 1: Add import** + +At the top of `widget-renderer.tsx` next to other widget imports: + +```ts +import { MetricCardWidget } from './widgets/metric-card' +``` + +And add `MetricCardConfig` to the existing `widget-types` type import. + +- [ ] **Step 2: Add switch case** + +After the `'stat-number'` case (around line 87), add: + +```tsx +case 'metric-card': + return +``` + +- [ ] **Step 3: Typecheck** + +Run: `cd apps/web && bun run typecheck` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/components/dashboard/widget-renderer.tsx +git commit -m "feat(web): render metric-card widget" +``` + +--- + +## Task 11: Register icon in widget-picker + +**Files:** +- Modify: `apps/web/src/components/dashboard/widget-picker.tsx` + +- [ ] **Step 1: Import the Cpu icon (or reuse existing)** + +In `widget-picker.tsx` add `Cpu` to the lucide import. + +- [ ] **Step 2: Register icon** + +In `WIDGET_ICONS`, add: + +```ts +'metric-card': Cpu, +``` + +- [ ] **Step 3: Typecheck** + +Run: `cd apps/web && bun run typecheck` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/components/dashboard/widget-picker.tsx +git commit -m "feat(web): metric-card icon in widget picker" +``` + +--- + +## Task 12: Config dialog form + +**Files:** +- Modify: `apps/web/src/components/dashboard/widget-config-dialog.tsx` + +The dialog uses a per-widget subform component (`StatNumberForm`, `GaugeForm`, etc.). Add `MetricCardForm` following the same pattern. + +- [ ] **Step 1: Add import** + +Add `MetricCardConfig`, `MetricCardMetric` to the existing `widget-types` import. + +- [ ] **Step 2: Add metric option helper** + +Near the other metric-options helpers (around line 70): + +```ts +function useMetricCardMetrics(t: (key: string) => string): { label: string; value: MetricCardMetric }[] { + return [ + { label: t('common.metrics.cpu'), value: 'cpu' }, + { label: t('common.metrics.memory'), value: 'memory' }, + { label: t('common.metrics.network'), value: 'network' }, + { label: t('common.metrics.diskIo'), value: 'disk_io' } + ] +} +``` + +- [ ] **Step 3: Add `MetricCardForm` component** + +Define a new form component near the other `*Form` definitions in the file. It mirrors `GaugeForm`: server select + metric select + optional label. + +```tsx +interface MetricCardFormProps { + config: Partial + onChange: (next: WidgetConfig) => void + servers: ServerMetrics[] + t: (key: string) => string +} + +function MetricCardForm({ config, onChange, servers, t }: MetricCardFormProps) { + const metrics = useMetricCardMetrics(t) + const metric = (config.metric ?? 'cpu') as MetricCardMetric + const serverId = config.server_id ?? '' + const label = config.label ?? '' + + return ( + <> +
+ + +
+
+ + +
+
+ + onChange({ ...config, metric, server_id: serverId, label: e.target.value })} + placeholder={t('dialogs.widgetConfig.placeholders.optionalLabel')} + value={label} + /> +
+ + ) +} +``` + +- [ ] **Step 4: Render in the dialog body** + +After the `stat-number` form block (around line 678), add: + +```tsx +{widgetType === 'metric-card' && ( + } + onChange={setConfig} + servers={servers} + t={t} + /> +)} +``` + +- [ ] **Step 5: Add `metric-card` to `i18n` common metrics if missing** + +Verify `common.metrics.diskIo` and `common.metrics.network` exist in `apps/web/src/locales/{en,zh}/common.json` (or wherever `common.metrics.*` resolves). If absent, add: + +```jsonc +// en/common.json +"network": "Network", +"diskIo": "Disk I/O" +// zh/common.json +"network": "网络", +"diskIo": "磁盘 I/O" +``` + +Also ensure `dialogs.widgetConfig.fields.metric`, `.label`, `.server`, `.placeholders.selectServer`, `.placeholders.optionalLabel` exist — they are reused by other widget forms, so they should already be present. + +- [ ] **Step 6: Typecheck** + +Run: `cd apps/web && bun run typecheck` +Expected: no errors. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/components/dashboard/widget-config-dialog.tsx apps/web/src/locales/ +git commit -m "feat(web): metric-card config form" +``` + +--- + +## Task 13: i18n strings + +**Files:** +- Modify: `apps/web/src/locales/en/dashboard.json` +- Modify: `apps/web/src/locales/zh/dashboard.json` + +- [ ] **Step 1: Add English keys** + +Inside `apps/web/src/locales/en/dashboard.json`, add at the top level: + +```jsonc +"metricCard": { + "metric": { + "cpu": "CPU", + "memory": "Memory", + "network": "Network", + "diskIo": "Disk I/O" + }, + "past1h": "past 1h", + "peak": "24H PEAK", + "avg": "24H AVG", + "unknownServer": "Unknown server" +} +``` + +And inside the existing `widgetPicker.types` object, add: + +```jsonc +"metric-card": { + "label": "Metric Card", + "description": "Current value + 1h delta + 24h sparkline & stats" +} +``` + +- [ ] **Step 2: Add Chinese keys (mirror structure)** + +In `apps/web/src/locales/zh/dashboard.json`: + +```jsonc +"metricCard": { + "metric": { + "cpu": "CPU", + "memory": "内存", + "network": "网络", + "diskIo": "磁盘 I/O" + }, + "past1h": "过去 1 小时", + "peak": "24H 峰值", + "avg": "24H 均值", + "unknownServer": "未知服务器" +} +``` + +And under `widgetPicker.types`: + +```jsonc +"metric-card": { + "label": "指标卡片", + "description": "当前值 + 1h 变化 + 24h sparkline 与峰值/均值" +} +``` + +- [ ] **Step 3: Typecheck** + +Run: `cd apps/web && bun run typecheck` +Expected: no errors (JSON locale files do not type-check, but renderer references will). + +- [ ] **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): i18n strings for metric-card widget" +``` + +--- + +## Task 14: Full repo verification + +- [ ] **Step 1: Run web tests** + +Run: `cd apps/web && bun run test` +Expected: all tests pass, including the new `use-metric-series` and `metric-card` suites. + +- [ ] **Step 2: Run typecheck** + +Run: `cd apps/web && bun run typecheck` +Expected: no errors. + +- [ ] **Step 3: Run lint** + +Run: `cd apps/web && bun x ultracite check` +Expected: no errors. If fixable issues exist run `bun x ultracite fix` and re-run check. + +- [ ] **Step 4: Visual verification check** + +Per project preference [[feedback_visual_verification]]: this is a UI feature. Honestly report that visual verification was not performed in this environment if no browser tooling is available. Otherwise, run `make web-dev-prod` (or `bun run dev` from `apps/web`), open the dashboard editor, add four `metric-card` widgets (one per metric) bound to a test server, and confirm: + +- Value updates every second. +- Sparkline scrolls without flicker. +- 24h peak/avg stay stable as live ticks arrive. +- Delta sign and color match (rising CPU = red, falling = green; network = neutral). +- Switching metric in the config dialog updates the icon, accent, and formatting. + +- [ ] **Step 5: Commit any lint-fix-only diffs** + +If `ultracite fix` changed anything, commit it: + +```bash +git add -A +git commit -m "chore(web): lint fixes for metric-card" +``` + +(Skip this step if there are no changes.) + +--- + +## Notes on style + +- Default to no comments. The subcomponent split, file names, and identifier names should carry the WHAT. +- Follow [[feedback_no_claude_attribution]]: no "Generated with Claude" or co-author lines on any commit. +- Honor [[feedback_git_push]]: commit locally only — do NOT push. +- Don't widen API surfaces beyond what the spec requires. The hook returns exactly the four derived values; the widget exposes one config shape. From a3760676d98f49935154a5d1407609c780f272cd Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:07:14 +0800 Subject: [PATCH 03/21] feat(web): add network and disk_io metric extractors --- apps/web/src/lib/widget-helpers.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/widget-helpers.ts b/apps/web/src/lib/widget-helpers.ts index cd0391d5..fb0144cf 100644 --- a/apps/web/src/lib/widget-helpers.ts +++ b/apps/web/src/lib/widget-helpers.ts @@ -1,5 +1,6 @@ import type { ServerMetrics } from '@/hooks/use-servers-ws' import type { ServerMetricRecord, UptimeDailyEntry } from '@/lib/api-schema' +import { parseDiskIoJson } from './disk-io' // --- Shared metric labels --- @@ -13,7 +14,9 @@ export const METRIC_LABELS: Record = { load15: 'Load (15m)', net_in: 'Network In', net_out: 'Network Out', - bandwidth: 'Bandwidth' + bandwidth: 'Bandwidth', + network: 'Network', + disk_io: 'Disk I/O' } export const METRIC_UNITS: Record = { @@ -39,12 +42,24 @@ export function extractLiveMetric(server: ServerMetrics, metric: string): number case 'swap': return server.swap_total > 0 ? (server.swap_used / server.swap_total) * 100 : 0 case 'bandwidth': + case 'network': return server.net_in_speed + server.net_out_speed + case 'disk_io': + return server.disk_read_bytes_per_sec + server.disk_write_bytes_per_sec default: return 0 } } +function sumDiskIoJson(raw: string | null | undefined): number { + const samples = parseDiskIoJson(raw) + let total = 0 + for (const sample of samples) { + total += sample.read_bytes_per_sec + sample.write_bytes_per_sec + } + return total +} + export function extractRecordMetric(record: ServerMetricRecord, metric: string, server?: ServerMetrics): number { switch (metric) { case 'cpu': @@ -63,6 +78,10 @@ export function extractRecordMetric(record: ServerMetricRecord, metric: string, return record.net_in_speed case 'net_out': return record.net_out_speed + case 'network': + return record.net_in_speed + record.net_out_speed + case 'disk_io': + return sumDiskIoJson(record.disk_io_json) default: return 0 } From c0c3e0c6bd6f6766f84ef817cb113178081fa102 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:07:23 +0800 Subject: [PATCH 04/21] feat(web): register metric-card widget type --- apps/web/src/lib/widget-types.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/web/src/lib/widget-types.ts b/apps/web/src/lib/widget-types.ts index 77c7d88b..e33d9c72 100644 --- a/apps/web/src/lib/widget-types.ts +++ b/apps/web/src/lib/widget-types.ts @@ -24,6 +24,17 @@ export const WIDGET_TYPES = [ maxW: 2, maxH: 1 }, + { + id: 'metric-card', + label: 'Metric Card', + category: 'Real-time', + defaultW: 4, + defaultH: 4, + minW: 3, + minH: 3, + maxW: 6, + maxH: 6 + }, { id: 'server-cards', label: 'Server Cards', category: 'Real-time', defaultW: 12, defaultH: 6, minW: 4, minH: 3 }, { id: 'gauge', label: 'Gauge', category: 'Real-time', defaultW: 2, defaultH: 2, minW: 2, minH: 2, maxW: 6, maxH: 6 }, { @@ -129,6 +140,14 @@ export interface StatNumberConfig { unit?: string } +export type MetricCardMetric = 'cpu' | 'memory' | 'network' | 'disk_io' + +export interface MetricCardConfig { + label?: string + metric: MetricCardMetric + server_id: string +} + export interface ServerCardsConfig { columns?: number server_ids?: string[] @@ -196,6 +215,7 @@ export interface UptimeTimelineConfig { export type WidgetConfig = | StatNumberConfig + | MetricCardConfig | ServerCardsConfig | GaugeConfig | LineChartConfig From 6f1f8a81d0d7fbf956ac93520c7f766932c9e48b Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:08:39 +0800 Subject: [PATCH 05/21] feat(web): metric-card per-metric spec map --- .../widgets/metric-card/metric-card-config.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/web/src/components/dashboard/widgets/metric-card/metric-card-config.ts diff --git a/apps/web/src/components/dashboard/widgets/metric-card/metric-card-config.ts b/apps/web/src/components/dashboard/widgets/metric-card/metric-card-config.ts new file mode 100644 index 00000000..ecb220ce --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/metric-card/metric-card-config.ts @@ -0,0 +1,53 @@ +import type { LucideIcon } from 'lucide-react' +import { Cpu, HardDriveDownload, MemoryStick, Network } from 'lucide-react' +import { formatSpeed } from '@/lib/utils' +import type { MetricCardMetric } from '@/lib/widget-types' + +export type DeltaUnit = 'pp' | 'percent' +export type DeltaTone = 'semantic' | 'neutral' + +export interface MetricCardSpec { + accent: string + deltaTone: DeltaTone + deltaUnit: DeltaUnit + formatValue: (n: number) => string + icon: LucideIcon + labelKey: string +} + +const formatPercent = (n: number) => `${n.toFixed(1)}%` + +export const METRIC_CARD_SPECS: Record = { + cpu: { + icon: Cpu, + accent: '--chart-4', + formatValue: formatPercent, + deltaUnit: 'pp', + deltaTone: 'semantic', + labelKey: 'metricCard.metric.cpu' + }, + memory: { + icon: MemoryStick, + accent: '--chart-3', + formatValue: formatPercent, + deltaUnit: 'pp', + deltaTone: 'semantic', + labelKey: 'metricCard.metric.memory' + }, + network: { + icon: Network, + accent: '--chart-1', + formatValue: formatSpeed, + deltaUnit: 'percent', + deltaTone: 'neutral', + labelKey: 'metricCard.metric.network' + }, + disk_io: { + icon: HardDriveDownload, + accent: '--chart-2', + formatValue: formatSpeed, + deltaUnit: 'percent', + deltaTone: 'neutral', + labelKey: 'metricCard.metric.diskIo' + } +} From f25faad68a1de5443d70892e424992ff29f963f8 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:14:07 +0800 Subject: [PATCH 06/21] feat(web): use-metric-series hook for metric-card --- apps/web/src/hooks/use-metric-series.test.ts | 91 +++++++++++++++++++ apps/web/src/hooks/use-metric-series.ts | 94 ++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 apps/web/src/hooks/use-metric-series.test.ts create mode 100644 apps/web/src/hooks/use-metric-series.ts diff --git a/apps/web/src/hooks/use-metric-series.test.ts b/apps/web/src/hooks/use-metric-series.test.ts new file mode 100644 index 00000000..4b246326 --- /dev/null +++ b/apps/web/src/hooks/use-metric-series.test.ts @@ -0,0 +1,91 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import type { ServerMetricRecord } from '@/lib/api-schema' +import { useMetricSeries } from './use-metric-series' + +function record(time: string, cpu: number): ServerMetricRecord { + return { + time, + cpu, + mem_used: 0, + disk_used: 0, + load1: 0, + load5: 0, + load15: 0, + net_in_speed: 0, + net_out_speed: 0, + disk_io_json: null + } as unknown as ServerMetricRecord +} + +function server(overrides: Partial = {}): ServerMetrics { + return { + id: 's1', + name: 'srv', + online: true, + cpu: 50, + mem_used: 0, + mem_total: 0, + disk_used: 0, + disk_total: 0, + swap_used: 0, + swap_total: 0, + net_in_speed: 0, + net_out_speed: 0, + disk_read_bytes_per_sec: 0, + disk_write_bytes_per_sec: 0, + ...overrides + } as unknown as ServerMetrics +} + +describe('useMetricSeries', () => { + it('returns null stats when records are empty', () => { + const { result } = renderHook(() => useMetricSeries({ records: [], server: server(), metric: 'cpu' })) + expect(result.current.points).toHaveLength(1) + expect(result.current.peak).toBe(50) + expect(result.current.avg).toBe(50) + expect(result.current.oneHourDelta).toBeNull() + }) + + it('computes peak and avg from records + live tick', () => { + const now = Date.now() + const records = [ + record(new Date(now - 60 * 60_000).toISOString(), 20), + record(new Date(now - 30 * 60_000).toISOString(), 60), + record(new Date(now - 5 * 60_000).toISOString(), 40) + ] + const { result } = renderHook(() => useMetricSeries({ records, server: server({ cpu: 80 }), metric: 'cpu' })) + expect(result.current.current).toBe(80) + expect(result.current.peak).toBe(80) + expect(result.current.avg).toBeCloseTo((20 + 60 + 40 + 80) / 4) + }) + + it('computes 1h delta when a sample exists near 1h ago', () => { + const now = Date.now() + const records = [ + record(new Date(now - 62 * 60_000).toISOString(), 30), + record(new Date(now - 1 * 60_000).toISOString(), 45) + ] + const { result } = renderHook(() => useMetricSeries({ records, server: server({ cpu: 50 }), metric: 'cpu' })) + expect(result.current.oneHourDelta).toBeCloseTo(50 - 30) + }) + + it('returns null delta when no sample is old enough', () => { + const now = Date.now() + const records = [record(new Date(now - 5 * 60_000).toISOString(), 30)] + const { result } = renderHook(() => useMetricSeries({ records, server: server({ cpu: 32 }), metric: 'cpu' })) + expect(result.current.oneHourDelta).toBeNull() + }) + + it('aggregates network as in+out', () => { + const { result } = renderHook(() => + useMetricSeries({ + records: [], + server: server({ net_in_speed: 1000, net_out_speed: 2000 }), + metric: 'network' + }) + ) + expect(result.current.current).toBe(3000) + }) +}) diff --git a/apps/web/src/hooks/use-metric-series.ts b/apps/web/src/hooks/use-metric-series.ts new file mode 100644 index 00000000..cfab59a3 --- /dev/null +++ b/apps/web/src/hooks/use-metric-series.ts @@ -0,0 +1,94 @@ +import { useMemo } from 'react' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import type { ServerMetricRecord } from '@/lib/api-schema' +import { extractLiveMetric, extractRecordMetric } from '@/lib/widget-helpers' + +export interface MetricSeriesPoint { + t: number + v: number +} + +export interface MetricSeries { + avg: number | null + current: number + oneHourDelta: number | null + peak: number | null + points: MetricSeriesPoint[] +} + +interface Params { + metric: string + records: ServerMetricRecord[] | undefined + server: ServerMetrics | undefined +} + +const ONE_HOUR_MS = 60 * 60_000 +const DELTA_WINDOW_MS = 5 * 60_000 + +function buildRecordPoints( + records: ServerMetricRecord[] | undefined, + metric: string, + server: ServerMetrics | undefined +): MetricSeriesPoint[] { + const points: MetricSeriesPoint[] = [] + if (!records) { + return points + } + for (const r of records) { + const t = new Date(r.time).getTime() + if (Number.isFinite(t)) { + points.push({ t, v: extractRecordMetric(r, metric, server) }) + } + } + points.sort((a, b) => a.t - b.t) + return points +} + +function computePeakAndAvg(points: MetricSeriesPoint[]): { avg: number; peak: number } { + let peak = points[0].v + let sum = 0 + for (const p of points) { + if (p.v > peak) { + peak = p.v + } + sum += p.v + } + return { peak, avg: sum / points.length } +} + +function computeOneHourDelta(points: MetricSeriesPoint[], nowTs: number, current: number): number | null { + const target = nowTs - ONE_HOUR_MS + let delta: number | null = null + let bestDist = Number.POSITIVE_INFINITY + for (const p of points) { + const dist = Math.abs(p.t - target) + if (dist <= DELTA_WINDOW_MS && dist < bestDist) { + bestDist = dist + delta = current - p.v + } + } + return delta +} + +export function useMetricSeries({ records, server, metric }: Params): MetricSeries { + return useMemo(() => { + const points = buildRecordPoints(records, metric, server) + const liveValue = server ? extractLiveMetric(server, metric) : 0 + const liveTick: MetricSeriesPoint = { t: Date.now(), v: liveValue } + + const last = points.at(-1) + if (!last || liveTick.t > last.t) { + points.push(liveTick) + } + + if (points.length === 0) { + return { points, current: 0, peak: null, avg: null, oneHourDelta: null } + } + + const current = points.at(-1)?.v ?? 0 + const { peak, avg } = computePeakAndAvg(points) + const oneHourDelta = computeOneHourDelta(points, liveTick.t, current) + + return { points, current, peak, avg, oneHourDelta } + }, [records, server, metric]) +} From 5fe4fb217cfd32ff8bbf656d2c7262789e8bf498 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:19:23 +0800 Subject: [PATCH 07/21] feat(web): metric-card header subcomponent --- .../metric-card/metric-card-header.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 apps/web/src/components/dashboard/widgets/metric-card/metric-card-header.tsx diff --git a/apps/web/src/components/dashboard/widgets/metric-card/metric-card-header.tsx b/apps/web/src/components/dashboard/widgets/metric-card/metric-card-header.tsx new file mode 100644 index 00000000..120173b1 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/metric-card/metric-card-header.tsx @@ -0,0 +1,25 @@ +import type { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface MetricCardHeaderProps { + accent: string + Icon: LucideIcon + label: string + serverName: string +} + +export function MetricCardHeader({ Icon, label, serverName, accent }: MetricCardHeaderProps) { + return ( +
+
+ +
+ {label} + {serverName} +
+ ) +} From 2cb4e5000fb5dd5d089e717414ba4cfb794449bf Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:21:23 +0800 Subject: [PATCH 08/21] feat(web): metric-card value + delta subcomponent --- .../widgets/metric-card/metric-card-value.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 apps/web/src/components/dashboard/widgets/metric-card/metric-card-value.tsx diff --git a/apps/web/src/components/dashboard/widgets/metric-card/metric-card-value.tsx b/apps/web/src/components/dashboard/widgets/metric-card/metric-card-value.tsx new file mode 100644 index 00000000..d0125886 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/metric-card/metric-card-value.tsx @@ -0,0 +1,61 @@ +import { TrendingDown, TrendingUp } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { DeltaTone, DeltaUnit } from './metric-card-config' + +interface MetricCardValueProps { + delta: number | null + deltaTone: DeltaTone + deltaUnit: DeltaUnit + formattedValue: string + pastLabel: string +} + +function formatDelta(delta: number, unit: DeltaUnit): string { + const sign = delta >= 0 ? '+' : '−' + const magnitude = Math.abs(delta) + if (unit === 'pp') { + return `${sign}${magnitude.toFixed(1)}pp` + } + return `${sign}${magnitude.toFixed(0)}%` +} + +function deltaColor(delta: number, tone: DeltaTone): string { + if (tone === 'neutral') { + return 'text-muted-foreground' + } + if (delta === 0) { + return 'text-muted-foreground' + } + return delta > 0 ? 'text-destructive' : 'text-emerald-500' +} + +export function MetricCardValue({ formattedValue, delta, deltaUnit, deltaTone, pastLabel }: MetricCardValueProps) { + const Trend = delta !== null && delta < 0 ? TrendingDown : TrendingUp + return ( +
+

+ {formattedValue} +

+

+ {delta === null ? ( + + ) : ( + <> + + {formatDelta(delta, deltaUnit)} + + )} + · {pastLabel} +

+
+ ) +} From 4fecb7fd8506f503e7a3aeef64d0b2b9d08fc463 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:24:08 +0800 Subject: [PATCH 09/21] feat(web): metric-card sparkline subcomponent --- .../metric-card/metric-card-sparkline.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 apps/web/src/components/dashboard/widgets/metric-card/metric-card-sparkline.tsx diff --git a/apps/web/src/components/dashboard/widgets/metric-card/metric-card-sparkline.tsx b/apps/web/src/components/dashboard/widgets/metric-card/metric-card-sparkline.tsx new file mode 100644 index 00000000..fa8fb189 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/metric-card/metric-card-sparkline.tsx @@ -0,0 +1,38 @@ +import { useId } from 'react' +import { Area, AreaChart, ResponsiveContainer } from 'recharts' +import type { MetricSeriesPoint } from '@/hooks/use-metric-series' + +interface MetricCardSparklineProps { + accent: string + points: MetricSeriesPoint[] +} + +export function MetricCardSparkline({ points, accent }: MetricCardSparklineProps) { + const gradientId = useId() + const color = `var(${accent})` + + if (points.length < 2) { + return
+ } + + return ( + + + + + + + + + + + + ) +} From 780732a8fad51d3899985c131092bf0d9c391e22 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:24:57 +0800 Subject: [PATCH 10/21] feat(web): metric-card stats subcomponent --- .../widgets/metric-card/metric-card-stats.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 apps/web/src/components/dashboard/widgets/metric-card/metric-card-stats.tsx diff --git a/apps/web/src/components/dashboard/widgets/metric-card/metric-card-stats.tsx b/apps/web/src/components/dashboard/widgets/metric-card/metric-card-stats.tsx new file mode 100644 index 00000000..cdef6f52 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/metric-card/metric-card-stats.tsx @@ -0,0 +1,31 @@ +interface StatProps { + caption: string + value: string +} + +function Stat({ caption, value }: StatProps) { + return ( +
+

{caption}

+

+ {value} +

+
+ ) +} + +interface MetricCardStatsProps { + avg: string + avgCaption: string + peak: string + peakCaption: string +} + +export function MetricCardStats({ peakCaption, avgCaption, peak, avg }: MetricCardStatsProps) { + return ( +
+ + +
+ ) +} From e031af438a955870fcb3a80d1036bf60266b5d47 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:26:13 +0800 Subject: [PATCH 11/21] docs: add gauge widget redesign spec --- ...2026-05-27-gauge-widget-redesign-design.md | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-gauge-widget-redesign-design.md diff --git a/docs/superpowers/specs/2026-05-27-gauge-widget-redesign-design.md b/docs/superpowers/specs/2026-05-27-gauge-widget-redesign-design.md new file mode 100644 index 00000000..f68bd018 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-gauge-widget-redesign-design.md @@ -0,0 +1,217 @@ +--- +title: Gauge Widget Visual Redesign +date: 2026-05-27 +status: Draft +branch: marseille-v1 +related: + - apps/web/src/components/dashboard/widgets/gauge.tsx + - apps/web/src/lib/widget-types.ts (GaugeConfig — unchanged) + - apps/web/src/lib/widget-helpers.ts (METRIC_LABELS, extractLiveMetric) +--- + +# Gauge Widget Visual Redesign + +## Goal + +Replace the current recharts-based `GaugeWidget` with a hand-rolled SVG gauge that matches the reference iOS-style design: a near-full-circle ring with a two-stop gradient stroke, rounded end caps, two decorative "ball" knobs at the arc endpoints, a metric-aware icon above a colored label, a large value, and a muted subtitle. Threshold-based severity coloring is preserved (it carries operational meaning), but rendered as a gradient between two related theme hues for visual interest. + +The widget's public surface (`GaugeConfig`, the metrics it accepts, its grid sizing constraints, its place in the dashboard registry) is unchanged. This is a pure visual and rendering-stack refactor. + +## Non-goals + +- New config fields — `GaugeConfig` stays exactly as it is. +- Animation — initial implementation is static, matching current behavior (`isAnimationActive={false}`). +- Trend indicators / ETAs / delta arrows in the subtitle — server name remains the subtitle. +- Multi-metric or stacked rings — single-metric, single-server, same as today. +- New widget category, new picker entry, or new tests of the dashboard grid. + +These keep the change scoped to a single file rewrite plus a focused vitest. + +--- + +## 1. Visual Anatomy + +``` + ┌──────────────────────────┐ + │ │ + │ ◯ ──── ◯ │ ← gradient arc (270°) + │ ╱ ╲ │ with two ball end-caps + │ ╱ [icon] ╲ │ + │ │ Capacity │ │ ← icon + label (gradient-start color) + │ │ │ │ + │ │ 68.0% │ │ ← big value (foreground) + │ │ │ │ + │ ╲ server-01 ╱ │ ← subtitle (muted) + │ ╲ ╱ │ + │ ╲ ─── ╱ │ + │ │ + └──────────────────────────┘ +``` + +Stack (centered, vertical): + +1. **Icon** — lucide-react, ~14–16px at default size, colored to match the gradient's start stop. +2. **Label** — small (`text-xs`/`text-sm`), same color as the icon. +3. **Value** — large numeric, foreground color, `text-2xl` to `text-4xl` depending on container. +4. **Subtitle** — `truncate text-center text-muted-foreground text-xs`, server name (preserved from current behavior). + +Ring: + +- Sweep **270°** with the gap centered at the top (start angle `135°`, end angle `45°` measured clockwise from 12 o'clock — equivalent to the reference image's gap orientation). +- Two SVG `` elements: + - **Track**: full 270° sweep, `stroke=var(--color-muted)`, low opacity. + - **Progress**: from start angle to `start + (value/max) * 270°`, `stroke=url(#gauge-gradient-{id})`. +- `stroke-linecap="round"` on both. +- Two `` end-caps placed at the progress arc's start and end. Each is a white circle with a thin colored inner dot — the inner dot's color is sampled from the gradient at that endpoint. + +## 2. Color Strategy + +Threshold-based severity is **preserved**, but each state pairs two related theme hues into a gradient. All colors come from existing `--chart-*` CSS variables so theme switching keeps working automatically. + +| Value range | Gradient start → end | Semantic | +| ----------- | -------------------------- | -------------- | +| `< 70%` | `--chart-1` → `--chart-2` | Normal (cool) | +| `70%–<90%` | `--chart-3` → `--chart-5` | Warning (warm) | +| `>= 90%` | `--chart-4` → `--chart-3` | Critical (hot) | + +The label, icon, and end-cap inner dots use the **gradient start color** (single solid pick, not the gradient itself) so they remain readable on top of the dark card. + +Rationale for the thresholds: matches the existing `getGaugeColor` ranges so no operational tuning is lost. The new piece is just rendering as a gradient pair instead of a single solid color. + +## 3. Component Structure + +Single file: `apps/web/src/components/dashboard/widgets/gauge.tsx`. + +No sub-component extraction unless the file exceeds ~180 lines after rewrite — currently it's ~80 lines, the rewrite is expected around ~140–160 lines. + +### Exports + +`GaugeWidget(props: { config: GaugeConfig; servers: ServerMetrics[] }): JSX.Element` — same signature as today. + +### Internal helpers (file-local, not exported) + +- `getGaugeGradient(value: number): { start: string; end: string }` — returns CSS variables for the two stops based on threshold. +- `getMetricIcon(metric: string): LucideIcon` — maps `cpu/memory/disk/swap/load*/net_*/bandwidth` to lucide icons, default `Gauge`. +- `polarToCartesian(cx, cy, r, angleDeg): { x, y }` — for end-cap positioning and arc-path generation. +- `arcPath(cx, cy, r, startAngle, endAngle): string` — returns an SVG `d` attribute for a circular arc between the two angles. + +### Icon mapping + +| Metric | Icon | +| ------------------------------------- | ------------- | +| `cpu` | `Cpu` | +| `memory`, `swap` | `MemoryStick` | +| `disk` | `HardDrive` | +| `load1`, `load5`, `load15` | `Activity` | +| `net_in`, `net_out`, `bandwidth` | `Network` | +| default | `Gauge` | + +All from `lucide-react`, already a dependency. + +### SVG structure (simplified) + +```tsx + + + + + + + + {/* Track */} + + {/* Progress */} + + {/* End-cap balls (only if value > 0) */} + + + + + +``` + +`uid` is a stable per-instance id from `useId()` so multiple gauges on one page don't collide on the gradient ref. + +Constants (in viewBox units, 100×100): +- `RADIUS = 38` +- `STROKE = 8` +- `BALL_R = 5.5` +- `BALL_R_INNER = 2` +- Sweep: `startAngle = 135°`, `endAngle = 45°` (clockwise, 270° total) + +The text stack (icon + label + value + subtitle) sits in a centered absolutely-positioned `
` inside the same flex column, layered above the SVG via `position: relative` on the wrapper. Text never lives inside the SVG — this lets Tailwind's responsive font sizing and `truncate` work as usual. + +## 4. Responsive Sizing + +The widget grid range is 2×2 → 6×6. The redesign must look good at all sizes. + +Two parts: + +- **SVG**: always fills its container via `viewBox=0 0 100 100` and `h-full w-full`. Scales linearly. +- **Text overlay**: Tailwind responsive classes keyed off container queries (Tailwind v4 supports `@container`). + - Value: `text-2xl @md:text-3xl @lg:text-4xl` + - Label: `text-xs @md:text-sm` + - Icon: `h-3.5 w-3.5 @md:h-4 @md:w-4 @lg:h-5 @lg:w-5` + - Subtitle: hidden below `@xs`; shown otherwise. + +The wrapping card gets `@container/gauge` so we don't depend on viewport size — multiple gauges of different grid sizes can sit side-by-side and each scale independently. + +If container queries turn out not to be wired up in the project's Tailwind config, fallback is to use `ResizeObserver` + a `useState` for a discrete size bucket (`sm | md | lg`). Decision made during implementation, not in this spec. + +## 5. State Handling + +Same as today: + +- If `server` is not found in `servers`, render the existing "Server not found" empty state (border + muted text). No gauge ring drawn. +- `value` is clamped to `[0, max]`. If `value === 0`, skip rendering the progress path and the two end-cap balls — only the track is shown. This avoids a degenerate zero-length arc with two overlapping balls at the start angle. + +## 6. Dependency Change + +Remove the `recharts` imports from `gauge.tsx`: + +```ts +// before +import { PolarAngleAxis, RadialBar, RadialBarChart, ResponsiveContainer } from 'recharts' +// after +import { useId, useMemo } from 'react' +import { Activity, Cpu, Gauge as GaugeIcon, HardDrive, MemoryStick, Network } from 'lucide-react' +``` + +Other widgets still use recharts (`line-chart-widget`, `multi-line`, `disk-io`, `traffic-bar`, etc.), so `recharts` stays in the project dependencies. We are only removing the use *from this one widget*. + +## 7. Testing + +Add `apps/web/src/components/dashboard/widgets/gauge.test.tsx` (vitest + RTL, same pattern as `stat-number.test.tsx`): + +- Renders "Server not found" when `server_id` doesn't match any server. +- Renders the configured label and the server's name (subtitle). +- Renders the formatted percentage (`68.0%`) for a known metric value. +- Threshold transitions: with values `50 / 75 / 95`, the rendered SVG `` has the expected `--chart-*` stop colors. Assert via `getByTestId('gauge-gradient')` and reading the `` children's `stopColor` attribute. +- Clamps values: when `extractLiveMetric` returns a value > `max`, the rendered text shows `max.toFixed(1)%`. +- Zero state: when `value === 0`, the progress `` and end-cap `` group are absent. + +The existing tests under `apps/web/src/components/dashboard/widgets/` use vitest + `@testing-library/react`. The new test file follows the same setup — no new harness needed. + +Manual visual verification (per project policy on UI changes): start `bun run dev`, add a Gauge widget to a dashboard, eyeball it against the reference image, and re-check at min (2×2) and max (6×6) grid sizes. Note in the PR description if visual confirmation was done. + +## 8. Out-of-scope follow-ups (deliberate) + +- Animating value changes (`requestAnimationFrame` tween). +- Configurable gradient direction or angle. +- Showing a trend arrow / delta vs. 5-min ago. +- Letting users pick the icon per widget instance. +- Click-through to a detail view from the gauge. + +These can each ship later as small, independent improvements. None of them affect the data model or the API surface. + +## 9. File diff summary + +| File | Change | +| ----------------------------------------------------------------- | --------------------------------------- | +| `apps/web/src/components/dashboard/widgets/gauge.tsx` | Rewritten (recharts → SVG) | +| `apps/web/src/components/dashboard/widgets/gauge.test.tsx` | **New** — vitest coverage | +| `apps/web/src/lib/widget-types.ts` | Unchanged | +| `apps/web/src/lib/widget-helpers.ts` | Unchanged | +| `apps/web/src/components/dashboard/widget-renderer.tsx` | Unchanged (still routes `gauge` → `GaugeWidget`) | + +No backend changes. No migrations. No new env vars. No docs updates (CN/EN docs don't reference the gauge widget's visual style). From e64556a563705a64c61e25ce6b43f556bc36cc18 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:27:56 +0800 Subject: [PATCH 12/21] feat(web): MetricCardWidget composing subcomponents --- .../dashboard/widgets/metric-card.test.tsx | 81 ++++++++++++++++++ .../dashboard/widgets/metric-card.tsx | 82 +++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 apps/web/src/components/dashboard/widgets/metric-card.test.tsx create mode 100644 apps/web/src/components/dashboard/widgets/metric-card.tsx diff --git a/apps/web/src/components/dashboard/widgets/metric-card.test.tsx b/apps/web/src/components/dashboard/widgets/metric-card.test.tsx new file mode 100644 index 00000000..b8ca9cd3 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/metric-card.test.tsx @@ -0,0 +1,81 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { MetricCardWidget } from './metric-card' + +const translations: Record = { + 'metricCard.metric.cpu': 'CPU', + 'metricCard.metric.memory': 'Memory', + 'metricCard.metric.network': 'Network', + 'metricCard.metric.diskIo': 'Disk I/O', + 'metricCard.past1h': 'past 1h', + 'metricCard.peak': '24H PEAK', + 'metricCard.avg': '24H AVG', + 'metricCard.unknownServer': 'Unknown server' +} + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => translations[key] ?? key + }) +})) + +vi.mock('@/hooks/use-api', () => ({ + useServerRecords: () => ({ data: [], isLoading: false }) +})) + +function makeServer(overrides: Partial = {}): ServerMetrics { + return { + id: 's1', + name: 'web-1', + online: true, + cpu: 42.5, + mem_used: 4_000_000_000, + mem_total: 8_000_000_000, + disk_used: 0, + disk_total: 0, + swap_used: 0, + swap_total: 0, + net_in_speed: 0, + net_out_speed: 0, + disk_read_speed: 0, + disk_write_speed: 0, + ...overrides + } as unknown as ServerMetrics +} + +function wrap(node: ReactNode) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return {node} +} + +describe('MetricCardWidget', () => { + it('renders the CPU value', () => { + render(wrap()) + expect(screen.getByTestId('metric-card-value')).toHaveTextContent('42.5%') + }) + + it('shows unknown server placeholder when server_id is missing', () => { + render(wrap()) + expect(screen.getByText('Unknown server')).toBeInTheDocument() + }) + + it('renders dash for delta when no history is available', () => { + render(wrap()) + expect(screen.getByTestId('metric-card-delta')).toHaveTextContent('—') + }) + + it('uses the custom label override', () => { + render( + wrap( + + ) + ) + expect(screen.getByText('RAM Pressure')).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/dashboard/widgets/metric-card.tsx b/apps/web/src/components/dashboard/widgets/metric-card.tsx new file mode 100644 index 00000000..b4d74cba --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/metric-card.tsx @@ -0,0 +1,82 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useServerRecords } from '@/hooks/use-api' +import { useMetricSeries } from '@/hooks/use-metric-series' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { cn } from '@/lib/utils' +import type { MetricCardConfig } from '@/lib/widget-types' +import { METRIC_CARD_SPECS } from './metric-card/metric-card-config' +import { MetricCardHeader } from './metric-card/metric-card-header' +import { MetricCardSparkline } from './metric-card/metric-card-sparkline' +import { MetricCardStats } from './metric-card/metric-card-stats' +import { MetricCardValue } from './metric-card/metric-card-value' + +interface MetricCardWidgetProps { + config: MetricCardConfig + servers: ServerMetrics[] +} + +const HISTORY_HOURS = 24 +const HISTORY_INTERVAL = '5m' + +function formatStat(value: number | null, formatter: (n: number) => string): string { + return value === null ? '—' : formatter(value) +} + +export function MetricCardWidget({ config, servers }: MetricCardWidgetProps) { + const { t } = useTranslation('dashboard') + const spec = METRIC_CARD_SPECS[config.metric] + const server = useMemo(() => servers.find((s) => s.id === config.server_id), [servers, config.server_id]) + + const { data: records } = useServerRecords(config.server_id, HISTORY_HOURS, HISTORY_INTERVAL, { + enabled: Boolean(config.server_id) && Boolean(server) + }) + + const series = useMetricSeries({ records, server, metric: config.metric }) + + if (!server) { + return ( +
+ {t('metricCard.unknownServer')} +
+ ) + } + + const label = config.label ?? t(spec.labelKey) + const dimmed = !server.online + const formattedValue = dimmed ? '—' : spec.formatValue(series.current) + const formattedPeak = formatStat(series.peak, spec.formatValue) + const formattedAvg = formatStat(series.avg, spec.formatValue) + + return ( +
+ + +
+ +
+ +
+ ) +} From cc03ce3def2327a0aa6c2d19ee02643095dfba6b Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:29:20 +0800 Subject: [PATCH 13/21] docs: add gauge widget redesign implementation plan --- .../plans/2026-05-27-gauge-widget-redesign.md | 525 ++++++++++++++++++ 1 file changed, 525 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-27-gauge-widget-redesign.md diff --git a/docs/superpowers/plans/2026-05-27-gauge-widget-redesign.md b/docs/superpowers/plans/2026-05-27-gauge-widget-redesign.md new file mode 100644 index 00000000..37cd6871 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-gauge-widget-redesign.md @@ -0,0 +1,525 @@ +# Gauge Widget Visual Redesign 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:** Rewrite the Dashboard Gauge widget as a hand-rolled SVG ring with a two-stop gradient, end-cap "ball" knobs, and a metric-aware icon — matching the iOS-style reference in `docs/superpowers/specs/2026-05-27-gauge-widget-redesign-design.md`. + +**Architecture:** Single-file rewrite of `apps/web/src/components/dashboard/widgets/gauge.tsx`. Replace `recharts` (`RadialBarChart`) with native SVG (`` arcs + `` + `` end caps). Public surface (`GaugeConfig`, `GaugeWidget` signature, dashboard registration) is unchanged. Threshold severity coloring is preserved but rendered as gradients between two `--chart-*` theme variables. A new vitest spec verifies render contract. + +**Tech Stack:** React 19, TypeScript, Tailwind v4 (`@container` queries), `lucide-react` (already a dep), vitest + `@testing-library/react`. **Removes** the `recharts` import from this file only — other widgets keep using it. + +--- + +## File Structure + +| File | Responsibility | +| ----------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `apps/web/src/components/dashboard/widgets/gauge.tsx` | Rewritten widget: SVG ring + responsive text overlay | +| `apps/web/src/components/dashboard/widgets/gauge.test.tsx` | **New** — vitest covering empty state, label/value, thresholds, clamp, zero | +| (`apps/web/src/lib/widget-types.ts`) | Untouched — `GaugeConfig` is unchanged | +| (`apps/web/src/lib/widget-helpers.ts`) | Untouched — keeps providing `extractLiveMetric` and `METRIC_LABELS` | +| (`apps/web/src/components/dashboard/widget-renderer.tsx`) | Untouched — already routes `gauge` → `GaugeWidget` | + +Everything lives in one component file. We don't extract helpers into separate modules — they're file-local utilities that aren't reused. + +--- + +## Task 1: Add failing tests for the redesigned gauge + +**Files:** +- Create: `apps/web/src/components/dashboard/widgets/gauge.test.tsx` + +These tests describe the *target* render contract. They will fail against the current `recharts`-based implementation (no `data-testid` hooks, no ``, no end-cap circles). The next task makes them pass. + +- [ ] **Step 1: Create the test file** + +Path: `apps/web/src/components/dashboard/widgets/gauge.test.tsx` + +```tsx +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { GaugeWidget } from './gauge' + +function makeServer(id: string, overrides: Partial = {}): ServerMetrics { + return { + id, + name: `Server ${id}`, + online: true, + cpu: 50, + mem_used: 4_000_000_000, + mem_total: 8_000_000_000, + swap_used: 0, + swap_total: 0, + disk_used: 20_000_000_000, + disk_total: 40_000_000_000, + disk_read_bytes_per_sec: 0, + disk_write_bytes_per_sec: 0, + net_in_speed: 1024, + net_out_speed: 2048, + net_in_transfer: 1, + net_out_transfer: 1, + load1: 0.5, + load5: 0.4, + load15: 0.3, + tcp_conn: 10, + udp_conn: 5, + process_count: 100, + uptime: 86_400, + country_code: 'US', + os: 'Linux', + cpu_name: 'Test CPU', + last_active: Date.now(), + region: null, + group_id: null, + ...overrides + } +} + +function getStops(container: HTMLElement): { start: string | null; end: string | null } { + const gradient = container.querySelector('[data-testid="gauge-gradient"]') + if (!gradient) { + return { start: null, end: null } + } + const stops = gradient.querySelectorAll('stop') + return { + start: stops[0]?.getAttribute('stop-color') ?? null, + end: stops[1]?.getAttribute('stop-color') ?? null + } +} + +describe('GaugeWidget', () => { + it('renders the empty state when the configured server is not in the list', () => { + render() + + expect(screen.getByText('Server not found')).toBeInTheDocument() + expect(screen.queryByTestId('gauge-svg')).not.toBeInTheDocument() + }) + + it('renders label, formatted value, and server-name subtitle', () => { + const { container } = render( + + ) + + expect(screen.getByTestId('gauge-label')).toHaveTextContent('CPU Usage') + expect(screen.getByTestId('gauge-value')).toHaveTextContent('50.0%') + expect(screen.getByTestId('gauge-subtitle')).toHaveTextContent('Server 1') + expect(container.querySelector('[data-testid="gauge-svg"]')).not.toBeNull() + }) + + it('uses the normal-range gradient (chart-1 → chart-2) when value < 70', () => { + const { container } = render( + + ) + + expect(getStops(container)).toEqual({ + start: 'var(--chart-1)', + end: 'var(--chart-2)' + }) + }) + + it('uses the warning gradient (chart-3 → chart-5) when value is in [70, 90)', () => { + const { container } = render( + + ) + + expect(getStops(container)).toEqual({ + start: 'var(--chart-3)', + end: 'var(--chart-5)' + }) + }) + + it('uses the critical gradient (chart-4 → chart-3) when value >= 90', () => { + const { container } = render( + + ) + + expect(getStops(container)).toEqual({ + start: 'var(--chart-4)', + end: 'var(--chart-3)' + }) + }) + + it('clamps values above the configured max', () => { + render( + + ) + + expect(screen.getByTestId('gauge-value')).toHaveTextContent('80.0%') + }) + + it('hides the progress arc and end-cap balls when value is zero', () => { + const { container } = render( + + ) + + expect(container.querySelector('[data-testid="gauge-progress"]')).toBeNull() + expect(container.querySelector('[data-testid="gauge-endcaps"]')).toBeNull() + // The track is still drawn. + expect(container.querySelector('[data-testid="gauge-track"]')).not.toBeNull() + }) +}) +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +Run from repo root: + +```bash +cd apps/web && bun run test -- gauge.test.tsx --run +``` + +Expected: tests fail because the current `gauge.tsx` (recharts-based) doesn't expose any of these `data-testid` hooks and renders a different DOM. At minimum the empty-state and percentage tests may accidentally pass, but the gradient/zero/clamp/subtitle ones will fail. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/dashboard/widgets/gauge.test.tsx +git commit -m "test(web): add failing render contract for redesigned gauge widget" +``` + +--- + +## Task 2: Rewrite `gauge.tsx` as SVG-based, threshold-gradient gauge + +**Files:** +- Modify (full rewrite): `apps/web/src/components/dashboard/widgets/gauge.tsx` + +- [ ] **Step 1: Replace the file contents** + +Path: `apps/web/src/components/dashboard/widgets/gauge.tsx` + +```tsx +import { Activity, Cpu, Gauge as GaugeIcon, HardDrive, MemoryStick, Network } from 'lucide-react' +import { useId, useMemo } from 'react' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { extractLiveMetric, METRIC_LABELS } from '@/lib/widget-helpers' +import type { GaugeConfig } from '@/lib/widget-types' + +interface GaugeWidgetProps { + config: GaugeConfig + servers: ServerMetrics[] +} + +// SVG geometry constants (viewBox is 100x100) +const VIEWBOX = 100 +const CENTER = VIEWBOX / 2 +const RADIUS = 38 +const STROKE = 8 +const BALL_R = 5.5 +const BALL_R_INNER = 2 +// 270° sweep, gap centered at top. Angles in degrees, clockwise from 12 o'clock. +const START_ANGLE = 135 +const SWEEP = 270 + +interface Gradient { + end: string + start: string +} + +function getGaugeGradient(value: number): Gradient { + if (value >= 90) { + return { start: 'var(--chart-4)', end: 'var(--chart-3)' } + } + if (value >= 70) { + return { start: 'var(--chart-3)', end: 'var(--chart-5)' } + } + return { start: 'var(--chart-1)', end: 'var(--chart-2)' } +} + +function getMetricIcon(metric: string) { + switch (metric) { + case 'cpu': + return Cpu + case 'memory': + case 'swap': + return MemoryStick + case 'disk': + return HardDrive + case 'load1': + case 'load5': + case 'load15': + return Activity + case 'net_in': + case 'net_out': + case 'bandwidth': + return Network + default: + return GaugeIcon + } +} + +// Convert a polar coordinate (angle in degrees, clockwise from 12 o'clock) to cartesian SVG coords. +function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) { + const angleRad = ((angleDeg - 90) * Math.PI) / 180 + return { + x: cx + r * Math.cos(angleRad), + y: cy + r * Math.sin(angleRad) + } +} + +// SVG arc path between two angles (clockwise from 12 o'clock). Sweeps the short or long way +// as needed so that any 0-360° span is drawable. +function arcPath(cx: number, cy: number, r: number, startAngle: number, endAngle: number): string { + const start = polarToCartesian(cx, cy, r, startAngle) + const end = polarToCartesian(cx, cy, r, endAngle) + const span = ((endAngle - startAngle) % 360 + 360) % 360 + const largeArcFlag = span > 180 ? 1 : 0 + return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArcFlag} 1 ${end.x} ${end.y}` +} + +export function GaugeWidget({ config, servers }: GaugeWidgetProps) { + const gradientId = useId() + const server_id = config.server_id ?? '' + const { metric } = config + const max = config.max ?? 100 + + const server = useMemo(() => servers.find((s) => s.id === server_id), [servers, server_id]) + + const value = useMemo(() => { + if (!server) { + return 0 + } + return Math.min(max, Math.max(0, extractLiveMetric(server, metric))) + }, [server, metric, max]) + + if (!server) { + return ( +
+ Server not found +
+ ) + } + + const label = config.label ?? METRIC_LABELS[metric] ?? metric + const gradient = getGaugeGradient(value) + const Icon = getMetricIcon(metric) + + const progressSweep = max > 0 ? (value / max) * SWEEP : 0 + const trackEnd = START_ANGLE + SWEEP + const progressEnd = START_ANGLE + progressSweep + + const trackPathD = arcPath(CENTER, CENTER, RADIUS, START_ANGLE, trackEnd) + const progressPathD = arcPath(CENTER, CENTER, RADIUS, START_ANGLE, progressEnd) + const startCap = polarToCartesian(CENTER, CENTER, RADIUS, START_ANGLE) + const endCap = polarToCartesian(CENTER, CENTER, RADIUS, progressEnd) + + const showProgress = value > 0 + + return ( +
+ + +
+
+
+ ) +} +``` + +Notes for the engineer reviewing this code: + +- `useId()` gives every gauge instance its own gradient id, so multiple gauges on the same dashboard don't collide on `url(#...)` refs. +- `@container/gauge` names the container. The text overlay then uses arbitrary container queries (`@[10rem]:`, `@[14rem]:`) instead of fixed Tailwind breakpoints so each gauge scales by its own grid size, not the viewport. +- The empty-state early-return happens *before* computing gradient/icon/paths — keeps the happy path clean and matches the existing pattern in this file. +- The SVG is absolutely positioned behind the text via `absolute inset-0`. The text wrapper uses `relative z-10` so it sits on top. The card itself is `flex flex-col items-center justify-center` to center the text vertically. + +- [ ] **Step 2: Run the gauge tests; expect all to pass** + +```bash +cd apps/web && bun run test -- gauge.test.tsx --run +``` + +Expected: all 7 tests pass. + +- [ ] **Step 3: Run typecheck and the existing widget test suite** + +```bash +bun run typecheck +cd apps/web && bun run test --run +``` + +Expected: typecheck passes; full vitest run is green. If any existing test fails, it's almost certainly because another file imports `recharts` differently — read the failure and fix only if it's caused by this change. + +- [ ] **Step 4: Run lint and auto-format** + +From repo root: + +```bash +bun x ultracite check apps/web/src/components/dashboard/widgets/gauge.tsx apps/web/src/components/dashboard/widgets/gauge.test.tsx +``` + +If the check reports fixable issues: + +```bash +bun x ultracite fix apps/web/src/components/dashboard/widgets/gauge.tsx apps/web/src/components/dashboard/widgets/gauge.test.tsx +``` + +Expected: clean (zero diagnostics) after the fix pass. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/components/dashboard/widgets/gauge.tsx apps/web/src/components/dashboard/widgets/gauge.test.tsx +git commit -m "feat(web): redesign gauge widget with svg ring and gradient stroke" +``` + +(The test file's intent is now satisfied — same commit folds in any reformatting from Step 4.) + +--- + +## Task 3: Verify across the wider build and report visual-verification status + +**Files:** none modified (verification + reporting only) + +- [ ] **Step 1: Run the workspace-level checks** + +From repo root: + +```bash +bun run typecheck +cd apps/web && bun run test --run +bun x ultracite check +``` + +Expected: all clean. If `bun x ultracite check` flags unrelated files modified in earlier sessions, leave them alone — only fix files this plan touched. + +- [ ] **Step 2: Note manual visual verification status** + +Per project policy on UI changes, a real browser eyeballing pass is required before claiming completion. This environment may not have a browser. The executor must do *one* of the following and record the outcome in the final summary: + +- **If a browser is available:** start `cd apps/web && bun run dev`, open a dashboard with a Gauge widget at min (2×2) and max (6×6) sizes, confirm against `docs/superpowers/specs/2026-05-27-gauge-widget-redesign-design.md` § 1. Take notes; capture a screenshot path if possible. +- **If no browser is available:** state this explicitly in the completion summary — "Visual verification not done; no browser tool in this environment." Do NOT claim the visual matches the reference without having seen it. This matches the user's standing preference (`feedback_visual_verification.md` in MEMORY.md). + +- [ ] **Step 3: Confirm no `recharts` regression** + +```bash +rg "recharts" apps/web/src/components/dashboard/widgets/gauge.tsx +``` + +Expected: zero matches (the import was removed). + +```bash +rg "recharts" apps/web/src/ +``` + +Expected: still present in other widget files (`line-chart-widget.tsx`, `multi-line.tsx`, `disk-io.tsx`, `traffic-bar.tsx`, etc.). The dependency stays in `package.json`. + +- [ ] **Step 4: Final commit only if Task 3 produced changes** + +Task 3 is verification-only. Do not create an empty commit. If the ultracite check in Step 1 produced fixes that weren't covered by Task 2's commit, stage and commit them: + +```bash +git add -A +git commit -m "chore(web): apply ultracite formatting to gauge files" +``` + +Otherwise skip. + +--- + +## Self-Review + +**1. Spec coverage:** + +| Spec section | Plan coverage | +| ------------------------------------- | ------------------------------------------------------------------------------ | +| § 1 Visual anatomy (icon/label/value/subtitle) | Task 2 Step 1 — all four elements rendered with documented testids | +| § 2 Threshold gradient table | Task 1 Steps 3/4/5 (gradient stop assertions) + Task 2 `getGaugeGradient` | +| § 3 Component structure / helpers | Task 2 Step 1 — all helpers (`getGaugeGradient`, `getMetricIcon`, `polarToCartesian`, `arcPath`) defined | +| § 3 SVG structure | Task 2 Step 1 — matches the spec's simplified TSX example | +| § 4 Responsive sizing (container queries) | Task 2 Step 1 — `@container/gauge` + `@[10rem]/@[14rem]` arbitrary breakpoints | +| § 5 State handling (empty, clamp, zero) | Task 1 Steps 1/6/7 + Task 2 early-return and `showProgress` gate | +| § 6 Dependency change (drop recharts) | Task 2 Step 1 (no recharts import) + Task 3 Step 3 (grep verification) | +| § 7 Testing (6 cases) | Task 1 Step 1 — all six listed cases plus the label/value sanity test | +| § 8 Out-of-scope | N/A — not implemented, as intended | +| § 9 File diff summary | Plan's File Structure table matches exactly | + +No gaps. + +**2. Placeholder scan:** No TBDs, no "handle errors appropriately", no "similar to above". All code blocks are complete. + +**3. Type/name consistency:** +- `getGaugeGradient` returns `{ start, end }` — used as `gradient.start` / `gradient.end` everywhere. ✓ +- `data-testid` strings (`gauge-svg`, `gauge-gradient`, `gauge-track`, `gauge-progress`, `gauge-endcaps`, `gauge-label`, `gauge-value`, `gauge-subtitle`) match between Task 1's assertions and Task 2's implementation. ✓ +- `getStops` helper in the test file reads `'stop-color'` attribute, which is what React emits for `stopColor` in SVG. ✓ +- `useId()` gradient id is referenced via `url(#${gradientId})` and as the ``. ✓ +- `SWEEP = 270`, `START_ANGLE = 135` — `trackEnd = 405° = 45°` mod 360, which is the spec's stated end angle. ✓ + +All consistent. From adf2ab911453b34a71ecfb704e5af483f209d48d Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:32:34 +0800 Subject: [PATCH 14/21] test(web): add failing render contract for redesigned gauge widget --- .../dashboard/widgets/gauge.test.tsx | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 apps/web/src/components/dashboard/widgets/gauge.test.tsx diff --git a/apps/web/src/components/dashboard/widgets/gauge.test.tsx b/apps/web/src/components/dashboard/widgets/gauge.test.tsx new file mode 100644 index 00000000..055430d6 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/gauge.test.tsx @@ -0,0 +1,123 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { GaugeWidget } from './gauge' + +function makeServer(id: string, overrides: Partial = {}): ServerMetrics { + return { + id, + name: `Server ${id}`, + online: true, + cpu: 50, + mem_used: 4_000_000_000, + mem_total: 8_000_000_000, + swap_used: 0, + swap_total: 0, + disk_used: 20_000_000_000, + disk_total: 40_000_000_000, + disk_read_bytes_per_sec: 0, + disk_write_bytes_per_sec: 0, + net_in_speed: 1024, + net_out_speed: 2048, + net_in_transfer: 1, + net_out_transfer: 1, + load1: 0.5, + load5: 0.4, + load15: 0.3, + tcp_conn: 10, + udp_conn: 5, + process_count: 100, + uptime: 86_400, + country_code: 'US', + os: 'Linux', + cpu_name: 'Test CPU', + last_active: Date.now(), + region: null, + group_id: null, + ...overrides + } +} + +function getStops(container: HTMLElement): { start: string | null; end: string | null } { + const gradient = container.querySelector('[data-testid="gauge-gradient"]') + if (!gradient) { + return { start: null, end: null } + } + const stops = gradient.querySelectorAll('stop') + return { + start: stops[0]?.getAttribute('stop-color') ?? null, + end: stops[1]?.getAttribute('stop-color') ?? null + } +} + +describe('GaugeWidget', () => { + it('renders the empty state when the configured server is not in the list', () => { + render() + + expect(screen.getByText('Server not found')).toBeInTheDocument() + expect(screen.queryByTestId('gauge-svg')).not.toBeInTheDocument() + }) + + it('renders label, formatted value, and server-name subtitle', () => { + const { container } = render( + + ) + + expect(screen.getByTestId('gauge-label')).toHaveTextContent('CPU Usage') + expect(screen.getByTestId('gauge-value')).toHaveTextContent('50.0%') + expect(screen.getByTestId('gauge-subtitle')).toHaveTextContent('Server 1') + expect(container.querySelector('[data-testid="gauge-svg"]')).not.toBeNull() + }) + + it('uses the normal-range gradient (chart-1 → chart-2) when value < 70', () => { + const { container } = render( + + ) + + expect(getStops(container)).toEqual({ + start: 'var(--chart-1)', + end: 'var(--chart-2)' + }) + }) + + it('uses the warning gradient (chart-3 → chart-5) when value is in [70, 90)', () => { + const { container } = render( + + ) + + expect(getStops(container)).toEqual({ + start: 'var(--chart-3)', + end: 'var(--chart-5)' + }) + }) + + it('uses the critical gradient (chart-4 → chart-3) when value >= 90', () => { + const { container } = render( + + ) + + expect(getStops(container)).toEqual({ + start: 'var(--chart-4)', + end: 'var(--chart-3)' + }) + }) + + it('clamps values above the configured max', () => { + render() + + expect(screen.getByTestId('gauge-value')).toHaveTextContent('80.0%') + }) + + it('hides the progress arc and end-cap balls when value is zero', () => { + const { container } = render( + + ) + + expect(container.querySelector('[data-testid="gauge-progress"]')).toBeNull() + expect(container.querySelector('[data-testid="gauge-endcaps"]')).toBeNull() + expect(container.querySelector('[data-testid="gauge-track"]')).not.toBeNull() + }) +}) From ea370516fa5f1a71ac173f707d8f0178eab49b2d Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:36:16 +0800 Subject: [PATCH 15/21] fix(web): stable testids and negative-delta coverage for metric-card --- .../dashboard/widgets/metric-card.test.tsx | 43 ++++++++++++++++++- .../widgets/metric-card/metric-card-stats.tsx | 9 ++-- .../widgets/metric-card/metric-card-value.tsx | 3 +- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/dashboard/widgets/metric-card.test.tsx b/apps/web/src/components/dashboard/widgets/metric-card.test.tsx index b8ca9cd3..6a938664 100644 --- a/apps/web/src/components/dashboard/widgets/metric-card.test.tsx +++ b/apps/web/src/components/dashboard/widgets/metric-card.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen } from '@testing-library/react' import type { ReactNode } from 'react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ServerMetrics } from '@/hooks/use-servers-ws' import { MetricCardWidget } from './metric-card' @@ -22,8 +22,10 @@ vi.mock('react-i18next', () => ({ }) })) +const useServerRecordsMock = vi.fn(() => ({ data: [] as unknown[], isLoading: false })) + vi.mock('@/hooks/use-api', () => ({ - useServerRecords: () => ({ data: [], isLoading: false }) + useServerRecords: () => useServerRecordsMock() })) function makeServer(overrides: Partial = {}): ServerMetrics { @@ -52,6 +54,10 @@ function wrap(node: ReactNode) { } describe('MetricCardWidget', () => { + beforeEach(() => { + useServerRecordsMock.mockReturnValue({ data: [], isLoading: false }) + }) + it('renders the CPU value', () => { render(wrap()) expect(screen.getByTestId('metric-card-value')).toHaveTextContent('42.5%') @@ -78,4 +84,37 @@ describe('MetricCardWidget', () => { ) expect(screen.getByText('RAM Pressure')).toBeInTheDocument() }) + + it('renders a negative delta with U+2212 minus sign when one-hour history is higher than live value', () => { + const now = Date.now() + const oneHourAgo = new Date(now - 60 * 60_000).toISOString() + useServerRecordsMock.mockReturnValue({ + data: [ + { + cpu: 80, + disk_used: 0, + id: 1, + load1: 0, + load5: 0, + load15: 0, + mem_used: 0, + net_in_speed: 0, + net_in_transfer: 0, + net_out_speed: 0, + net_out_transfer: 0, + process_count: 0, + server_id: 's1', + swap_used: 0, + tcp_conn: 0, + time: oneHourAgo, + udp_conn: 0 + } + ], + isLoading: false + }) + render(wrap()) + const delta = screen.getByTestId('metric-card-delta') + expect(delta.textContent).toContain('−') + expect(delta.textContent).toContain('40.0pp') + }) }) diff --git a/apps/web/src/components/dashboard/widgets/metric-card/metric-card-stats.tsx b/apps/web/src/components/dashboard/widgets/metric-card/metric-card-stats.tsx index cdef6f52..5a9da726 100644 --- a/apps/web/src/components/dashboard/widgets/metric-card/metric-card-stats.tsx +++ b/apps/web/src/components/dashboard/widgets/metric-card/metric-card-stats.tsx @@ -1,13 +1,14 @@ interface StatProps { caption: string + kind: 'peak' | 'avg' value: string } -function Stat({ caption, value }: StatProps) { +function Stat({ caption, kind, value }: StatProps) { return (

{caption}

-

+

{value}

@@ -24,8 +25,8 @@ interface MetricCardStatsProps { export function MetricCardStats({ peakCaption, avgCaption, peak, avg }: MetricCardStatsProps) { return (
- - + +
) } diff --git a/apps/web/src/components/dashboard/widgets/metric-card/metric-card-value.tsx b/apps/web/src/components/dashboard/widgets/metric-card/metric-card-value.tsx index d0125886..cf74bb67 100644 --- a/apps/web/src/components/dashboard/widgets/metric-card/metric-card-value.tsx +++ b/apps/web/src/components/dashboard/widgets/metric-card/metric-card-value.tsx @@ -30,7 +30,6 @@ function deltaColor(delta: number, tone: DeltaTone): string { } export function MetricCardValue({ formattedValue, delta, deltaUnit, deltaTone, pastLabel }: MetricCardValueProps) { - const Trend = delta !== null && delta < 0 ? TrendingDown : TrendingUp return (

— ) : ( <> - + {delta < 0 ? : } {formatDelta(delta, deltaUnit)} )} From 8d33a0e6faf74506de964adaab31a7d935b1e372 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:45:26 +0800 Subject: [PATCH 16/21] feat(web): render metric-card widget --- apps/web/src/components/dashboard/widget-renderer.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/components/dashboard/widget-renderer.tsx b/apps/web/src/components/dashboard/widget-renderer.tsx index 99930032..087c9192 100644 --- a/apps/web/src/components/dashboard/widget-renderer.tsx +++ b/apps/web/src/components/dashboard/widget-renderer.tsx @@ -8,6 +8,7 @@ import type { GaugeConfig, LineChartConfig, MarkdownConfig, + MetricCardConfig, MultiLineConfig, ServerCardsConfig, ServerMapConfig, @@ -23,6 +24,7 @@ import { DiskIoWidget } from './widgets/disk-io' import { GaugeWidget } from './widgets/gauge' 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 { ServerCardsWidget } from './widgets/server-cards' import { ServerMapWidget } from './widgets/server-map' @@ -86,6 +88,8 @@ function WidgetContent({ widget, servers }: WidgetRendererProps) { switch (widget.widget_type) { case 'stat-number': return + case 'metric-card': + return case 'server-cards': return case 'gauge': From 7da1705c1a0d93101901f0f264d88409c81f43f0 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:47:13 +0800 Subject: [PATCH 17/21] feat(web): redesign gauge widget with svg ring and gradient stroke --- .../components/dashboard/widgets/gauge.tsx | 184 ++++++++++++++---- 1 file changed, 146 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/dashboard/widgets/gauge.tsx b/apps/web/src/components/dashboard/widgets/gauge.tsx index c1b31bb7..e6282097 100644 --- a/apps/web/src/components/dashboard/widgets/gauge.tsx +++ b/apps/web/src/components/dashboard/widgets/gauge.tsx @@ -1,5 +1,5 @@ -import { useMemo } from 'react' -import { PolarAngleAxis, RadialBar, RadialBarChart, ResponsiveContainer } from 'recharts' +import { Activity, Cpu, Gauge as GaugeIcon, HardDrive, MemoryStick, Network } from 'lucide-react' +import { useId, useMemo } from 'react' import type { ServerMetrics } from '@/hooks/use-servers-ws' import { extractLiveMetric, METRIC_LABELS } from '@/lib/widget-helpers' import type { GaugeConfig } from '@/lib/widget-types' @@ -9,17 +9,75 @@ interface GaugeWidgetProps { servers: ServerMetrics[] } -function getGaugeColor(value: number): string { +// SVG geometry constants (viewBox is 100x100) +const VIEWBOX = 100 +const CENTER = VIEWBOX / 2 +const RADIUS = 38 +const STROKE = 8 +const BALL_R = 5.5 +const BALL_R_INNER = 2 +// 270° sweep, gap centered at top. Angles in degrees, clockwise from 12 o'clock. +const START_ANGLE = 135 +const SWEEP = 270 + +interface Gradient { + end: string + start: string +} + +function getGaugeGradient(value: number): Gradient { if (value >= 90) { - return 'var(--color-chart-4)' + return { start: 'var(--chart-4)', end: 'var(--chart-3)' } } if (value >= 70) { - return 'var(--color-chart-5)' + return { start: 'var(--chart-3)', end: 'var(--chart-5)' } + } + return { start: 'var(--chart-1)', end: 'var(--chart-2)' } +} + +function getMetricIcon(metric: string) { + switch (metric) { + case 'cpu': + return Cpu + case 'memory': + case 'swap': + return MemoryStick + case 'disk': + return HardDrive + case 'load1': + case 'load5': + case 'load15': + return Activity + case 'net_in': + case 'net_out': + case 'bandwidth': + return Network + default: + return GaugeIcon } - return 'var(--color-chart-1)' +} + +// Convert a polar coordinate (angle in degrees, clockwise from 12 o'clock) to cartesian SVG coords. +function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) { + const angleRad = ((angleDeg - 90) * Math.PI) / 180 + return { + x: cx + r * Math.cos(angleRad), + y: cy + r * Math.sin(angleRad) + } +} + +// SVG arc path between two angles (clockwise from 12 o'clock). Sweeps the short or long way +// as needed so that any 0-360° span is drawable. +function arcPath(cx: number, cy: number, r: number, startAngle: number, endAngle: number): string { + const start = polarToCartesian(cx, cy, r, startAngle) + const end = polarToCartesian(cx, cy, r, endAngle) + const span = (((endAngle - startAngle) % 360) + 360) % 360 + const largeArcFlag = span > 180 ? 1 : 0 + return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArcFlag} 1 ${end.x} ${end.y}` } export function GaugeWidget({ config, servers }: GaugeWidgetProps) { + const gradientId = useId() const server_id = config.server_id ?? '' const { metric } = config const max = config.max ?? 100 @@ -33,10 +91,6 @@ export function GaugeWidget({ config, servers }: GaugeWidgetProps) { return Math.min(max, Math.max(0, extractLiveMetric(server, metric))) }, [server, metric, max]) - const label = config.label ?? METRIC_LABELS[metric] ?? metric - const color = getGaugeColor(value) - const data = [{ name: label, value, fill: color }] - if (!server) { return (

@@ -45,36 +99,90 @@ export function GaugeWidget({ config, servers }: GaugeWidgetProps) { ) } + const label = config.label ?? METRIC_LABELS[metric] ?? metric + const gradient = getGaugeGradient(value) + const Icon = getMetricIcon(metric) + + const progressSweep = max > 0 ? (value / max) * SWEEP : 0 + const trackEnd = START_ANGLE + SWEEP + const progressEnd = START_ANGLE + progressSweep + + const trackPathD = arcPath(CENTER, CENTER, RADIUS, START_ANGLE, trackEnd) + const progressPathD = arcPath(CENTER, CENTER, RADIUS, START_ANGLE, progressEnd) + const startCap = polarToCartesian(CENTER, CENTER, RADIUS, START_ANGLE) + const endCap = polarToCartesian(CENTER, CENTER, RADIUS, progressEnd) + + const showProgress = value > 0 + return ( -
-

{label}

-
- - - - - - {value.toFixed(1)}% - - - +
+ + +
+
-

{server.name}

) } From f08002cd85fb379302bf24751bdb1600c508c3fb Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 20:54:14 +0800 Subject: [PATCH 18/21] feat(web): metric-card icon in widget picker --- apps/web/src/components/dashboard/widget-picker.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/dashboard/widget-picker.tsx b/apps/web/src/components/dashboard/widget-picker.tsx index fc34834a..89ba775e 100644 --- a/apps/web/src/components/dashboard/widget-picker.tsx +++ b/apps/web/src/components/dashboard/widget-picker.tsx @@ -1,6 +1,7 @@ import { Activity, BarChart3, + Cpu, FileText, Gauge, Globe, @@ -26,6 +27,7 @@ interface WidgetPickerProps { const WIDGET_ICONS: Record = { 'stat-number': TrendingUp, + 'metric-card': Cpu, 'server-cards': LayoutGrid, gauge: Gauge, 'line-chart': LineChart, From 7b60e4e3834edbe4ccc53b6817685b9a1909c10c Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 21:02:59 +0800 Subject: [PATCH 19/21] feat(web): metric-card config form --- .../dashboard/widget-config-dialog.tsx | 58 +++++++++++++++++++ apps/web/src/locales/en/dashboard.json | 8 ++- apps/web/src/locales/zh/dashboard.json | 8 ++- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/dashboard/widget-config-dialog.tsx b/apps/web/src/components/dashboard/widget-config-dialog.tsx index a967af0d..4ec12729 100644 --- a/apps/web/src/components/dashboard/widget-config-dialog.tsx +++ b/apps/web/src/components/dashboard/widget-config-dialog.tsx @@ -16,6 +16,8 @@ import type { GaugeConfig, LineChartConfig, MarkdownConfig, + MetricCardConfig, + MetricCardMetric, MultiLineConfig, ServerCardsConfig, StatNumberConfig, @@ -68,6 +70,15 @@ function useLineMetrics(t: (key: string) => string): { label: string; value: str ] } +function useMetricCardMetrics(t: (key: string) => string): { label: string; value: MetricCardMetric }[] { + return [ + { label: t('common.metrics.cpu'), value: 'cpu' }, + { label: t('common.metrics.memory'), value: 'memory' }, + { label: t('common.metrics.network'), value: 'network' }, + { label: t('common.metrics.diskIo'), value: 'disk_io' } + ] +} + function useTopNMetrics(t: (key: string) => string): { label: string; value: string }[] { return [ { label: t('common.metrics.cpu'), value: 'cpu' }, @@ -259,6 +270,50 @@ function StatNumberForm({ ) } +function MetricCardForm({ + config, + servers, + onChange, + t +}: { + config: Partial + onChange: (c: Partial) => void + servers: ServerMetrics[] + t: (key: string) => string +}) { + const METRIC_CARD_METRICS = useMetricCardMetrics(t) + const metric = (config.metric ?? 'cpu') as MetricCardMetric + const serverId = config.server_id ?? '' + const label = config.label ?? '' + + return ( + <> + onChange({ ...config, metric, server_id: v, label })} + placeholder={t('widgets.common.placeholders.selectServer')} + servers={servers} + value={serverId} + /> + onChange({ ...config, metric: v as MetricCardMetric, server_id: serverId, label })} + placeholder={t('widgets.common.placeholders.selectMetric')} + value={metric} + /> +
+ + onChange({ ...config, metric, server_id: serverId, label: e.target.value })} + placeholder={t('widgets.common.placeholders.optionalLabel')} + value={label} + /> +
+ + ) +} + function GaugeForm({ config, servers, @@ -677,6 +732,9 @@ export function WidgetConfigDialog({ {widgetType === 'stat-number' && ( } onChange={setConfig} t={t} /> )} + {widgetType === 'metric-card' && ( + } onChange={setConfig} servers={servers} t={t} /> + )} {widgetType === 'gauge' && ( } onChange={setConfig} servers={servers} t={t} /> )} diff --git a/apps/web/src/locales/en/dashboard.json b/apps/web/src/locales/en/dashboard.json index 9b99ea0a..be1f8bf2 100644 --- a/apps/web/src/locales/en/dashboard.json +++ b/apps/web/src/locales/en/dashboard.json @@ -162,7 +162,8 @@ "maxItems": "Max Items", "markdownContent": "Markdown Content", "days": "Days", - "titleOptional": "Title (optional)" + "titleOptional": "Title (optional)", + "labelOptional": "Label (optional)" }, "placeholders": { "selectServer": "Select server", @@ -170,7 +171,8 @@ "selectRange": "Select range", "widgetTitle": "Widget title", "allServers": "All Servers", - "writeMarkdown": "Write markdown here..." + "writeMarkdown": "Write markdown here...", + "optionalLabel": "Override label" }, "empty": { "noServers": "No servers available" @@ -188,6 +190,8 @@ "load15m": "Load (15m)", "networkIn": "Network In", "networkOut": "Network Out", + "network": "Network", + "diskIo": "Disk I/O", "bandwidth": "Bandwidth", "serverCount": "Server Count", "avgCpu": "Average CPU", diff --git a/apps/web/src/locales/zh/dashboard.json b/apps/web/src/locales/zh/dashboard.json index 568cde92..ee003add 100644 --- a/apps/web/src/locales/zh/dashboard.json +++ b/apps/web/src/locales/zh/dashboard.json @@ -162,7 +162,8 @@ "maxItems": "最大条目数", "markdownContent": "Markdown 内容", "days": "天数", - "titleOptional": "标题(可选)" + "titleOptional": "标题(可选)", + "labelOptional": "标签(可选)" }, "placeholders": { "selectServer": "选择服务器", @@ -170,7 +171,8 @@ "selectRange": "选择范围", "widgetTitle": "小组件标题", "allServers": "所有服务器", - "writeMarkdown": "在此编写 Markdown..." + "writeMarkdown": "在此编写 Markdown...", + "optionalLabel": "覆盖默认标签" }, "empty": { "noServers": "暂无可用服务器" @@ -188,6 +190,8 @@ "load15m": "负载(15分钟)", "networkIn": "网络入站", "networkOut": "网络出站", + "network": "网络", + "diskIo": "磁盘 I/O", "bandwidth": "带宽", "serverCount": "服务器数量", "avgCpu": "平均 CPU", From 72918823ec8c346802cb1d123d4ddb2ff646cf07 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 21:04:56 +0800 Subject: [PATCH 20/21] feat(web): i18n strings for metric-card widget --- apps/web/src/locales/en/dashboard.json | 16 ++++++++++++++++ apps/web/src/locales/zh/dashboard.json | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/apps/web/src/locales/en/dashboard.json b/apps/web/src/locales/en/dashboard.json index be1f8bf2..d218e5b3 100644 --- a/apps/web/src/locales/en/dashboard.json +++ b/apps/web/src/locales/en/dashboard.json @@ -43,6 +43,10 @@ "label": "Stat Number", "description": "Single metric value with icon" }, + "metric-card": { + "label": "Metric Card", + "description": "Current value + 1h delta + 24h sparkline & stats" + }, "server-cards": { "label": "Server Cards", "description": "Server overview cards grid" @@ -231,5 +235,17 @@ "loading": "Loading...", "downloading": "Downloading...", "download_failed": "Download failed" + }, + "metricCard": { + "metric": { + "cpu": "CPU", + "memory": "Memory", + "network": "Network", + "diskIo": "Disk I/O" + }, + "past1h": "past 1h", + "peak": "24H PEAK", + "avg": "24H AVG", + "unknownServer": "Unknown server" } } diff --git a/apps/web/src/locales/zh/dashboard.json b/apps/web/src/locales/zh/dashboard.json index ee003add..86c85100 100644 --- a/apps/web/src/locales/zh/dashboard.json +++ b/apps/web/src/locales/zh/dashboard.json @@ -43,6 +43,10 @@ "label": "数值统计", "description": "带图标的单一指标值" }, + "metric-card": { + "label": "指标卡片", + "description": "当前值 + 1h 变化 + 24h sparkline 与峰值/均值" + }, "server-cards": { "label": "服务器卡片", "description": "服务器概览卡片网格" @@ -231,5 +235,17 @@ "loading": "加载中...", "downloading": "下载中...", "download_failed": "下载失败" + }, + "metricCard": { + "metric": { + "cpu": "CPU", + "memory": "内存", + "network": "网络", + "diskIo": "磁盘 I/O" + }, + "past1h": "过去 1 小时", + "peak": "24H 峰值", + "avg": "24H 均值", + "unknownServer": "未知服务器" } } From d76fd58fe6ae9dfacef8cef190798eea47127b11 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 27 May 2026 21:07:36 +0800 Subject: [PATCH 21/21] refactor(web): simplify metric-card form onChange handlers --- apps/web/src/components/dashboard/widget-config-dialog.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/dashboard/widget-config-dialog.tsx b/apps/web/src/components/dashboard/widget-config-dialog.tsx index 4ec12729..bad124ff 100644 --- a/apps/web/src/components/dashboard/widget-config-dialog.tsx +++ b/apps/web/src/components/dashboard/widget-config-dialog.tsx @@ -290,7 +290,7 @@ function MetricCardForm({ <> onChange({ ...config, metric, server_id: v, label })} + onChange={(v) => onChange({ ...config, server_id: v })} placeholder={t('widgets.common.placeholders.selectServer')} servers={servers} value={serverId} @@ -298,14 +298,14 @@ function MetricCardForm({ onChange({ ...config, metric: v as MetricCardMetric, server_id: serverId, label })} + onChange={(v) => onChange({ ...config, metric: v as MetricCardMetric })} placeholder={t('widgets.common.placeholders.selectMetric')} value={metric} />
onChange({ ...config, metric, server_id: serverId, label: e.target.value })} + onChange={(e) => onChange({ ...config, label: e.target.value })} placeholder={t('widgets.common.placeholders.optionalLabel')} value={label} />