From dab45845f4e113177fc7ed61bd4bb578b9596ae5 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 15 Apr 2026 23:55:50 +0800 Subject: [PATCH 01/43] docs: spec for servers table density and disk i/o display --- ...2026-04-15-servers-table-density-design.md | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-15-servers-table-density-design.md diff --git a/docs/superpowers/specs/2026-04-15-servers-table-density-design.md b/docs/superpowers/specs/2026-04-15-servers-table-density-design.md new file mode 100644 index 00000000..551884ef --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-servers-table-density-design.md @@ -0,0 +1,147 @@ +# Servers Table Density & Disk I/O Display Design + +**Date:** 2026-04-15 +**Scope:** `apps/web/src/routes/_authed/servers/index.tsx` (table view only) + +## Problem + +The `/servers?view=table` page has low information density. Each metric cell (CPU / Memory / Disk) is 160px wide but shows only a 1.5px-high progress bar plus a percentage and a single sub-line (`formatBytes(used)`). Disk I/O is collected by the agent (`disk_read_bytes_per_sec`, `disk_write_bytes_per_sec` are already on the `ServerMetrics` WebSocket payload) but not surfaced anywhere in the list view. + +Goals: + +1. Surface Disk I/O in the table. +2. Increase per-cell information density without adding new columns. +3. Keep layout stable across breakpoints (lg / xl). + +Non-goals: + +- Grid view (`ServerCard`) — untouched. +- Adding new columns — the `xl` layout already has 8 columns and is tight. +- Sparkline / mini-chart visualizations — out of scope; we meet density goals with text. + +## Design + +### Cell layouts (column widths unchanged) + +**CPU** (2 rows, 160px) + +``` +▓▓▓▓▓░░░░░ 45% +load 1.23 +``` + +Sub-line shows `load {load1.toFixed(2)}`. `cpu_cores` is not available on the `ServerMetrics` list payload (only on the detail DTO), so we do not display core count here. + +**Memory** (2 rows, 160px) + +``` +▓▓▓▓▓░░░░░ 45% +3.2GB / 8.0GB +``` + +Sub-line upgraded from `formatBytes(used)` to `formatBytes(used) / formatBytes(total)`. + +**Disk** (3 rows, 160px) + +``` +▓▓▓▓▓░░░░░ 45% +120G / 500G +↺ 2.1MB/s ↻ 1.2MB/s +``` + +Third row shows I/O. Rendered **only when** `disk_read_bytes_per_sec !== undefined || disk_write_bytes_per_sec !== undefined`. Missing side shows `0B/s` so the row stays fixed-width. The arrow glyphs (`↺` read, `↻` write) match the existing `ServerCard` convention (see `servers.json` `card_disk_read` / `card_disk_write`). + +**Network** (2 rows, 160px, stays `hidden lg:table-cell`) + +``` +↓ 1.2MB/s ↑ 340KB/s +Σ ↓12GB ↑3.4GB +``` + +Sub-line shows cumulative transfer using `net_in_transfer` / `net_out_transfer`, prefixed with `Σ` to distinguish from the live speed row. + +### Component changes + +**`MiniBar`** (in `apps/web/src/routes/_authed/servers/index.tsx`) + +- `sub` prop type changes from `string | undefined` to `ReactNode | undefined`. +- Rendering: `sub` is wrapped in a single `
`; consumers can pass a fragment with multiple `` / `

` children for multi-line sub content. +- Sub styling stays `text-[10px] text-muted-foreground tabular-nums`. Multi-row cases use `flex flex-col gap-0.5`. + +**CPU column cell** + +```tsx +load {s.load1.toFixed(2)}} /> +``` + +**Memory column cell** + +```tsx +{formatBytes(s.mem_used)} / {formatBytes(s.mem_total)}} /> +``` + +**Disk column cell** + +```tsx +const hasIo = s.disk_read_bytes_per_sec !== undefined || s.disk_write_bytes_per_sec !== undefined + + {formatBytes(s.disk_used)} / {formatBytes(s.disk_total)} + {hasIo && ( + + ↺ {formatSpeed(s.disk_read_bytes_per_sec ?? 0)} ↻ {formatSpeed(s.disk_write_bytes_per_sec ?? 0)} + + )} +

+ } +/> +``` + +**Network column cell** (inline, no `MiniBar`) + +```tsx +
+ + ↓{formatSpeed(s.net_in_speed)} + ↑{formatSpeed(s.net_out_speed)} + + + Σ ↓{formatBytes(s.net_in_transfer)} ↑{formatBytes(s.net_out_transfer)} + +
+``` + +### Row height + +Current row height ≈ 48px. After change: + +- Rows with I/O data (most rows): ≈ 64px (Disk cell has 3 rows). +- Rows without I/O (offline / legacy agents): ≈ 52px (Disk cell has 2 rows). + +This is acceptable given the user's explicit approval to grow row height. + +## Edge cases + +| Case | Behavior | +|------|----------| +| `disk_total === 0` | Existing logic: pct = 0, sub shows `0B / 0B`. No I/O row unless `disk_read_bytes_per_sec` / `disk_write_bytes_per_sec` present. | +| `disk_read_bytes_per_sec === undefined` and `disk_write_bytes_per_sec === undefined` | No I/O row (legacy agent compatibility). | +| Only one I/O side defined | Both shown, missing side renders as `0B/s`. | +| Offline server | Network live speeds become 0; cumulative transfer still shows last known values. Matches existing behavior. | +| `mem_total === 0` / `disk_total === 0` | Sub-line renders `0B / 0B`. `formatBytes(0)` returns `0B`. | + +## Testing + +- No new unit tests for the page itself (existing pattern: table logic lives inline in the route file without tests). +- Manual check: offline agent, legacy agent (no I/O fields), normal agent — verify I/O row visibility and row-height consistency. +- Lint: `bun x ultracite check` and `bun run typecheck`. +- Visual: 1789×963 viewport (user's reported size) and narrow viewport to confirm `hidden lg:` / `hidden xl:` breakpoints still work. + +## Rejected alternatives + +- **Separate Disk I/O column (option A)**: xl layout is already dense; adding a column squeezes `name` or `group`. +- **Single-line disk sub (`120G/500G · ↺2M ↻1M`)**: does not fit in 160px at 10px monospace (~35 chars ≈ 210px). +- **Widen Disk column to 200px** to fit single-line: disrupts existing column balance; forced us to shrink name/group. +- **Sparkline for CPU/Memory/Disk**: out of scope; meets density goal without additional dependencies or renders. From 7113d2b5afc4fef2b446ef4f238f90134d05d0a4 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Thu, 16 Apr 2026 00:03:01 +0800 Subject: [PATCH 02/43] docs: address servers table density spec review feedback --- ...2026-04-15-servers-table-density-design.md | 67 +++++++++++++------ 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/docs/superpowers/specs/2026-04-15-servers-table-density-design.md b/docs/superpowers/specs/2026-04-15-servers-table-density-design.md index 551884ef..c74dfbc5 100644 --- a/docs/superpowers/specs/2026-04-15-servers-table-density-design.md +++ b/docs/superpowers/specs/2026-04-15-servers-table-density-design.md @@ -27,10 +27,10 @@ Non-goals: ``` ▓▓▓▓▓░░░░░ 45% -load 1.23 +Load 1.23 ``` -Sub-line shows `load {load1.toFixed(2)}`. `cpu_cores` is not available on the `ServerMetrics` list payload (only on the detail DTO), so we do not display core count here. +Sub-line shows `{t('card_load')} {load1.toFixed(2)}` (reuses existing `card_load` key in `servers.json` — "Load" / "负载"). `cpu_cores` is not available on the `ServerMetrics` list payload (only on the detail DTO), so we do not display core count here. **Memory** (2 rows, 160px) @@ -49,7 +49,7 @@ Sub-line upgraded from `formatBytes(used)` to `formatBytes(used) / formatBytes(t ↺ 2.1MB/s ↻ 1.2MB/s ``` -Third row shows I/O. Rendered **only when** `disk_read_bytes_per_sec !== undefined || disk_write_bytes_per_sec !== undefined`. Missing side shows `0B/s` so the row stays fixed-width. The arrow glyphs (`↺` read, `↻` write) match the existing `ServerCard` convention (see `servers.json` `card_disk_read` / `card_disk_write`). +Third row shows I/O. Rendered **only when `server.online === true`**. We cannot distinguish "legacy agent that never reports I/O" from "modern agent reporting 0" on the browser, because `crates/common/src/types.rs:172` declares `disk_read_bytes_per_sec: u64` with `#[serde(default)]` — missing fields deserialize to 0 on the server and are re-emitted as numbers to the browser. Offline rows hide the I/O line (value would be a stale last frame); online rows always show it, with legacy / idle agents rendering `↺ 0B/s ↻ 0B/s`. The arrow glyphs (`↺` read, `↻` write) match the existing `ServerCard` convention (see `servers.json` `card_disk_read` / `card_disk_write`). The TypeScript type in `apps/web/src/hooks/use-servers-ws.ts` should be tightened from `disk_read_bytes_per_sec?: number` to `disk_read_bytes_per_sec: number` (non-optional, default 0) to reflect the wire reality. **Network** (2 rows, 160px, stays `hidden lg:table-cell`) @@ -71,7 +71,7 @@ Sub-line shows cumulative transfer using `net_in_transfer` / `net_out_transfer`, **CPU column cell** ```tsx -load {s.load1.toFixed(2)}} /> +{t('card_load')} {s.load1.toFixed(2)}} /> ``` **Memory column cell** @@ -83,15 +83,14 @@ Sub-line shows cumulative transfer using `net_in_transfer` / `net_out_transfer`, **Disk column cell** ```tsx -const hasIo = s.disk_read_bytes_per_sec !== undefined || s.disk_write_bytes_per_sec !== undefined {formatBytes(s.disk_used)} / {formatBytes(s.disk_total)} - {hasIo && ( + {s.online && ( - ↺ {formatSpeed(s.disk_read_bytes_per_sec ?? 0)} ↻ {formatSpeed(s.disk_write_bytes_per_sec ?? 0)} + ↺ {formatSpeed(s.disk_read_bytes_per_sec)} ↻ {formatSpeed(s.disk_write_bytes_per_sec)} )} @@ -99,13 +98,17 @@ const hasIo = s.disk_read_bytes_per_sec !== undefined || s.disk_write_bytes_per_ /> ``` +The visibility gate is `s.online` (not `disk_*` field presence) — see the HIGH rationale under "Cell layouts / Disk" above. + **Network column cell** (inline, no `MiniBar`) ```tsx +const inSpeed = s.online ? s.net_in_speed : 0 +const outSpeed = s.online ? s.net_out_speed : 0
- ↓{formatSpeed(s.net_in_speed)} - ↑{formatSpeed(s.net_out_speed)} + ↓{formatSpeed(inSpeed)} + ↑{formatSpeed(outSpeed)} Σ ↓{formatBytes(s.net_in_transfer)} ↑{formatBytes(s.net_out_transfer)} @@ -113,31 +116,55 @@ const hasIo = s.disk_read_bytes_per_sec !== undefined || s.disk_write_bytes_per_
``` +Live speed is explicitly zeroed when `!s.online`, because `use-servers-ws.ts` keeps the last-frame `net_*_speed` values on `server_offline` (only flips the `online` boolean). Without the zeroing, offline rows would look like they are still pushing traffic. Cumulative `net_*_transfer` keeps its last value intentionally — historical totals do not expire. + ### Row height Current row height ≈ 48px. After change: -- Rows with I/O data (most rows): ≈ 64px (Disk cell has 3 rows). -- Rows without I/O (offline / legacy agents): ≈ 52px (Disk cell has 2 rows). +- Online rows: ≈ 64px (Disk cell has 3 rows — bar, `used/total`, I/O). +- Offline rows: ≈ 52px (Disk cell drops the I/O row). -This is acceptable given the user's explicit approval to grow row height. +The ≈12px in-table jump between online and offline rows is acceptable; offline rows are rare in steady state. User has explicitly approved row-height growth. ## Edge cases | Case | Behavior | |------|----------| -| `disk_total === 0` | Existing logic: pct = 0, sub shows `0B / 0B`. No I/O row unless `disk_read_bytes_per_sec` / `disk_write_bytes_per_sec` present. | -| `disk_read_bytes_per_sec === undefined` and `disk_write_bytes_per_sec === undefined` | No I/O row (legacy agent compatibility). | -| Only one I/O side defined | Both shown, missing side renders as `0B/s`. | -| Offline server | Network live speeds become 0; cumulative transfer still shows last known values. Matches existing behavior. | +| `disk_total === 0` | pct = 0, sub shows `0B / 0B`. I/O row shown if online (as `↺ 0B/s ↻ 0B/s`). | +| Legacy agent (never sends `disk_*_bytes_per_sec`) | Server's `#[serde(default)]` lands 0; browser sees `0` and renders `↺ 0B/s ↻ 0B/s` when the server is online. Indistinguishable from a truly idle disk — this is intentional given the protocol. | +| Offline server — Disk I/O row | Hidden (would be stale last-frame). Disk `used/total` stays (stored value). | +| Offline server — Network live speeds | Rendered as `↓0B/s ↑0B/s` (explicitly zeroed in the cell, since `use-servers-ws.ts` does not clear the fields on `server_offline`). | +| Offline server — Network cumulative | Unchanged (Σ ↓.. ↑..), historical totals do not expire. | | `mem_total === 0` / `disk_total === 0` | Sub-line renders `0B / 0B`. `formatBytes(0)` returns `0B`. | ## Testing -- No new unit tests for the page itself (existing pattern: table logic lives inline in the route file without tests). -- Manual check: offline agent, legacy agent (no I/O fields), normal agent — verify I/O row visibility and row-height consistency. -- Lint: `bun x ultracite check` and `bun run typecheck`. -- Visual: 1789×963 viewport (user's reported size) and narrow viewport to confirm `hidden lg:` / `hidden xl:` breakpoints still work. +The change's two riskiest behaviors — conditional I/O row rendering and offline speed zeroing — must have regression coverage. Visual QA alone is not enough. + +**Refactor for testability**: extract the metric cell renderers from the inline `columns` array into named exports in `apps/web/src/routes/_authed/servers/index.cells.tsx`: + +- `CpuCell({ server })` +- `MemoryCell({ server })` +- `DiskCell({ server })` +- `NetworkCell({ server })` + +The `columns` definition then wires `cell: ({ row }) => `. `MiniBar` and `UpgradeBadgeCell` stay in `index.tsx`. + +**Unit tests** in `apps/web/src/routes/_authed/servers/index.cells.test.tsx` (vitest + RTL, wrapped in `I18nextProvider` per existing `server-card.test.tsx` pattern): + +1. `DiskCell` — online server: I/O row is present and shows both `↺` and `↻` values. +2. `DiskCell` — offline server: I/O row is not rendered (assert neither arrow glyph appears). +3. `DiskCell` — online with `disk_read_bytes_per_sec === 0` and `disk_write_bytes_per_sec === 0`: I/O row still renders as `↺ 0B/s ↻ 0B/s` (documents the "legacy agent ≡ idle disk" behavior). +4. `NetworkCell` — offline server: live speed row shows `↓0B/s ↑0B/s` regardless of the numeric `net_in_speed` / `net_out_speed` fields on the record; cumulative row shows the stored `net_*_transfer` values. +5. `NetworkCell` — online server with non-zero speeds: live speed row reflects the fields. + +**Manual checks**: + +- 1789×963 viewport (user's reported size) — verify Disk row does not overflow at 160px, row height increase is acceptable. +- `hidden lg:` / `hidden xl:` breakpoints — narrow viewport still hides Network / Group / Uptime correctly. + +**Lint / typecheck**: `bun x ultracite check` and `bun run typecheck`. The latter will flag the `disk_read_bytes_per_sec?: number` → `disk_read_bytes_per_sec: number` type tightening if any consumer was relying on `undefined`. ## Rejected alternatives From 3b6b016f8080660d2bdbe9962168270ee7ed55da Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Thu, 16 Apr 2026 00:05:58 +0800 Subject: [PATCH 03/43] docs: implementation plan for servers table density --- .../plans/2026-04-16-servers-table-density.md | 784 ++++++++++++++++++ 1 file changed, 784 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-16-servers-table-density.md diff --git a/docs/superpowers/plans/2026-04-16-servers-table-density.md b/docs/superpowers/plans/2026-04-16-servers-table-density.md new file mode 100644 index 00000000..f181180c --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-servers-table-density.md @@ -0,0 +1,784 @@ +# Servers Table Density & Disk I/O Display 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:** Increase information density of the `/servers` table view and surface Disk I/O per row, implementing the design in `docs/superpowers/specs/2026-04-15-servers-table-density-design.md`. + +**Architecture:** Extract the CPU / Memory / Disk / Network cell renderers from the inline `columns` array in `apps/web/src/routes/_authed/servers/index.tsx` into named, testable components in a sibling `index.cells.tsx` file. Extend `MiniBar` to accept a `ReactNode` sub slot so cells can stack multiple sub-lines. Gate Disk I/O row on `server.online` (protocol cannot distinguish legacy vs idle). Zero live network speeds when offline. + +**Tech Stack:** React 19, TypeScript, vitest + @testing-library/react, react-i18next, TanStack Table, Tailwind v4. + +--- + +## File Structure + +- **Modify** `apps/web/src/hooks/use-servers-ws.ts` — tighten `disk_*_bytes_per_sec` types from `?: number` to `: number`. +- **Modify** `apps/web/src/routes/_authed/servers/index.tsx`: + - Extend `MiniBar`'s `sub` prop from `string` to `ReactNode`. + - Replace inline cell render functions in `columns` with `` etc. +- **Create** `apps/web/src/routes/_authed/servers/index.cells.tsx` — named exports: `CpuCell`, `MemoryCell`, `DiskCell`, `NetworkCell`. Imports `MiniBar` from `./index`. +- **Create** `apps/web/src/routes/_authed/servers/index.cells.test.tsx` — vitest + RTL tests covering the two riskiest behaviors: conditional Disk I/O row and offline network zeroing. + +To avoid a circular-import smell (`index.cells.tsx` needs `MiniBar`; `index.tsx` imports `*Cell` back), `MiniBar` will be moved out of `index.tsx` into `index.cells.tsx` and re-imported by the route file. This keeps all presentational pieces in one place. `UpgradeBadgeCell` stays in `index.tsx` (it's wired to a specific column only). + +--- + +## Task 1: Tighten `ServerMetrics` disk I/O types + +**Files:** +- Modify: `apps/web/src/hooks/use-servers-ws.ts:20,23` + +**Rationale:** `crates/common/src/types.rs:172` declares `disk_read_bytes_per_sec: u64` with `#[serde(default)]`. Missing fields from legacy agents deserialize to 0 on the server and are re-emitted as numbers. The browser never sees `undefined`, so the `?` on these fields is misleading and will cause `hasIo` checks based on `!== undefined` to always be true. + +- [ ] **Step 1: Make the disk I/O fields non-optional** + +Change `apps/web/src/hooks/use-servers-ws.ts` lines 20 and 23: + +```ts +// Before: + disk_read_bytes_per_sec?: number + ... + disk_write_bytes_per_sec?: number + +// After: + disk_read_bytes_per_sec: number + ... + disk_write_bytes_per_sec: number +``` + +- [ ] **Step 2: Verify typecheck passes** + +Run: `cd apps/web && bun run typecheck` +Expected: PASS. If any existing consumer was treating these as optional (e.g., `s.disk_read_bytes_per_sec ?? 0`), the typecheck will surface it — those sites should be updated to read the field directly since it is now always defined. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/hooks/use-servers-ws.ts +git commit -m "refactor(web): tighten disk i/o fields to non-optional on ServerMetrics" +``` + +--- + +## Task 2: Scaffold `index.cells.tsx` and move `MiniBar` + +**Files:** +- Create: `apps/web/src/routes/_authed/servers/index.cells.tsx` +- Modify: `apps/web/src/routes/_authed/servers/index.tsx` (remove local `MiniBar`, import from new file; extend `sub` prop to `ReactNode`) + +- [ ] **Step 1: Create `index.cells.tsx` with the extended `MiniBar`** + +Create `apps/web/src/routes/_authed/servers/index.cells.tsx`: + +```tsx +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +function getBarColor(p: number): string { + if (p > 90) { + return 'bg-red-500' + } + if (p > 70) { + return 'bg-amber-500' + } + return 'bg-emerald-500' +} + +export function MiniBar({ pct, sub }: { pct: number; sub?: ReactNode }) { + const p = Math.min(100, Math.max(0, pct)) + const color = getBarColor(p) + return ( +
+
+
+
+
+ {p.toFixed(0)}% +
+ {sub !== undefined && ( +
{sub}
+ )} +
+ ) +} +``` + +Note: the old `sub` used a `

` with `formatBytes(used)` inline. The new form wraps sub in a generic `

` so callers may pass a fragment of multiple lines (`
`). Font-mono / tabular-nums / text-[10px] move up to the wrapper so individual sub-lines don't need to repeat the styling. + +- [ ] **Step 2: Remove `MiniBar` and `getBarColor` from `index.tsx`, import from `./index.cells`** + +In `apps/web/src/routes/_authed/servers/index.tsx`: + +- Delete the `getBarColor` function (currently around line 468). +- Delete the `MiniBar` function (currently around line 478-492). +- Add an import near the other local imports: + +```tsx +import { MiniBar } from './index.cells' +``` + +- [ ] **Step 3: Update existing inline cell calls to match the new API** + +The three existing call sites (CPU, Memory, Disk columns) need the `sub` prop wrapped properly because the old `sub` was `string`. For this task, preserve the existing sub content but wrap the disk/memory sub in a fragment-compatible node. Concretely: + +```tsx +// CPU cell (old): +cell: ({ row }) => , + +// Memory cell (old inline): +return + +// Disk cell (old inline): +return +``` + +Change the memory and disk cells so the string becomes a node: + +```tsx +// Memory cell (new, temporary — will be replaced in Task 4): +return {formatBytes(s.mem_used)}} /> + +// Disk cell (new, temporary — will be replaced in Task 5): +return {formatBytes(s.disk_used)}} /> +``` + +CPU has no sub today — leave it as is for now; Task 3 adds the load sub-line. + +- [ ] **Step 4: Verify typecheck and existing tests still pass** + +Run: `cd apps/web && bun run typecheck && bun run test -- --run` +Expected: PASS. No behavioral change yet — only a refactor and a `sub` type widening. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/routes/_authed/servers/index.tsx apps/web/src/routes/_authed/servers/index.cells.tsx +git commit -m "refactor(web): extract MiniBar to index.cells.tsx and accept ReactNode sub" +``` + +--- + +## Task 3: Implement and test `CpuCell` (with i18n load) + +**Files:** +- Modify: `apps/web/src/routes/_authed/servers/index.cells.tsx` (add `CpuCell`) +- Create: `apps/web/src/routes/_authed/servers/index.cells.test.tsx` +- Modify: `apps/web/src/routes/_authed/servers/index.tsx` (wire `` into the `cpu` column) + +- [ ] **Step 1: Write the failing test** + +Create `apps/web/src/routes/_authed/servers/index.cells.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { CpuCell } from './index.cells' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})) + +function makeServer(overrides: Partial = {}): ServerMetrics { + return { + id: 'srv-1', + name: 'test-server', + online: true, + country_code: null, + cpu: 45, + cpu_name: null, + disk_read_bytes_per_sec: 0, + disk_total: 500_000_000_000, + disk_used: 120_000_000_000, + disk_write_bytes_per_sec: 0, + group_id: null, + last_active: 0, + load1: 1.23, + load5: 0, + load15: 0, + mem_total: 8_000_000_000, + mem_used: 3_200_000_000, + net_in_speed: 0, + net_in_transfer: 0, + net_out_speed: 0, + net_out_transfer: 0, + os: null, + process_count: 0, + region: null, + swap_total: 0, + swap_used: 0, + tcp_conn: 0, + udp_conn: 0, + uptime: 0, + ...overrides + } +} + +describe('CpuCell', () => { + it('shows cpu percentage and load1', () => { + render() + expect(screen.getByText('45%')).toBeDefined() + // Sub line contains the translated label key and load1 formatted to 2 decimals. + expect(screen.getByText(/card_load\s+1\.23/)).toBeDefined() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/web && bun run test -- --run src/routes/_authed/servers/index.cells.test.tsx` +Expected: FAIL with `CpuCell is not exported from ./index.cells` (or similar). + +- [ ] **Step 3: Implement `CpuCell`** + +Add to `apps/web/src/routes/_authed/servers/index.cells.tsx`: + +```tsx +import { useTranslation } from 'react-i18next' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +``` + +Then append: + +```tsx +export function CpuCell({ server }: { server: ServerMetrics }) { + const { t } = useTranslation(['servers']) + return ( + {t('card_load')} {server.load1.toFixed(2)}} + /> + ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/web && bun run test -- --run src/routes/_authed/servers/index.cells.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Wire `CpuCell` into the table column** + +In `apps/web/src/routes/_authed/servers/index.tsx`, update the cpu column cell: + +```tsx +// Before: +{ + accessorKey: 'cpu', + id: 'cpu', + header: ({ column }) => , + cell: ({ row }) => , + size: 160, + meta: { className: 'w-[160px]' } +}, + +// After: +{ + accessorKey: 'cpu', + id: 'cpu', + header: ({ column }) => , + cell: ({ row }) => , + size: 160, + meta: { className: 'w-[160px]' } +}, +``` + +Add to the `./index.cells` import at the top of `index.tsx`: + +```tsx +import { CpuCell, MiniBar } from './index.cells' +``` + +- [ ] **Step 6: Verify full web test suite still passes** + +Run: `cd apps/web && bun run test -- --run && bun run typecheck` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/routes/_authed/servers/index.tsx apps/web/src/routes/_authed/servers/index.cells.tsx apps/web/src/routes/_authed/servers/index.cells.test.tsx +git commit -m "feat(web): add CpuCell with load1 sub-line" +``` + +--- + +## Task 4: Implement and test `MemoryCell` + +**Files:** +- Modify: `apps/web/src/routes/_authed/servers/index.cells.tsx` +- Modify: `apps/web/src/routes/_authed/servers/index.cells.test.tsx` +- Modify: `apps/web/src/routes/_authed/servers/index.tsx` + +- [ ] **Step 1: Write the failing test** + +Append to `apps/web/src/routes/_authed/servers/index.cells.test.tsx`: + +```tsx +import { MemoryCell } from './index.cells' + +describe('MemoryCell', () => { + it('shows used/total with percentage', () => { + render( + + ) + // 3.2GB / 8.0GB (formatBytes uses 1 decimal, units: B/KB/MB/GB/TB, base 1024) + // 3_200_000_000 / 1024^3 ≈ 2.98 → "3.0 GB" + // 8_000_000_000 / 1024^3 ≈ 7.45 → "7.5 GB" + expect(screen.getByText('3.0 GB / 7.5 GB')).toBeDefined() + // Pct: 3.2e9 / 8e9 = 40 + expect(screen.getByText('40%')).toBeDefined() + }) + + it('renders 0B / 0B when mem_total is zero', () => { + render() + expect(screen.getByText('0 B / 0 B')).toBeDefined() + expect(screen.getByText('0%')).toBeDefined() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/web && bun run test -- --run src/routes/_authed/servers/index.cells.test.tsx` +Expected: FAIL with `MemoryCell is not exported from ./index.cells`. + +- [ ] **Step 3: Implement `MemoryCell`** + +Append to `apps/web/src/routes/_authed/servers/index.cells.tsx`: + +```tsx +import { formatBytes } from '@/lib/utils' + +// ...existing code... + +export function MemoryCell({ server }: { server: ServerMetrics }) { + const pct = server.mem_total > 0 ? (server.mem_used / server.mem_total) * 100 : 0 + return ( + {formatBytes(server.mem_used)} / {formatBytes(server.mem_total)}} + /> + ) +} +``` + +(Consolidate the `formatBytes` import at the top of the file, not inside the cell.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/web && bun run test -- --run src/routes/_authed/servers/index.cells.test.tsx` +Expected: PASS (all 3 cases). + +- [ ] **Step 5: Wire `MemoryCell` into the table column** + +In `apps/web/src/routes/_authed/servers/index.tsx`, update the memory column cell and import: + +```tsx +import { CpuCell, MemoryCell, MiniBar } from './index.cells' +``` + +```tsx +// Before: +{ + accessorFn: (row) => (row.mem_total > 0 ? row.mem_used / row.mem_total : 0), + id: 'memory', + header: ({ column }) => , + cell: ({ row }) => { + const s = row.original + const memPct = s.mem_total > 0 ? (s.mem_used / s.mem_total) * 100 : 0 + return {formatBytes(s.mem_used)}} /> + }, + size: 160, + meta: { className: 'w-[160px]' } +}, + +// After: +{ + accessorFn: (row) => (row.mem_total > 0 ? row.mem_used / row.mem_total : 0), + id: 'memory', + header: ({ column }) => , + cell: ({ row }) => , + size: 160, + meta: { className: 'w-[160px]' } +}, +``` + +- [ ] **Step 6: Verify full test suite** + +Run: `cd apps/web && bun run test -- --run && bun run typecheck` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/routes/_authed/servers/index.cells.tsx apps/web/src/routes/_authed/servers/index.cells.test.tsx apps/web/src/routes/_authed/servers/index.tsx +git commit -m "feat(web): add MemoryCell showing used/total" +``` + +--- + +## Task 5: Implement and test `DiskCell` (with online-gated I/O row) + +**Files:** +- Modify: `apps/web/src/routes/_authed/servers/index.cells.tsx` +- Modify: `apps/web/src/routes/_authed/servers/index.cells.test.tsx` +- Modify: `apps/web/src/routes/_authed/servers/index.tsx` + +This is the most important task — the I/O row is the new feature and offline behavior is a correctness contract. + +- [ ] **Step 1: Write the failing tests** + +Append to `apps/web/src/routes/_authed/servers/index.cells.test.tsx`: + +```tsx +import { DiskCell } from './index.cells' + +describe('DiskCell', () => { + it('shows used/total and I/O row when online', () => { + render( + + ) + // used/total line — 120e9 → 111.8 GB, 500e9 → 465.7 GB + expect(screen.getByText('111.8 GB / 465.7 GB')).toBeDefined() + // I/O row shows both arrows + expect(screen.getByText(/↺.*2\.0 MB\/s.*↻.*1\.1 MB\/s/)).toBeDefined() + }) + + it('hides I/O row when offline', () => { + render( + + ) + expect(screen.getByText('111.8 GB / 465.7 GB')).toBeDefined() + // Neither arrow glyph should appear + expect(screen.queryByText(/↺/)).toBeNull() + expect(screen.queryByText(/↻/)).toBeNull() + }) + + it('renders 0 B/s arrows when online with zero I/O (legacy agent or idle)', () => { + render( + + ) + // Regex allows one or more spaces between tokens + expect(screen.getByText(/↺\s+0 B\/s\s+↻\s+0 B\/s/)).toBeDefined() + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/web && bun run test -- --run src/routes/_authed/servers/index.cells.test.tsx` +Expected: FAIL with `DiskCell is not exported from ./index.cells`. + +- [ ] **Step 3: Implement `DiskCell`** + +Append to `apps/web/src/routes/_authed/servers/index.cells.tsx`: + +```tsx +import { formatSpeed } from '@/lib/utils' + +// (add formatSpeed to the existing formatBytes import if already present: +// import { formatBytes, formatSpeed } from '@/lib/utils') + +export function DiskCell({ server }: { server: ServerMetrics }) { + const pct = server.disk_total > 0 ? (server.disk_used / server.disk_total) * 100 : 0 + return ( + + {formatBytes(server.disk_used)} / {formatBytes(server.disk_total)} + {server.online && ( + + ↺ {formatSpeed(server.disk_read_bytes_per_sec)} ↻ {formatSpeed(server.disk_write_bytes_per_sec)} + + )} +
+ } + /> + ) +} +``` + +Note the two spaces between `↺ ...` and `↻ ...` — this creates visual separation at 10px font size without needing a border or pipe. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/web && bun run test -- --run src/routes/_authed/servers/index.cells.test.tsx` +Expected: PASS (all DiskCell cases). + +- [ ] **Step 5: Wire `DiskCell` into the table column** + +In `apps/web/src/routes/_authed/servers/index.tsx`, update the disk column and import: + +```tsx +import { CpuCell, DiskCell, MemoryCell, MiniBar } from './index.cells' +``` + +```tsx +// Before: +{ + accessorFn: (row) => (row.disk_total > 0 ? row.disk_used / row.disk_total : 0), + id: 'disk', + header: ({ column }) => , + cell: ({ row }) => { + const s = row.original + const diskPct = s.disk_total > 0 ? (s.disk_used / s.disk_total) * 100 : 0 + return {formatBytes(s.disk_used)}} /> + }, + size: 160, + meta: { className: 'w-[160px]' } +}, + +// After: +{ + accessorFn: (row) => (row.disk_total > 0 ? row.disk_used / row.disk_total : 0), + id: 'disk', + header: ({ column }) => , + cell: ({ row }) => , + size: 160, + meta: { className: 'w-[160px]' } +}, +``` + +- [ ] **Step 6: Verify full test suite and types** + +Run: `cd apps/web && bun run test -- --run && bun run typecheck` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/routes/_authed/servers/index.cells.tsx apps/web/src/routes/_authed/servers/index.cells.test.tsx apps/web/src/routes/_authed/servers/index.tsx +git commit -m "feat(web): add DiskCell with online-gated i/o row" +``` + +--- + +## Task 6: Implement and test `NetworkCell` (with offline zeroing + cumulative) + +**Files:** +- Modify: `apps/web/src/routes/_authed/servers/index.cells.tsx` +- Modify: `apps/web/src/routes/_authed/servers/index.cells.test.tsx` +- Modify: `apps/web/src/routes/_authed/servers/index.tsx` + +- [ ] **Step 1: Write the failing tests** + +Append to `apps/web/src/routes/_authed/servers/index.cells.test.tsx`: + +```tsx +import { NetworkCell } from './index.cells' + +describe('NetworkCell', () => { + it('shows live speed and cumulative when online', () => { + render( + + ) + // Live speeds + expect(screen.getByText(/↓1\.1 MB\/s/)).toBeDefined() + expect(screen.getByText(/↑332\.0 KB\/s/)).toBeDefined() + // Cumulative row + expect(screen.getByText(/Σ\s*↓11\.2 GB\s+↑3\.2 GB/)).toBeDefined() + }) + + it('zeroes live speed and keeps cumulative when offline', () => { + render( + + ) + // Live speed zeroed + expect(screen.getByText(/↓0 B\/s/)).toBeDefined() + expect(screen.getByText(/↑0 B\/s/)).toBeDefined() + // Should NOT show the stale values + expect(screen.queryByText(/↓1\.1 MB\/s/)).toBeNull() + // Cumulative still present + expect(screen.getByText(/Σ\s*↓11\.2 GB\s+↑3\.2 GB/)).toBeDefined() + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/web && bun run test -- --run src/routes/_authed/servers/index.cells.test.tsx` +Expected: FAIL with `NetworkCell is not exported from ./index.cells`. + +- [ ] **Step 3: Implement `NetworkCell`** + +Append to `apps/web/src/routes/_authed/servers/index.cells.tsx`: + +```tsx +export function NetworkCell({ server }: { server: ServerMetrics }) { + const inSpeed = server.online ? server.net_in_speed : 0 + const outSpeed = server.online ? server.net_out_speed : 0 + return ( +
+ + ↓{formatSpeed(inSpeed)} + ↑{formatSpeed(outSpeed)} + + + Σ ↓{formatBytes(server.net_in_transfer)} ↑{formatBytes(server.net_out_transfer)} + +
+ ) +} +``` + +Note `NetworkCell` does not use `MiniBar` — it is a two-line inline text cell, not a progress bar. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/web && bun run test -- --run src/routes/_authed/servers/index.cells.test.tsx` +Expected: PASS (all NetworkCell cases). + +- [ ] **Step 5: Wire `NetworkCell` into the table column** + +In `apps/web/src/routes/_authed/servers/index.tsx`, update the network column and import: + +```tsx +import { CpuCell, DiskCell, MemoryCell, MiniBar, NetworkCell } from './index.cells' +``` + +```tsx +// Before: +{ + id: 'network', + enableSorting: false, + header: () => {t('col_network')}, + cell: ({ row }) => { + const s = row.original + return ( + + ↓{formatSpeed(s.net_in_speed)} + ↑{formatSpeed(s.net_out_speed)} + + ) + }, + size: 160, + meta: { className: 'hidden lg:table-cell lg:w-[160px]' } +}, + +// After: +{ + id: 'network', + enableSorting: false, + header: () => {t('col_network')}, + cell: ({ row }) => , + size: 160, + meta: { className: 'hidden lg:table-cell lg:w-[160px]' } +}, +``` + +Now the top-level imports in `index.tsx` should no longer need `formatSpeed` or `formatBytes` at the route level (they are only used by cell components). Remove any now-unused imports — the existing `import { cn, countryCodeToFlag, formatBytes, formatSpeed, formatUptime } from '@/lib/utils'` should be trimmed based on what else uses them in the file. `countryCodeToFlag` and `formatUptime` are still used by the `name` and `uptime` columns respectively; `cn` may be unused after MiniBar moved out. + +- [ ] **Step 6: Verify full test suite and types** + +Run: `cd apps/web && bun run test -- --run && bun run typecheck && bun x ultracite check apps/web/src/routes/_authed/servers/` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/routes/_authed/servers/index.cells.tsx apps/web/src/routes/_authed/servers/index.cells.test.tsx apps/web/src/routes/_authed/servers/index.tsx +git commit -m "feat(web): add NetworkCell with offline zeroing and cumulative row" +``` + +--- + +## Task 7: Final verification + +**Files:** none — verification only. + +- [ ] **Step 1: Run the full frontend test suite** + +Run: `cd apps/web && bun run test -- --run` +Expected: PASS. Watch for any regressions in pre-existing tests that might be sensitive to MiniBar's sub-line DOM change (the `

` → `

` wrapper, font-mono / text-[10px] moved up from the sub-line to the wrapper). If any test queries sub content by tag or class, update it to match the new structure. + +- [ ] **Step 2: Typecheck both web targets** + +Run: `bun run typecheck` +Expected: PASS. + +- [ ] **Step 3: Lint** + +Run: `bun x ultracite check apps/web/src/routes/_authed/servers/ apps/web/src/hooks/use-servers-ws.ts` +Expected: no warnings/errors. Auto-fix with `bun x ultracite fix ` if any formatting drift. + +- [ ] **Step 4: Manual visual check** + +Start the dev server against prod data for realistic content: `make web-dev-prod` (requires `SERVERBEE_PROD_URL` and `SERVERBEE_PROD_READONLY_API_KEY` per `CLAUDE.md`). Open `http://localhost:5173/servers?view=table`. + +Verify at 1789×963 viewport: +- Each online row's Disk cell has 3 sub-lines: percentage row, `used/total`, I/O `↺ ↻`. +- Any offline row's Disk cell has only 2 sub-lines (no I/O row). +- Any offline row's Network cell shows `↓0 B/s ↑0 B/s` in the live-speed row, with the Σ cumulative row unchanged. +- CPU sub-line reads `Load 1.23` (or `负载 1.23` depending on locale), not `load 1.23`. +- Memory sub-line shows `used / total` (e.g. `3.0 GB / 7.5 GB`), not just `used`. +- Row height increase is visually acceptable (no layout break, no overflow). + +Also resize the browser below the `lg` breakpoint (~<1024px) and confirm the Network column is hidden as before. + +- [ ] **Step 5: Report completion** + +If everything passes, the feature is done — no additional commit (no code changes in this task). + +If manual check surfaces a visual issue (e.g., row content overflows 160px), file a follow-up rather than patching blindly; report what was seen and which cell/viewport. + +--- + +## Self-Review Summary + +**Spec coverage:** +- Disk I/O row with online gating — Task 5 ✓ +- Memory used/total upgrade — Task 4 ✓ +- CPU load1 with i18n — Task 3 ✓ +- Network zero-on-offline + cumulative — Task 6 ✓ +- TS type tightening — Task 1 ✓ +- MiniBar ReactNode sub — Task 2 ✓ +- Named cell extraction + tests — Tasks 3–6 ✓ +- Lint/typecheck/manual QA — Task 7 ✓ + +**Not applicable:** Grid view (`ServerCard`) and Rust protocol changes are explicitly out of scope per the spec's non-goals. + +**Placeholder scan:** None. + +**Type consistency:** All cells take `{ server: ServerMetrics }`. `MiniBar` signature is consistent across tasks (`{ pct: number; sub?: ReactNode }`). From d0c0e1efe34ded6d3c33657ac34651b8393444ac Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Thu, 16 Apr 2026 00:10:48 +0800 Subject: [PATCH 04/43] fix(web): align dashboard default star icons --- .../dashboard/dashboard-switcher.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/dashboard/dashboard-switcher.tsx b/apps/web/src/components/dashboard/dashboard-switcher.tsx index 43caa2a9..7dc29fc5 100644 --- a/apps/web/src/components/dashboard/dashboard-switcher.tsx +++ b/apps/web/src/components/dashboard/dashboard-switcher.tsx @@ -97,15 +97,23 @@ export function DashboardSwitcher({ dashboards, currentId, onSelect, isAdmin }: {dashboards.map((d) => ( - {d.is_default && } - {d.name} + + {d.is_default && } + {d.name} + ))} {isAdmin && !isDefault && ( - )} @@ -125,7 +133,13 @@ export function DashboardSwitcher({ dashboards, currentId, onSelect, isAdmin }: )} {isAdmin && !isDefault && ( - )} From cda96bf0ee0d59a9befef4f7838c2a5bde3d1ff5 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Thu, 16 Apr 2026 00:11:58 +0800 Subject: [PATCH 05/43] refactor(web): tighten disk i/o fields to non-optional on ServerMetrics --- apps/web/src/hooks/use-servers-ws.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/use-servers-ws.ts b/apps/web/src/hooks/use-servers-ws.ts index 65ec5382..bac91f66 100644 --- a/apps/web/src/hooks/use-servers-ws.ts +++ b/apps/web/src/hooks/use-servers-ws.ts @@ -17,10 +17,10 @@ interface ServerMetrics { country_code: string | null cpu: number cpu_name: string | null - disk_read_bytes_per_sec?: number + disk_read_bytes_per_sec: number disk_total: number disk_used: number - disk_write_bytes_per_sec?: number + disk_write_bytes_per_sec: number effective_capabilities?: number | null features?: string[] group_id: string | null From a3b51b81e711032e417e94d3eb28607798677061 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Thu, 16 Apr 2026 00:13:30 +0800 Subject: [PATCH 06/43] refactor(web): remove stale disk i/o fallbacks from server card --- apps/web/src/components/server/server-card.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/server/server-card.tsx b/apps/web/src/components/server/server-card.tsx index d42d90c3..bb4bb19b 100644 --- a/apps/web/src/components/server/server-card.tsx +++ b/apps/web/src/components/server/server-card.tsx @@ -277,12 +277,12 @@ const ServerCardInner = ({ server }: ServerCardProps) => { Date: Thu, 16 Apr 2026 00:17:22 +0800 Subject: [PATCH 07/43] refactor(web): extract MiniBar to index.cells.tsx and accept ReactNode sub --- .../routes/_authed/servers/index.cells.tsx | 30 +++++++++++++++++ apps/web/src/routes/_authed/servers/index.tsx | 33 +++---------------- 2 files changed, 34 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/routes/_authed/servers/index.cells.tsx diff --git a/apps/web/src/routes/_authed/servers/index.cells.tsx b/apps/web/src/routes/_authed/servers/index.cells.tsx new file mode 100644 index 00000000..6f2b07e9 --- /dev/null +++ b/apps/web/src/routes/_authed/servers/index.cells.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +function getBarColor(p: number): string { + if (p > 90) { + return 'bg-red-500' + } + if (p > 70) { + return 'bg-amber-500' + } + return 'bg-emerald-500' +} + +export function MiniBar({ pct, sub }: { pct: number; sub?: ReactNode }) { + const p = Math.min(100, Math.max(0, pct)) + const color = getBarColor(p) + return ( +
+
+
+
+
+ {p.toFixed(0)}% +
+ {sub !== undefined && ( +
{sub}
+ )} +
+ ) +} diff --git a/apps/web/src/routes/_authed/servers/index.tsx b/apps/web/src/routes/_authed/servers/index.tsx index 54052e67..694c8b11 100644 --- a/apps/web/src/routes/_authed/servers/index.tsx +++ b/apps/web/src/routes/_authed/servers/index.tsx @@ -34,7 +34,8 @@ import type { ServerMetrics } from '@/hooks/use-servers-ws' import { api } from '@/lib/api-client' import type { ServerGroup } from '@/lib/api-schema' import { countCleanupCandidates } from '@/lib/orphan-server-utils' -import { cn, countryCodeToFlag, formatBytes, formatSpeed, formatUptime } from '@/lib/utils' +import { countryCodeToFlag, formatBytes, formatSpeed, formatUptime } from '@/lib/utils' +import { MiniBar } from './index.cells' import { useUpgradeJobsStore } from '@/stores/upgrade-jobs-store' function UpgradeBadgeCell({ serverId }: { serverId: string }) { @@ -206,7 +207,7 @@ function ServersListPage() { cell: ({ row }) => { const s = row.original const memPct = s.mem_total > 0 ? (s.mem_used / s.mem_total) * 100 : 0 - return + return {formatBytes(s.mem_used)}} /> }, size: 160, meta: { className: 'w-[160px]' } @@ -218,7 +219,7 @@ function ServersListPage() { cell: ({ row }) => { const s = row.original const diskPct = s.disk_total > 0 ? (s.disk_used / s.disk_total) * 100 : 0 - return + return {formatBytes(s.disk_used)}} /> }, size: 160, meta: { className: 'w-[160px]' } @@ -465,32 +466,6 @@ function ServersListPage() { ) } -function getBarColor(p: number): string { - if (p > 90) { - return 'bg-red-500' - } - if (p > 70) { - return 'bg-amber-500' - } - return 'bg-emerald-500' -} - -function MiniBar({ pct, sub }: { pct: number; sub?: string }) { - const p = Math.min(100, Math.max(0, pct)) - const color = getBarColor(p) - return ( -
-
-
-
-
- {p.toFixed(0)}% -
- {sub &&

{sub}

} -
- ) -} - function EditWrapper({ serverId, onClose }: { onClose: () => void; serverId: string }) { const { data: server, isLoading } = useServer(serverId) From d1387c380d9330c1cee7616dda51c3771706000d Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Thu, 16 Apr 2026 00:20:35 +0800 Subject: [PATCH 08/43] fix(web): organize servers index imports for ultracite --- apps/web/src/routes/_authed/servers/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/routes/_authed/servers/index.tsx b/apps/web/src/routes/_authed/servers/index.tsx index 694c8b11..dae0c70a 100644 --- a/apps/web/src/routes/_authed/servers/index.tsx +++ b/apps/web/src/routes/_authed/servers/index.tsx @@ -35,8 +35,8 @@ import { api } from '@/lib/api-client' import type { ServerGroup } from '@/lib/api-schema' import { countCleanupCandidates } from '@/lib/orphan-server-utils' import { countryCodeToFlag, formatBytes, formatSpeed, formatUptime } from '@/lib/utils' -import { MiniBar } from './index.cells' import { useUpgradeJobsStore } from '@/stores/upgrade-jobs-store' +import { MiniBar } from './index.cells' function UpgradeBadgeCell({ serverId }: { serverId: string }) { const job = useUpgradeJobsStore((state) => state.jobs.get(serverId)) From 088779005b1fa70be0a28a1bf550b66633ab05e6 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Thu, 16 Apr 2026 00:22:36 +0800 Subject: [PATCH 09/43] feat(web): add CpuCell with load1 sub-line --- .../_authed/servers/index.cells.test.tsx | 51 +++++++++++++++++++ .../routes/_authed/servers/index.cells.tsx | 16 ++++++ apps/web/src/routes/_authed/servers/index.tsx | 4 +- 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/routes/_authed/servers/index.cells.test.tsx diff --git a/apps/web/src/routes/_authed/servers/index.cells.test.tsx b/apps/web/src/routes/_authed/servers/index.cells.test.tsx new file mode 100644 index 00000000..b34cc1e7 --- /dev/null +++ b/apps/web/src/routes/_authed/servers/index.cells.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { CpuCell } from './index.cells' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})) + +function makeServer(overrides: Partial = {}): ServerMetrics { + return { + id: 'srv-1', + name: 'test-server', + online: true, + country_code: null, + cpu: 45, + cpu_name: null, + disk_read_bytes_per_sec: 0, + disk_total: 500_000_000_000, + disk_used: 120_000_000_000, + disk_write_bytes_per_sec: 0, + group_id: null, + last_active: 0, + load1: 1.23, + load5: 0, + load15: 0, + mem_total: 8_000_000_000, + mem_used: 3_200_000_000, + net_in_speed: 0, + net_in_transfer: 0, + net_out_speed: 0, + net_out_transfer: 0, + os: null, + process_count: 0, + region: null, + swap_total: 0, + swap_used: 0, + tcp_conn: 0, + udp_conn: 0, + uptime: 0, + ...overrides + } +} + +describe('CpuCell', () => { + it('shows cpu percentage and load1', () => { + render() + expect(screen.getByText('45%')).toBeDefined() + expect(screen.getByText(/card_load\s+1\.23/)).toBeDefined() + }) +}) diff --git a/apps/web/src/routes/_authed/servers/index.cells.tsx b/apps/web/src/routes/_authed/servers/index.cells.tsx index 6f2b07e9..697d4db6 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.tsx @@ -1,4 +1,6 @@ import type { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import type { ServerMetrics } from '@/hooks/use-servers-ws' import { cn } from '@/lib/utils' function getBarColor(p: number): string { @@ -28,3 +30,17 @@ export function MiniBar({ pct, sub }: { pct: number; sub?: ReactNode }) {
) } + +export function CpuCell({ server }: { server: ServerMetrics }) { + const { t } = useTranslation(['servers']) + return ( + + {t('card_load')} {server.load1.toFixed(2)} + + } + /> + ) +} diff --git a/apps/web/src/routes/_authed/servers/index.tsx b/apps/web/src/routes/_authed/servers/index.tsx index dae0c70a..ab517cec 100644 --- a/apps/web/src/routes/_authed/servers/index.tsx +++ b/apps/web/src/routes/_authed/servers/index.tsx @@ -36,7 +36,7 @@ import type { ServerGroup } from '@/lib/api-schema' import { countCleanupCandidates } from '@/lib/orphan-server-utils' import { countryCodeToFlag, formatBytes, formatSpeed, formatUptime } from '@/lib/utils' import { useUpgradeJobsStore } from '@/stores/upgrade-jobs-store' -import { MiniBar } from './index.cells' +import { CpuCell, MiniBar } from './index.cells' function UpgradeBadgeCell({ serverId }: { serverId: string }) { const job = useUpgradeJobsStore((state) => state.jobs.get(serverId)) @@ -196,7 +196,7 @@ function ServersListPage() { accessorKey: 'cpu', id: 'cpu', header: ({ column }) => , - cell: ({ row }) => , + cell: ({ row }) => , size: 160, meta: { className: 'w-[160px]' } }, From 18c9c4c5b2ac3c00df9ad0cbb2078af593905629 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Thu, 16 Apr 2026 00:26:33 +0800 Subject: [PATCH 10/43] fix(web): hoist cpu cell test regex for ultracite --- apps/web/src/routes/_authed/servers/index.cells.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/routes/_authed/servers/index.cells.test.tsx b/apps/web/src/routes/_authed/servers/index.cells.test.tsx index b34cc1e7..6885c277 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.test.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.test.tsx @@ -3,6 +3,8 @@ import { describe, expect, it, vi } from 'vitest' import type { ServerMetrics } from '@/hooks/use-servers-ws' import { CpuCell } from './index.cells' +const CPU_LOAD_TEXT = /card_load\s+1\.23/ + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) @@ -46,6 +48,6 @@ describe('CpuCell', () => { it('shows cpu percentage and load1', () => { render() expect(screen.getByText('45%')).toBeDefined() - expect(screen.getByText(/card_load\s+1\.23/)).toBeDefined() + expect(screen.getByText(CPU_LOAD_TEXT)).toBeDefined() }) }) From fb0f5fd832d8e412ab88759a74bd3be34c49d79e Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Thu, 16 Apr 2026 00:29:14 +0800 Subject: [PATCH 11/43] feat(web): add MemoryCell showing used/total --- .../routes/_authed/servers/index.cells.test.tsx | 16 +++++++++++++++- .../src/routes/_authed/servers/index.cells.tsx | 16 +++++++++++++++- apps/web/src/routes/_authed/servers/index.tsx | 8 ++------ 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/apps/web/src/routes/_authed/servers/index.cells.test.tsx b/apps/web/src/routes/_authed/servers/index.cells.test.tsx index 6885c277..922e6341 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.test.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import type { ServerMetrics } from '@/hooks/use-servers-ws' -import { CpuCell } from './index.cells' +import { CpuCell, MemoryCell } from './index.cells' const CPU_LOAD_TEXT = /card_load\s+1\.23/ @@ -51,3 +51,17 @@ describe('CpuCell', () => { expect(screen.getByText(CPU_LOAD_TEXT)).toBeDefined() }) }) + +describe('MemoryCell', () => { + it('shows used/total with percentage', () => { + render() + expect(screen.getByText('3.0 GB / 7.5 GB')).toBeDefined() + expect(screen.getByText('40%')).toBeDefined() + }) + + it('renders 0B / 0B when mem_total is zero', () => { + render() + expect(screen.getByText('0 B / 0 B')).toBeDefined() + expect(screen.getByText('0%')).toBeDefined() + }) +}) diff --git a/apps/web/src/routes/_authed/servers/index.cells.tsx b/apps/web/src/routes/_authed/servers/index.cells.tsx index 697d4db6..66b38333 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import { useTranslation } from 'react-i18next' import type { ServerMetrics } from '@/hooks/use-servers-ws' -import { cn } from '@/lib/utils' +import { cn, formatBytes } from '@/lib/utils' function getBarColor(p: number): string { if (p > 90) { @@ -44,3 +44,17 @@ export function CpuCell({ server }: { server: ServerMetrics }) { /> ) } + +export function MemoryCell({ server }: { server: ServerMetrics }) { + const pct = server.mem_total > 0 ? (server.mem_used / server.mem_total) * 100 : 0 + return ( + + {formatBytes(server.mem_used)} / {formatBytes(server.mem_total)} + + } + /> + ) +} diff --git a/apps/web/src/routes/_authed/servers/index.tsx b/apps/web/src/routes/_authed/servers/index.tsx index ab517cec..cc53f9b9 100644 --- a/apps/web/src/routes/_authed/servers/index.tsx +++ b/apps/web/src/routes/_authed/servers/index.tsx @@ -36,7 +36,7 @@ import type { ServerGroup } from '@/lib/api-schema' import { countCleanupCandidates } from '@/lib/orphan-server-utils' import { countryCodeToFlag, formatBytes, formatSpeed, formatUptime } from '@/lib/utils' import { useUpgradeJobsStore } from '@/stores/upgrade-jobs-store' -import { CpuCell, MiniBar } from './index.cells' +import { CpuCell, MemoryCell, MiniBar } from './index.cells' function UpgradeBadgeCell({ serverId }: { serverId: string }) { const job = useUpgradeJobsStore((state) => state.jobs.get(serverId)) @@ -204,11 +204,7 @@ function ServersListPage() { accessorFn: (row) => (row.mem_total > 0 ? row.mem_used / row.mem_total : 0), id: 'memory', header: ({ column }) => , - cell: ({ row }) => { - const s = row.original - const memPct = s.mem_total > 0 ? (s.mem_used / s.mem_total) * 100 : 0 - return {formatBytes(s.mem_used)}} /> - }, + cell: ({ row }) => , size: 160, meta: { className: 'w-[160px]' } }, From 4bc1d865b985473c3f3b53a20ecaf5573ef675b8 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Thu, 16 Apr 2026 00:33:59 +0800 Subject: [PATCH 12/43] feat(web): add DiskCell with online-gated i/o row --- .../_authed/servers/index.cells.test.tsx | 56 ++++++++++++++++++- .../routes/_authed/servers/index.cells.tsx | 24 +++++++- apps/web/src/routes/_authed/servers/index.tsx | 10 +--- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/apps/web/src/routes/_authed/servers/index.cells.test.tsx b/apps/web/src/routes/_authed/servers/index.cells.test.tsx index 922e6341..eebea244 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.test.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.test.tsx @@ -1,9 +1,12 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import type { ServerMetrics } from '@/hooks/use-servers-ws' -import { CpuCell, MemoryCell } from './index.cells' +import { CpuCell, DiskCell, MemoryCell } from './index.cells' const CPU_LOAD_TEXT = /card_load\s+1\.23/ +const DISK_USAGE_TEXT = '111.8 GB / 465.7 GB' +const DISK_IO_TEXT = /↺\s+2\.0 MB\/s\s+↻\s+1\.1 MB\/s/ +const DISK_ZERO_IO_TEXT = /↺\s+0 B\/s\s+↻\s+0 B\/s/ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) @@ -65,3 +68,54 @@ describe('MemoryCell', () => { expect(screen.getByText('0%')).toBeDefined() }) }) + +describe('DiskCell', () => { + it('shows used/total and io row when online', () => { + render( + + ) + + expect(screen.getByText(DISK_USAGE_TEXT)).toBeDefined() + expect(screen.getByText('24%')).toBeDefined() + expect(screen.getByText(DISK_IO_TEXT)).toBeDefined() + }) + + it('shows used/total but hides io row when offline', () => { + render( + + ) + + expect(screen.getByText(DISK_USAGE_TEXT)).toBeDefined() + expect(screen.queryByText(DISK_IO_TEXT)).toBeNull() + }) + + it('shows zero io speeds when online and idle', () => { + render( + + ) + + expect(screen.getByText(DISK_ZERO_IO_TEXT)).toBeDefined() + }) +}) diff --git a/apps/web/src/routes/_authed/servers/index.cells.tsx b/apps/web/src/routes/_authed/servers/index.cells.tsx index 66b38333..d12eb12a 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import { useTranslation } from 'react-i18next' import type { ServerMetrics } from '@/hooks/use-servers-ws' -import { cn, formatBytes } from '@/lib/utils' +import { cn, formatBytes, formatSpeed } from '@/lib/utils' function getBarColor(p: number): string { if (p > 90) { @@ -58,3 +58,25 @@ export function MemoryCell({ server }: { server: ServerMetrics }) { /> ) } + +export function DiskCell({ server }: { server: ServerMetrics }) { + const pct = server.disk_total > 0 ? (server.disk_used / server.disk_total) * 100 : 0 + return ( + + + {formatBytes(server.disk_used)} / {formatBytes(server.disk_total)} + + {server.online && ( + + ↺ {formatSpeed(server.disk_read_bytes_per_sec)} + {' '}↻ {formatSpeed(server.disk_write_bytes_per_sec)} + + )} +
+ } + /> + ) +} diff --git a/apps/web/src/routes/_authed/servers/index.tsx b/apps/web/src/routes/_authed/servers/index.tsx index cc53f9b9..1807dd9d 100644 --- a/apps/web/src/routes/_authed/servers/index.tsx +++ b/apps/web/src/routes/_authed/servers/index.tsx @@ -34,9 +34,9 @@ import type { ServerMetrics } from '@/hooks/use-servers-ws' import { api } from '@/lib/api-client' import type { ServerGroup } from '@/lib/api-schema' import { countCleanupCandidates } from '@/lib/orphan-server-utils' -import { countryCodeToFlag, formatBytes, formatSpeed, formatUptime } from '@/lib/utils' +import { countryCodeToFlag, formatSpeed, formatUptime } from '@/lib/utils' import { useUpgradeJobsStore } from '@/stores/upgrade-jobs-store' -import { CpuCell, MemoryCell, MiniBar } from './index.cells' +import { CpuCell, DiskCell, MemoryCell } from './index.cells' function UpgradeBadgeCell({ serverId }: { serverId: string }) { const job = useUpgradeJobsStore((state) => state.jobs.get(serverId)) @@ -212,11 +212,7 @@ function ServersListPage() { accessorFn: (row) => (row.disk_total > 0 ? row.disk_used / row.disk_total : 0), id: 'disk', header: ({ column }) => , - cell: ({ row }) => { - const s = row.original - const diskPct = s.disk_total > 0 ? (s.disk_used / s.disk_total) * 100 : 0 - return {formatBytes(s.disk_used)}} /> - }, + cell: ({ row }) => , size: 160, meta: { className: 'w-[160px]' } }, From cf11bb7224c11144f7d4494addf14cc7add314d4 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Thu, 16 Apr 2026 00:39:41 +0800 Subject: [PATCH 13/43] fix(web): harden disk cell io row rendering --- .../routes/_authed/servers/index.cells.test.tsx | 17 ++++++++++++----- .../src/routes/_authed/servers/index.cells.tsx | 6 +++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/web/src/routes/_authed/servers/index.cells.test.tsx b/apps/web/src/routes/_authed/servers/index.cells.test.tsx index eebea244..6d595d43 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.test.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.test.tsx @@ -5,8 +5,12 @@ import { CpuCell, DiskCell, MemoryCell } from './index.cells' const CPU_LOAD_TEXT = /card_load\s+1\.23/ const DISK_USAGE_TEXT = '111.8 GB / 465.7 GB' -const DISK_IO_TEXT = /↺\s+2\.0 MB\/s\s+↻\s+1\.1 MB\/s/ -const DISK_ZERO_IO_TEXT = /↺\s+0 B\/s\s+↻\s+0 B\/s/ +const DISK_READ_TEXT = '↺ 2.0 MB/s' +const DISK_WRITE_TEXT = '↻ 1.1 MB/s' +const DISK_ZERO_READ_TEXT = '↺ 0 B/s' +const DISK_ZERO_WRITE_TEXT = '↻ 0 B/s' +const DISK_READ_ARROW_TEXT = /↺/ +const DISK_WRITE_ARROW_TEXT = /↻/ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) @@ -85,7 +89,8 @@ describe('DiskCell', () => { expect(screen.getByText(DISK_USAGE_TEXT)).toBeDefined() expect(screen.getByText('24%')).toBeDefined() - expect(screen.getByText(DISK_IO_TEXT)).toBeDefined() + expect(screen.getByText(DISK_READ_TEXT)).toBeDefined() + expect(screen.getByText(DISK_WRITE_TEXT)).toBeDefined() }) it('shows used/total but hides io row when offline', () => { @@ -102,7 +107,8 @@ describe('DiskCell', () => { ) expect(screen.getByText(DISK_USAGE_TEXT)).toBeDefined() - expect(screen.queryByText(DISK_IO_TEXT)).toBeNull() + expect(screen.queryByText(DISK_READ_ARROW_TEXT)).toBeNull() + expect(screen.queryByText(DISK_WRITE_ARROW_TEXT)).toBeNull() }) it('shows zero io speeds when online and idle', () => { @@ -116,6 +122,7 @@ describe('DiskCell', () => { /> ) - expect(screen.getByText(DISK_ZERO_IO_TEXT)).toBeDefined() + expect(screen.getByText(DISK_ZERO_READ_TEXT)).toBeDefined() + expect(screen.getByText(DISK_ZERO_WRITE_TEXT)).toBeDefined() }) }) diff --git a/apps/web/src/routes/_authed/servers/index.cells.tsx b/apps/web/src/routes/_authed/servers/index.cells.tsx index d12eb12a..db260cee 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.tsx @@ -70,9 +70,9 @@ export function DiskCell({ server }: { server: ServerMetrics }) { {formatBytes(server.disk_used)} / {formatBytes(server.disk_total)} {server.online && ( - - ↺ {formatSpeed(server.disk_read_bytes_per_sec)} - {' '}↻ {formatSpeed(server.disk_write_bytes_per_sec)} + + ↺ {formatSpeed(server.disk_read_bytes_per_sec)} + ↻ {formatSpeed(server.disk_write_bytes_per_sec)} )}
From 3ebc29271ae28daf19e993691e783d4358522c38 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Thu, 16 Apr 2026 00:42:48 +0800 Subject: [PATCH 14/43] feat(web): add NetworkCell with offline zeroing and cumulative row --- .../_authed/servers/index.cells.test.tsx | 51 ++++++++++++++++++- .../routes/_authed/servers/index.cells.tsx | 18 +++++++ apps/web/src/routes/_authed/servers/index.tsx | 14 ++--- 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/apps/web/src/routes/_authed/servers/index.cells.test.tsx b/apps/web/src/routes/_authed/servers/index.cells.test.tsx index 6d595d43..e85889c9 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.test.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import type { ServerMetrics } from '@/hooks/use-servers-ws' -import { CpuCell, DiskCell, MemoryCell } from './index.cells' +import { CpuCell, DiskCell, MemoryCell, NetworkCell } from './index.cells' const CPU_LOAD_TEXT = /card_load\s+1\.23/ const DISK_USAGE_TEXT = '111.8 GB / 465.7 GB' @@ -11,6 +11,11 @@ const DISK_ZERO_READ_TEXT = '↺ 0 B/s' const DISK_ZERO_WRITE_TEXT = '↻ 0 B/s' const DISK_READ_ARROW_TEXT = /↺/ const DISK_WRITE_ARROW_TEXT = /↻/ +const NETWORK_IN_SPEED_TEXT = '↓1.1 MB/s' +const NETWORK_OUT_SPEED_TEXT = '↑332.0 KB/s' +const NETWORK_ZERO_IN_SPEED_TEXT = '↓0 B/s' +const NETWORK_ZERO_OUT_SPEED_TEXT = '↑0 B/s' +const NETWORK_CUMULATIVE_TEXT = /^Σ ↓5\.0 GB\s*↑2\.0 GB$/ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) @@ -51,6 +56,10 @@ function makeServer(overrides: Partial = {}): ServerMetrics { } } +function hasTextContent(node: Element | null, pattern: RegExp): boolean { + return pattern.test(node?.textContent ?? '') +} + describe('CpuCell', () => { it('shows cpu percentage and load1', () => { render() @@ -126,3 +135,43 @@ describe('DiskCell', () => { expect(screen.getByText(DISK_ZERO_WRITE_TEXT)).toBeDefined() }) }) + +describe('NetworkCell', () => { + it('shows live speeds and cumulative row when online', () => { + render( + + ) + + expect(screen.getByText(NETWORK_IN_SPEED_TEXT)).toBeDefined() + expect(screen.getByText(NETWORK_OUT_SPEED_TEXT)).toBeDefined() + expect(screen.getByText((_, node) => hasTextContent(node, NETWORK_CUMULATIVE_TEXT))).toBeDefined() + }) + + it('zeros live speeds but keeps cumulative row when offline', () => { + render( + + ) + + expect(screen.getByText(NETWORK_ZERO_IN_SPEED_TEXT)).toBeDefined() + expect(screen.getByText(NETWORK_ZERO_OUT_SPEED_TEXT)).toBeDefined() + expect(screen.queryByText(NETWORK_IN_SPEED_TEXT)).toBeNull() + expect(screen.queryByText(NETWORK_OUT_SPEED_TEXT)).toBeNull() + expect(screen.getByText((_, node) => hasTextContent(node, NETWORK_CUMULATIVE_TEXT))).toBeDefined() + }) +}) diff --git a/apps/web/src/routes/_authed/servers/index.cells.tsx b/apps/web/src/routes/_authed/servers/index.cells.tsx index db260cee..6b25a03e 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.tsx @@ -80,3 +80,21 @@ export function DiskCell({ server }: { server: ServerMetrics }) { /> ) } + +export function NetworkCell({ server }: { server: ServerMetrics }) { + const inSpeed = server.online ? server.net_in_speed : 0 + const outSpeed = server.online ? server.net_out_speed : 0 + + return ( +
+ + ↓{formatSpeed(inSpeed)} + ↑{formatSpeed(outSpeed)} + + + Σ ↓{formatBytes(server.net_in_transfer)} + ↑{formatBytes(server.net_out_transfer)} + +
+ ) +} diff --git a/apps/web/src/routes/_authed/servers/index.tsx b/apps/web/src/routes/_authed/servers/index.tsx index 1807dd9d..f746e8d6 100644 --- a/apps/web/src/routes/_authed/servers/index.tsx +++ b/apps/web/src/routes/_authed/servers/index.tsx @@ -34,9 +34,9 @@ import type { ServerMetrics } from '@/hooks/use-servers-ws' import { api } from '@/lib/api-client' import type { ServerGroup } from '@/lib/api-schema' import { countCleanupCandidates } from '@/lib/orphan-server-utils' -import { countryCodeToFlag, formatSpeed, formatUptime } from '@/lib/utils' +import { countryCodeToFlag, formatUptime } from '@/lib/utils' import { useUpgradeJobsStore } from '@/stores/upgrade-jobs-store' -import { CpuCell, DiskCell, MemoryCell } from './index.cells' +import { CpuCell, DiskCell, MemoryCell, NetworkCell } from './index.cells' function UpgradeBadgeCell({ serverId }: { serverId: string }) { const job = useUpgradeJobsStore((state) => state.jobs.get(serverId)) @@ -220,15 +220,7 @@ function ServersListPage() { id: 'network', enableSorting: false, header: () => {t('col_network')}, - cell: ({ row }) => { - const s = row.original - return ( - - ↓{formatSpeed(s.net_in_speed)} - ↑{formatSpeed(s.net_out_speed)} - - ) - }, + cell: ({ row }) => , size: 160, meta: { className: 'hidden lg:table-cell lg:w-[160px]' } }, From 4e2504b599588e2a81619bb78c4c1e7e7fcff8ab Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 01:00:34 +0800 Subject: [PATCH 15/43] docs(superpowers): spec for servers table row visual redesign --- ...ervers-table-row-visual-redesign-design.md | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-17-servers-table-row-visual-redesign-design.md diff --git a/docs/superpowers/specs/2026-04-17-servers-table-row-visual-redesign-design.md b/docs/superpowers/specs/2026-04-17-servers-table-row-visual-redesign-design.md new file mode 100644 index 00000000..fbc7f9de --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-servers-table-row-visual-redesign-design.md @@ -0,0 +1,220 @@ +# Servers Table Row Visual Redesign + +**Date:** 2026-04-17 +**Scope:** `apps/web/src/routes/_authed/servers` (table view rows), plus a small backend additive surface for `server_tags` and `cpu_cores`. + +## Problem + +The `/servers?view=table` rows are text-heavy. Each metric cell is a thin (1.5px) progress bar plus a single sub-line of text; the `status` column renders a text badge; the `name` column shows only the server name with an optional flag; there is no surface for user-defined labels. Disk I/O got added in an earlier pass (`2026-04-15-servers-table-density-design.md`) as an extra text line on the Disk cell, but the overall "every cell is a thin bar + one-line sub-text" rhythm remains flat and busy. + +Two user needs drive this redesign: + +1. **Iconography over text.** The page should lean on lucide icons and progress bars to convey metrics at a glance rather than relying on label text. +2. **Traffic quota visibility.** The `Network` column currently shows only per-second speeds and cumulative transfers. Users want to see current-cycle traffic against the configured monthly cap, the same signal that the grid-view `ServerCard` already renders as a ring (`93.2 GB / 1.0 TB`). It must also exist in the table. +3. **User-defined tags.** The `server_tags` table exists in the database (`crates/server/src/entity/server_tag.rs`) but is never read by the API, never pushed by the WebSocket, and never editable in the UI. Users want short tags (e.g., `prod`, `db-primary`, `asia`) displayed under the server name and editable from the server edit dialog. + +## Goals + +1. Every metric cell (`CPU`, `Memory`, `Disk`, `Network`) becomes a consistent two-line cell: **line 1 = lucide icon + progress bar + percentage**, **line 2 = monospace sub-line with 1–3 datapoints**. +2. The `status` column text badge collapses into a small pulsing dot in a new first column (36px wide). Total column count is unchanged: `status-dot · name · cpu · memory · disk · network · group · uptime · actions` (we drop the existing dedicated `status` column, because its signal is now in the dot). +3. `Name` cell becomes two lines: flag + name on top, colored tag chips below. Rows with no tags render single-line naturally. +4. `Network` cell's top line becomes a **traffic-quota** progress bar (cycle bytes / `traffic_limit`), matching the grid card's `useTrafficOverview` data, falling back to a 1 TiB default when no quota is configured. The bottom line carries `{used} / {limit} · ↓ {in_speed} ↑ {out_speed}`. +5. `Uptime` cell gets a sub-line: online rows show the OS with its emoji (`🐧 Ubuntu 22.04`), offline rows show `last seen 2h ago` (relative time from `last_active`). +6. `server_tags` becomes an end-to-end feature: REST read/write, WS payload inclusion, and an editor in `ServerEditDialog`. +7. Color thresholds are unchanged (`getBarColor` in `index.cells.tsx`: <70% green, 70–90% amber, >90% red). + +## Non-goals + +- Grid view (`ServerCard`) — untouched. +- Sparkline / mini-charts (explicitly rejected during brainstorming; no client-side history buffer is introduced). +- Ring-chart variant (considered; rejected in favor of the icon+bar direction). +- Realtime tag-change broadcasts to all browsers. Tag edits will manually invalidate the local `['servers']` query; live propagation across browsers is a follow-up (Phase C below). +- Changing sorting, filtering, pagination, or other `DataTable` behavior. +- Changing column sizes or the shadcn `` primitive. + +## Design + +### Visual rhythm (authoritative per-cell spec) + +| Column | Width | Top line | Bottom line (sub) | +|---|---|---|---| +| `status-dot` (new) | 36px (`w-9`) | 8px pulsing dot: green `bg-emerald-500` with `box-shadow` halo + CSS `pulse` when online; muted grey `bg-muted-foreground/60` when offline | — | +| `name` | 260px | flag (if country_code) + server name (truncates, Link) + UpgradeBadge | `` — colored chips, wraps; absent when `tags` empty | +| `cpu` | 160px | `` 14px + bar + `%` (monospace, right-aligned, colored by threshold) | `{cores} cores · load {load1.toFixed(2)}` | +| `memory` | 160px | `` + bar + `%` | `{formatBytes(used)} / {formatBytes(total)} · swap {swapPct}%` | +| `disk` | 160px | `` + bar + `%` | `{formatSpeed(read)} {formatSpeed(write)}` | +| `network` | 160px (stays `hidden lg:table-cell`) | `` + **traffic quota** bar + `%` | `{formatBytes(used)} / {formatBytes(limit)} · {formatSpeed(in)} {formatSpeed(out)}` | +| `group` | 140px (stays `hidden xl:table-cell`) | Group name (as today) | — | +| `uptime` | 100px (stays `hidden xl:table-cell`) | Online: `` + `formatUptime(uptime)` · Offline: `offline` | Online: `{osEmoji} {os}` · Offline: `last seen {relative(last_active)}` | +| `actions` | 40px | edit button (unchanged) | — | + +Offline rows render `—` for metric cells' top lines (as today) and render a subdued sub-line where applicable (e.g. `last seen 2h ago` on Uptime). Tag chips on `name` still show, independent of online status. + +The `status` data column defined in today's `index.tsx` is removed (the signal moves to the pulsing dot in the new first column). Its filter (`status: online / offline`) migrates to the new `status-dot` column's `meta.options` so `DataTableToolbar` continues to offer that filter pill. + +### Color & threshold rules + +- `getBarColor(pct)` is reused verbatim for CPU / Memory / Disk usage and for Network traffic quota. +- Percentage text in the bar row adopts the same color (via `getBarColor` mapped to text class) so an 87% CPU shows red both on the bar and the number. +- Swap percentage in the Memory sub-line uses the same thresholds against `swap_total`. + +### Component structure + +``` +apps/web/src/routes/_authed/servers/ + index.tsx # column defs updated (status-dot first, status column dropped) + index.cells.tsx # REWRITTEN + online|offline + flag + name + tags + server_tags as colored chips + reusable: icon + bar + % + + sub + + sub + + sub + (traffic%) + sub + online vs offline branch +``` + +`MetricBarRow` is the new primitive; it takes `{ icon: ReactNode; pct: number; label?: string; valueClassName?: string }`. It does NOT render any sub-line — each metric cell composes `` with its own `
...`. + +The existing `MiniBar` component is retained for other callers (none today in `src/`, but it's exported) — we will leave it as a thin wrapper that calls `MetricBarRow` with an empty icon slot, for back-compat. + +`TagChipRow` uses a deterministic palette: `tag.split('').reduce((h,c)=>h*31+c.charCodeAt(0),0) % N` to pick one of 6 muted colors (emerald, sky, amber, rose, violet, slate). Individual chips are truncated with `max-w-[80px]` plus `title={tag}`; the row allows wrap (`flex flex-wrap`). + +### Data dependencies + +**Already available on `ServerMetrics` WebSocket payload** — no backend change: + +- `cpu`, `load1` +- `mem_used`, `mem_total`, `swap_used`, `swap_total` +- `disk_used`, `disk_total`, `disk_read_bytes_per_sec`, `disk_write_bytes_per_sec` +- `net_in_speed`, `net_out_speed`, `net_in_transfer`, `net_out_transfer` +- `uptime`, `last_active`, `os`, `country_code` + +**Already available via a separate query** — reused, no backend change: + +- `useTrafficOverview()` → `/api/traffic/overview` → `{ cycle_in, cycle_out, traffic_limit, days_remaining }` per server. The table view will call this query at the page level (once), then lookup per row. Fallback to `DEFAULT_TRAFFIC_LIMIT_BYTES = 1 TiB` when no quota is configured, identical to `ServerCard`. + +**New on `ServerStatus` (backend work required)**: + +- `tags: Vec` — `#[serde(default)]` empty vec; added to `crates/common/src/types.rs::ServerStatus` and populated in `crates/server/src/router/ws/browser.rs::build_full_sync` (single query: `server_tag::Entity::find().all(&db)` grouped by `server_id` in memory). +- `cpu_cores: Option` — `#[serde(default)]`; populated from `servers.cpu_cores` column (already exists in the DB). + +Both fields are static across most updates; they will be included on `full_sync` and on any `update` message where the underlying data changed. Because `STATIC_FIELDS` in `apps/web/src/hooks/use-servers-ws.ts` guards against overwriting static fields with `null`/`0`, we will add `cpu_cores` to that set. `tags` is an array and is not subject to the static-fields guard (an explicit `[]` from the server should overwrite local state). + +### Backend: tags API + +Two new endpoints in `crates/server/src/router/api/server_tags.rs` (new file), mounted under `/api`: + +**`GET /api/servers/:id/tags`** → `ApiResponse>` +- Auth: any authenticated user (reuses the default auth middleware — members can read, same as reading servers today). + +**`PUT /api/servers/:id/tags`** body `{ tags: Vec }` → `ApiResponse>` +- Auth: `require_admin`. +- Replaces the tag set atomically inside a transaction: delete all rows for `server_id`, insert the new ones. +- Validation: `tags.len() <= 8`, each `tag.len() <= 16`, each tag matches `[A-Za-z0-9_.-]+` and is non-empty after trim. Duplicates are de-duplicated server-side (case-sensitive). Returns 400 with a `validation_error` on violation. +- Returns the canonical (sorted, deduped) tag list. + +Both endpoints are annotated with `#[utoipa::path]` and include a `ToSchema`-derived DTO for the request body. + +After a successful `PUT`, the server broadcasts no new WS event in Phase B; the frontend manually invalidates `['servers']` on success, which causes a refetch (there is no REST for the servers list — the list is WS-only). **However**, since no REST refetch exists, the table will only pick up the new tag when the next `update` or `full_sync` rolls in. To avoid the "I just edited the tags but my row hasn't updated" lag, the `PUT` response's `data: string[]` is used to optimistically patch `queryClient.setQueryData(['servers'], prev => prev.map(s => s.id === id ? { ...s, tags: data } : s))`. + +### Frontend: tag editor in `ServerEditDialog` + +A new block in the existing `ServerEditDialog` form: + +- Label: `t('servers:tags_label')` ("Tags" / "标签") +- Input: shadcn `` with helper text `t('servers:tags_hint')` ("Comma or space separated, up to 8 tags, 16 chars each"). On blur, the string is split on `/[\s,]+/`, trimmed, deduped, and normalized against the same validation rules as the backend. +- Submit: a separate PUT to `/api/servers/:id/tags` fires on save (not bundled into the existing server-update PATCH). Success toast; optimistic cache update as above. +- Fetched on open via `useQuery(['server-tags', id])` → `GET /api/servers/:id/tags`. The initial form value is populated from this query. + +### Phasing + +**Phase A** (frontend-only, ships first): +- Rewrite `index.cells.tsx` and adjust `columns` in `index.tsx` (status-dot column, no Status text column, Network traffic quota bar, Uptime sub-line). +- Use `useTrafficOverview()` at the page level; pass per-row lookup to `NetworkCell`. +- Render tag chips when `server.tags?.length > 0`, otherwise single-line Name. Since backend does not yet push `tags`, the chip row is dormant. +- Add optional `cpu_cores?: number | null` and `tags?: string[]` to the `ServerMetrics` TS interface now, so Phase B plugs in without a second wave of type churn. + +**Phase B** (backend + editor): +- Add `tags: Vec` and `cpu_cores: Option` to `ServerStatus` and `build_full_sync` in `crates/server/src/router/ws/browser.rs`. +- Add `server_tags` REST endpoints (`GET` / `PUT`). +- Add tag editor in `ServerEditDialog` with optimistic cache update. +- Add `cpu_cores` to the frontend `STATIC_FIELDS` guard. +- Swagger/OpenAPI auto-updates via utoipa annotations. + +**Phase C** (optional, follow-up spec if desired): broadcast a `tags_changed` WS event so all connected browsers see tag edits live. Not in scope for this spec. + +Phase A and Phase B may be shipped together in a single PR if convenient; they are separated here only to clarify which change depends on which. + +### i18n keys (new) + +Added to `apps/web/public/locales/{en,zh}/servers.json`: + +- `tags_label` — "Tags" / "标签" +- `tags_hint` — editor helper text +- `tags_placeholder` — input placeholder `prod, db, web` +- `tags_validation_too_many` — "At most 8 tags" / "最多 8 个标签" +- `tags_validation_too_long` — "Each tag must be ≤16 chars" / "单个标签最多 16 字符" +- `tags_validation_invalid_char` — "Only letters, digits, and `._-` allowed" +- `last_seen_ago` — "last seen {{time}}" / "最后上线 {{time}}" + +Existing keys reused: `card_load`, `col_cpu`, `col_memory`, `col_disk`, `col_network`, `col_uptime`, `status_online`, `status_offline`. + +## Testing + +### Rust (Phase B) + +`crates/server/tests/` integration coverage: + +- `server_tags_crud` — PUT then GET returns same list; dedup + trim; 400 on too many / too long / invalid chars; RBAC (member GET 200, member PUT 403). +- `full_sync_includes_tags` — seed two servers each with two tags, open the browser WS, assert the first `full_sync` frame contains `tags: ["a","b"]` for each. + +No new unit test for `build_full_sync` shape beyond the integration test; existing WS tests cover the rest. + +### Frontend (vitest) + +`apps/web/src/routes/_authed/servers/__tests__/`: + +- `cells.test.tsx` + - `MetricBarRow`: color threshold at 69/70/89/90/91; custom icon slot renders; `%` rounds to 0 decimals. + - `CpuCell`: renders `{cores} cores · load {1.23}` with `cpu_cores=8, load1=1.234`; hides sub when offline. + - `MemoryCell`: renders `7.2 GB / 16 GB · swap 3%`; swap color follows threshold. + - `DiskCell`: renders read/write arrow row; hides I/O sub when offline (same as today's rule). + - `NetworkCell`: uses `trafficEntry.traffic_limit` when present; falls back to 1 TiB default when null; clamps pct to 100. + - `UptimeCell`: online shows OS emoji + name; offline shows `last seen 2h ago` derived from `last_active` 2h in the past. + - `NameCell`: 0 tags → single line, no tag row rendered; 3 tags → chips wrap; long tag truncates with `title` attr. + - `TagChipRow`: same tag → same palette color (hash stability). +- `index.test.tsx` already exists for `/servers`; extend the "renders online/offline rows" block to assert the pulsing dot and no text badge column. + +### Manual QA checklist + +New file `tests/servers/table-row-visual-redesign.md`: + +1. Open `/servers?view=table` with mixed online/offline rows; verify pulsing dot vs grey dot. +2. Add tags via `ServerEditDialog`, save, verify chips appear immediately (optimistic), persist after reload. +3. Verify tag validation: 9 tags / 17-char tag / tag with spaces → form-level error. +4. Configure a server with `traffic_limit = 1GB`, push it past 50%/80%/95% usage in fixture data; verify Network bar color transitions and `%` color. +5. Configure a server without `traffic_limit`; verify Network bar renders against 1 TiB fallback (stays small). +6. Take a server offline; verify row dims, metric cells show `—`, Uptime sub shows `last seen … ago`, tags still visible. +7. Resize viewport: network column hides below `lg:` (1024px); group + uptime hide below `xl:` (1280px). No horizontal scroll bleed at any breakpoint. +8. Verify ultracite + typecheck + `cargo clippy` all pass. + +## Rollout + +1. Phase A PR — frontend rewrite. No migration, no backend changes. User-visible change: table looks different; tags row is empty. +2. Phase B PR — `cpu_cores` + `tags` on WS, REST endpoints, editor in `ServerEditDialog`. `cpu_cores` retro-populates from existing DB column; `tags` defaults to empty for all servers. +3. Documentation update: add a short section to `apps/docs/content/docs/{en,cn}/*` about tags if user requests it (not bundled by default — CLAUDE.md only mandates docs updates for env var changes). + +No schema migration required: `server_tags` and `cpu_cores` already exist in the database. + +## Open questions + +None at spec-approval time. All resolved during brainstorming: + +- Visual direction: C (icon + bar), not rings or sparklines. +- Disk I/O placement: C1 (inline under Disk bar), not a separate column. +- Sub-line data for CPU: `{cores} cores · load {load1}`. +- Sub-line data for Memory: `{used}/{total} · swap {pct}%`. +- Name sub-line: `server_tags` (not `public_remark`, not group name). +- Uptime sub-line: OS line for online, `last seen` for offline. From 5980f5e6887836f3b94642d9d4bc44ab983c93e2 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 01:06:37 +0800 Subject: [PATCH 16/43] docs(superpowers): address spec review feedback for servers table row redesign --- ...ervers-table-row-visual-redesign-design.md | 51 ++++++++++++++----- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/specs/2026-04-17-servers-table-row-visual-redesign-design.md b/docs/superpowers/specs/2026-04-17-servers-table-row-visual-redesign-design.md index fbc7f9de..52b6a2ec 100644 --- a/docs/superpowers/specs/2026-04-17-servers-table-row-visual-redesign-design.md +++ b/docs/superpowers/specs/2026-04-17-servers-table-row-visual-redesign-design.md @@ -16,7 +16,7 @@ Two user needs drive this redesign: ## Goals 1. Every metric cell (`CPU`, `Memory`, `Disk`, `Network`) becomes a consistent two-line cell: **line 1 = lucide icon + progress bar + percentage**, **line 2 = monospace sub-line with 1–3 datapoints**. -2. The `status` column text badge collapses into a small pulsing dot in a new first column (36px wide). Total column count is unchanged: `status-dot · name · cpu · memory · disk · network · group · uptime · actions` (we drop the existing dedicated `status` column, because its signal is now in the dot). +2. The `status` column text badge collapses into a small pulsing dot in a new `status-dot` column (36px wide) that slots in immediately after the existing `select` checkbox column. Total column count is unchanged: `select · status-dot · name · cpu · memory · disk · network · group · uptime · actions` (we drop the existing dedicated `status` text-badge column, because its signal is now in the dot). 3. `Name` cell becomes two lines: flag + name on top, colored tag chips below. Rows with no tags render single-line naturally. 4. `Network` cell's top line becomes a **traffic-quota** progress bar (cycle bytes / `traffic_limit`), matching the grid card's `useTrafficOverview` data, falling back to a 1 TiB default when no quota is configured. The bottom line carries `{used} / {limit} · ↓ {in_speed} ↑ {out_speed}`. 5. `Uptime` cell gets a sub-line: online rows show the OS with its emoji (`🐧 Ubuntu 22.04`), offline rows show `last seen 2h ago` (relative time from `last_active`). @@ -40,7 +40,7 @@ Two user needs drive this redesign: |---|---|---|---| | `status-dot` (new) | 36px (`w-9`) | 8px pulsing dot: green `bg-emerald-500` with `box-shadow` halo + CSS `pulse` when online; muted grey `bg-muted-foreground/60` when offline | — | | `name` | 260px | flag (if country_code) + server name (truncates, Link) + UpgradeBadge | `` — colored chips, wraps; absent when `tags` empty | -| `cpu` | 160px | `` 14px + bar + `%` (monospace, right-aligned, colored by threshold) | `{cores} cores · load {load1.toFixed(2)}` | +| `cpu` | 160px | `` 14px + bar + `%` (monospace, right-aligned, colored by threshold) | `{cores} cores · load {load1.toFixed(2)}` when `cpu_cores` present; `load {load1.toFixed(2)}` alone when `cpu_cores` is `null`/`undefined` (the Phase A state before the backend surfaces it) | | `memory` | 160px | `` + bar + `%` | `{formatBytes(used)} / {formatBytes(total)} · swap {swapPct}%` | | `disk` | 160px | `` + bar + `%` | `{formatSpeed(read)} {formatSpeed(write)}` | | `network` | 160px (stays `hidden lg:table-cell`) | `` + **traffic quota** bar + `%` | `{formatBytes(used)} / {formatBytes(limit)} · {formatSpeed(in)} {formatSpeed(out)}` | @@ -50,7 +50,7 @@ Two user needs drive this redesign: Offline rows render `—` for metric cells' top lines (as today) and render a subdued sub-line where applicable (e.g. `last seen 2h ago` on Uptime). Tag chips on `name` still show, independent of online status. -The `status` data column defined in today's `index.tsx` is removed (the signal moves to the pulsing dot in the new first column). Its filter (`status: online / offline`) migrates to the new `status-dot` column's `meta.options` so `DataTableToolbar` continues to offer that filter pill. +The `status` data column defined in today's `index.tsx` is removed (the signal moves to the pulsing dot in the new `status-dot` column). Its filter (`status: online / offline`) migrates to the `status-dot` column, which must therefore carry both an `accessorFn: (row) => (row.online ? 'online' : 'offline')` (to drive `arrayIncludesFilter`) and the same `meta: { variant: 'select', options: statusOptions, icon: CircleDot, label: t('col_status') }` block the current `status` column has, so `DataTableToolbar` continues to offer the filter pill. The cell body is purely the pulsing dot — there is no header text (`header: () => null`) and `enableSorting: false` to match the intent of a glyph-only column. ### Color & threshold rules @@ -75,9 +75,9 @@ apps/web/src/routes/_authed/servers/ online vs offline branch ``` -`MetricBarRow` is the new primitive; it takes `{ icon: ReactNode; pct: number; label?: string; valueClassName?: string }`. It does NOT render any sub-line — each metric cell composes `` with its own `
...`. +`MetricBarRow` is the new primitive; its props are `{ icon: ReactNode; pct: number; valueClassName?: string; ariaLabel?: string }`. It renders only the icon + bar + percentage row and does NOT own the sub-line — each metric cell composes `` and then renders its own sub-line underneath in the same flex column. -The existing `MiniBar` component is retained for other callers (none today in `src/`, but it's exported) — we will leave it as a thin wrapper that calls `MetricBarRow` with an empty icon slot, for back-compat. +The existing `MiniBar` component keeps its current signature (`{ pct: number; sub?: ReactNode }`) for back-compat. Internally it is refactored to render `` followed by its `sub` block when provided. No external caller changes. `TagChipRow` uses a deterministic palette: `tag.split('').reduce((h,c)=>h*31+c.charCodeAt(0),0) % N` to pick one of 6 muted colors (emerald, sky, amber, rose, violet, slate). Individual chips are truncated with `max-w-[80px]` plus `title={tag}`; the row allows wrap (`flex flex-wrap`). @@ -93,14 +93,20 @@ The existing `MiniBar` component is retained for other callers (none today in `s **Already available via a separate query** — reused, no backend change: -- `useTrafficOverview()` → `/api/traffic/overview` → `{ cycle_in, cycle_out, traffic_limit, days_remaining }` per server. The table view will call this query at the page level (once), then lookup per row. Fallback to `DEFAULT_TRAFFIC_LIMIT_BYTES = 1 TiB` when no quota is configured, identical to `ServerCard`. +- `useTrafficOverview()` → `/api/traffic/overview` → `{ cycle_in, cycle_out, traffic_limit, days_remaining }` per server. The table view will call this query at the page level (once), then lookup per row. Fallback to `DEFAULT_TRAFFIC_LIMIT_BYTES = 1 TiB` when no quota is configured **or when `traffic_limit <= 0`**, identical to `ServerCard`. This constant is currently private to `components/server/server-card.tsx`; it is hoisted to `apps/web/src/lib/traffic.ts` (new file) exporting `DEFAULT_TRAFFIC_LIMIT_BYTES` and a `computeTrafficQuota({ entry, netInTransfer, netOutTransfer }) => { used: number; limit: number; pct: number }` helper, so both `ServerCard` and `NetworkCell` share one code path. **New on `ServerStatus` (backend work required)**: -- `tags: Vec` — `#[serde(default)]` empty vec; added to `crates/common/src/types.rs::ServerStatus` and populated in `crates/server/src/router/ws/browser.rs::build_full_sync` (single query: `server_tag::Entity::find().all(&db)` grouped by `server_id` in memory). -- `cpu_cores: Option` — `#[serde(default)]`; populated from `servers.cpu_cores` column (already exists in the DB). +- `tags: Vec` with `#[serde(default)]` — added to `crates/common/src/types.rs::ServerStatus`. +- `cpu_cores: Option` with `#[serde(default)]` — populated from `servers.cpu_cores` column (already exists in the DB). -Both fields are static across most updates; they will be included on `full_sync` and on any `update` message where the underlying data changed. Because `STATIC_FIELDS` in `apps/web/src/hooks/use-servers-ws.ts` guards against overwriting static fields with `null`/`0`, we will add `cpu_cores` to that set. `tags` is an array and is not subject to the static-fields guard (an explicit `[]` from the server should overwrite local state). +**Update-broadcast semantics (critical to prevent clobber).** `crates/server/src/service/agent_manager.rs::update_report` constructs a fresh `ServerStatus` for every metric update; it has no database access and therefore cannot populate `tags` or `cpu_cores`. We follow the existing `features` pattern: + +- **`tags`**: populated in `build_full_sync` only. In `update_report` it is left at `Vec::new()` (the default). The frontend must treat an empty `tags` on an incremental `update` message as "no change" rather than "cleared". This is implemented by adding both `'tags'` and `'cpu_cores'` to `STATIC_FIELDS` in `apps/web/src/hooks/use-servers-ws.ts`, and extending the guard to also treat `[]` (empty array) as a default value that must not overwrite prior state. The guard check becomes: `isStaticDefault = STATIC_FIELDS.has(key) && (value === null || value === 0 || (Array.isArray(value) && value.length === 0))`. +- **`cpu_cores`**: populated in `build_full_sync` from `servers.cpu_cores`. In `update_report` it is `None`, which serializes to `null` — already covered by the existing `value === null` branch of the static-fields guard. +- **Authoritative source for tag mutations on the current tab** is the optimistic cache update after a successful `PUT /api/servers/:id/tags`, not the WS. Clearing all tags (going from `["prod"]` to `[]`) works because the `PUT` response + optimistic setter writes `[]` into `queryClient.setQueryData(['servers'], …)` directly; the subsequent incremental WS `update` carrying `tags: []` is ignored by the guard, which is fine because the cache is already correct. Cross-tab propagation (Tab A edits tags, Tab B should see it) is explicitly a Phase C concern. + +Future cross-tab propagation in Phase C will use a dedicated `tags_changed` WS event (analogous to today's `capabilities_changed`) that bypasses the static-fields guard, so the guard strategy above is forward-compatible. ### Backend: tags API @@ -114,10 +120,16 @@ Two new endpoints in `crates/server/src/router/api/server_tags.rs` (new file), m - Replaces the tag set atomically inside a transaction: delete all rows for `server_id`, insert the new ones. - Validation: `tags.len() <= 8`, each `tag.len() <= 16`, each tag matches `[A-Za-z0-9_.-]+` and is non-empty after trim. Duplicates are de-duplicated server-side (case-sensitive). Returns 400 with a `validation_error` on violation. - Returns the canonical (sorted, deduped) tag list. +- Does **not** touch `servers.updated_at`. The `build_full_sync` path uses `server.updated_at.timestamp()` as `last_active` for offline rows, and we do not want editing tags to make an offline server appear to have just phoned home. Only the transaction against `server_tags` runs. Both endpoints are annotated with `#[utoipa::path]` and include a `ToSchema`-derived DTO for the request body. -After a successful `PUT`, the server broadcasts no new WS event in Phase B; the frontend manually invalidates `['servers']` on success, which causes a refetch (there is no REST for the servers list — the list is WS-only). **However**, since no REST refetch exists, the table will only pick up the new tag when the next `update` or `full_sync` rolls in. To avoid the "I just edited the tags but my row hasn't updated" lag, the `PUT` response's `data: string[]` is used to optimistically patch `queryClient.setQueryData(['servers'], prev => prev.map(s => s.id === id ? { ...s, tags: data } : s))`. +After a successful `PUT`, the server broadcasts no new WS event in Phase B. The frontend performs an optimistic cache update in two places using the response body (`data: string[]`): + +1. `queryClient.setQueryData(['servers'], prev => prev?.map(s => s.id === id ? { ...s, tags: data } : s))` — updates the table view instantly. +2. `queryClient.setQueryData(['server-tags', id], data)` — keeps the editor's own query fresh so reopening the dialog without a refetch shows the saved state. + +This is the authoritative path for tag changes in the current tab. Because the WS incremental-update static-fields guard ignores empty `tags` payloads, clearing all tags via this `PUT` continues to work: the optimistic setter writes `[]` into the cache directly, and the subsequent WS `update` with `tags: []` harmlessly no-ops. ### Frontend: tag editor in `ServerEditDialog` @@ -178,14 +190,16 @@ No new unit test for `build_full_sync` shape beyond the integration test; existi - `cells.test.tsx` - `MetricBarRow`: color threshold at 69/70/89/90/91; custom icon slot renders; `%` rounds to 0 decimals. - - `CpuCell`: renders `{cores} cores · load {1.23}` with `cpu_cores=8, load1=1.234`; hides sub when offline. + - `CpuCell`: with `cpu_cores=8, load1=1.234` renders `8 cores · load 1.23`; with `cpu_cores=null` renders `load 1.23` only (Phase A fallback); hides sub when offline. - `MemoryCell`: renders `7.2 GB / 16 GB · swap 3%`; swap color follows threshold. - `DiskCell`: renders read/write arrow row; hides I/O sub when offline (same as today's rule). - - `NetworkCell`: uses `trafficEntry.traffic_limit` when present; falls back to 1 TiB default when null; clamps pct to 100. + - `NetworkCell`: uses `trafficEntry.traffic_limit` when positive; falls back to 1 TiB default when `null`, `undefined`, or `<= 0` (guards against a `NaN%` render path); clamps `pct` to 100. - `UptimeCell`: online shows OS emoji + name; offline shows `last seen 2h ago` derived from `last_active` 2h in the past. - `NameCell`: 0 tags → single line, no tag row rendered; 3 tags → chips wrap; long tag truncates with `title` attr. - `TagChipRow`: same tag → same palette color (hash stability). -- `index.test.tsx` already exists for `/servers`; extend the "renders online/offline rows" block to assert the pulsing dot and no text badge column. + - `StatusDot`: renders pulsing class when `online`; plain muted class when `!online`. + - Merge guard: `mergeServerUpdate` preserves prior `tags` when the incoming frame carries `tags: []` (regression test for the clobber issue flagged in spec review). +- `index.test.tsx` (existing `/servers` tests): extend the "renders online/offline rows" block to (a) assert the pulsing dot appears where the text badge used to be, (b) assert `DataTableToolbar` still exposes the `status` filter pill sourced from the new `status-dot` column's `meta.options`. ### Manual QA checklist @@ -218,3 +232,14 @@ None at spec-approval time. All resolved during brainstorming: - Sub-line data for Memory: `{used}/{total} · swap {pct}%`. - Name sub-line: `server_tags` (not `public_remark`, not group name). - Uptime sub-line: OS line for online, `last seen` for offline. + +## Resolved during spec review + +- `tags` on incremental `update` broadcasts must not clobber the cache: see the "Update-broadcast semantics" paragraph above. Summary: left empty in `update_report`, guarded in the frontend `STATIC_FIELDS` merge by extending the default-value check to also cover empty arrays. +- `cpu_cores` also defaults in `update_report`; covered by the existing `value === null` branch of the static-fields guard once `cpu_cores` is added to `STATIC_FIELDS`. +- Phase A sub-line for CPU must handle missing `cpu_cores` gracefully: falls back to `load {load1}` alone. +- `status-dot` column keeps the filter pill by carrying both `accessorFn` and the existing `meta.options` block, even though its cell renders only a dot. +- `MiniBar` retains its public signature; it is refactored internally rather than reduced to a wrapper. +- `PUT /api/servers/:id/tags` does not touch `servers.updated_at`, to keep `last_active` honest for offline rows. +- `NetworkCell` must treat `traffic_limit <= 0` identically to `null`/`undefined` (both fall back to 1 TiB default). +- `DEFAULT_TRAFFIC_LIMIT_BYTES` is hoisted to `apps/web/src/lib/traffic.ts` so `ServerCard` and `NetworkCell` share it. From 72f94a9b2bbf82813752f50fb9fe84225dd5a395 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 01:12:18 +0800 Subject: [PATCH 17/43] docs(superpowers): second-round spec review fixes for servers table row redesign --- ...ervers-table-row-visual-redesign-design.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-04-17-servers-table-row-visual-redesign-design.md b/docs/superpowers/specs/2026-04-17-servers-table-row-visual-redesign-design.md index 52b6a2ec..53c806e8 100644 --- a/docs/superpowers/specs/2026-04-17-servers-table-row-visual-redesign-design.md +++ b/docs/superpowers/specs/2026-04-17-servers-table-row-visual-redesign-design.md @@ -48,7 +48,7 @@ Two user needs drive this redesign: | `uptime` | 100px (stays `hidden xl:table-cell`) | Online: `` + `formatUptime(uptime)` · Offline: `offline` | Online: `{osEmoji} {os}` · Offline: `last seen {relative(last_active)}` | | `actions` | 40px | edit button (unchanged) | — | -Offline rows render `—` for metric cells' top lines (as today) and render a subdued sub-line where applicable (e.g. `last seen 2h ago` on Uptime). Tag chips on `name` still show, independent of online status. +Offline rows render `—` for the **agent-live** metric cells (CPU / Memory / Disk) top lines, as today. The **Network cell is special**: because its top-line bar is sourced from server-level traffic quota data (`useTrafficOverview`, not the agent's live report), the traffic quota bar+% continues to render for offline rows — matching the grid `ServerCard` which always renders the quota ring regardless of online status. Only the sub-line's live `↓in ↑out` speeds collapse when offline (shown as `—` or simply hidden); `{used} / {limit}` on the sub-line remains visible. Tag chips on `name` still show, independent of online status. Uptime's sub-line gets the `last seen 2h ago` treatment on offline rows. The `status` data column defined in today's `index.tsx` is removed (the signal moves to the pulsing dot in the new `status-dot` column). Its filter (`status: online / offline`) migrates to the `status-dot` column, which must therefore carry both an `accessorFn: (row) => (row.online ? 'online' : 'offline')` (to drive `arrayIncludesFilter`) and the same `meta: { variant: 'select', options: statusOptions, icon: CircleDot, label: t('col_status') }` block the current `status` column has, so `DataTableToolbar` continues to offer the filter pill. The cell body is purely the pulsing dot — there is no header text (`header: () => null`) and `enableSorting: false` to match the intent of a glyph-only column. @@ -100,9 +100,11 @@ The existing `MiniBar` component keeps its current signature (`{ pct: number; su - `tags: Vec` with `#[serde(default)]` — added to `crates/common/src/types.rs::ServerStatus`. - `cpu_cores: Option` with `#[serde(default)]` — populated from `servers.cpu_cores` column (already exists in the DB). -**Update-broadcast semantics (critical to prevent clobber).** `crates/server/src/service/agent_manager.rs::update_report` constructs a fresh `ServerStatus` for every metric update; it has no database access and therefore cannot populate `tags` or `cpu_cores`. We follow the existing `features` pattern: +**`build_full_sync` fetch strategy** (to avoid N+1): a single query `server_tag::Entity::find().order_by_asc(server_tag::Column::ServerId).order_by_asc(server_tag::Column::Tag).all(&db).await?` is issued once per full-sync build; the result is grouped into a `HashMap>` keyed by `server_id` and each server's `tags` field is filled via `map.remove(&server.id).unwrap_or_default()`. `cpu_cores` is read from `server.cpu_cores` inline (no extra query; it's already on the `servers` row). A unit test on `build_full_sync` is not required; the integration test `full_sync_includes_tags` covers the wire shape. -- **`tags`**: populated in `build_full_sync` only. In `update_report` it is left at `Vec::new()` (the default). The frontend must treat an empty `tags` on an incremental `update` message as "no change" rather than "cleared". This is implemented by adding both `'tags'` and `'cpu_cores'` to `STATIC_FIELDS` in `apps/web/src/hooks/use-servers-ws.ts`, and extending the guard to also treat `[]` (empty array) as a default value that must not overwrite prior state. The guard check becomes: `isStaticDefault = STATIC_FIELDS.has(key) && (value === null || value === 0 || (Array.isArray(value) && value.length === 0))`. +**Update-broadcast semantics (critical to prevent clobber).** `crates/server/src/service/agent_manager.rs::update_report` constructs a fresh `ServerStatus` for every metric update; it has no database access and therefore cannot populate `tags` or `cpu_cores`. We **generalize** the existing `features` backend default (`features: vec![]` in `update_report`) and pair it with a **stronger frontend static-fields guard** that also treats empty arrays as defaults. Note: today's `features` is *not* in the frontend `STATIC_FIELDS` set and is in fact clobbered on every incremental `update`; the damage is masked because `FullSync` re-hydrates and because `docker_availability_changed` is the authoritative side-channel. This spec fixes that drift for `tags` and opportunistically for `features`: + +- **`tags`**: populated in `build_full_sync` only. In `update_report` it is left at `Vec::new()` (the default). The frontend must treat an empty `tags` on an incremental `update` message as "no change" rather than "cleared". This is implemented by adding `'tags'`, `'cpu_cores'`, and `'features'` to `STATIC_FIELDS` in `apps/web/src/hooks/use-servers-ws.ts`, and extending the guard to also treat `[]` (empty array) as a default value that must not overwrite prior state. The guard check becomes: `isStaticDefault = STATIC_FIELDS.has(key) && (value === null || value === 0 || (Array.isArray(value) && value.length === 0))`. Adding `'features'` here is opportunistic hardening: today `features` also drops to `[]` on every `update` and is rescued only by `docker_availability_changed` + `full_sync`, which is fragile. The guard change unifies all three `[]`-valued static arrays under one rule. - **`cpu_cores`**: populated in `build_full_sync` from `servers.cpu_cores`. In `update_report` it is `None`, which serializes to `null` — already covered by the existing `value === null` branch of the static-fields guard. - **Authoritative source for tag mutations on the current tab** is the optimistic cache update after a successful `PUT /api/servers/:id/tags`, not the WS. Clearing all tags (going from `["prod"]` to `[]`) works because the `PUT` response + optimistic setter writes `[]` into `queryClient.setQueryData(['servers'], …)` directly; the subsequent incremental WS `update` carrying `tags: []` is ignored by the guard, which is fine because the cache is already correct. Cross-tab propagation (Tab A edits tags, Tab B should see it) is explicitly a Phase C concern. @@ -137,8 +139,9 @@ A new block in the existing `ServerEditDialog` form: - Label: `t('servers:tags_label')` ("Tags" / "标签") - Input: shadcn `` with helper text `t('servers:tags_hint')` ("Comma or space separated, up to 8 tags, 16 chars each"). On blur, the string is split on `/[\s,]+/`, trimmed, deduped, and normalized against the same validation rules as the backend. -- Submit: a separate PUT to `/api/servers/:id/tags` fires on save (not bundled into the existing server-update PATCH). Success toast; optimistic cache update as above. - Fetched on open via `useQuery(['server-tags', id])` → `GET /api/servers/:id/tags`. The initial form value is populated from this query. +- **Save ordering with the existing server-update PATCH**: when the user clicks Save, the dialog's submit handler awaits the PATCH first (`PATCH /api/servers/:id` for name/remark/group/etc.), then, **only if PATCH succeeded and tags changed**, awaits the `PUT /api/servers/:id/tags`. Both requests show a single combined spinner on the Save button. On PATCH failure, no tag PUT is issued; on tag PUT failure after a successful PATCH, the tag-editor field reverts to its previous value (from the `['server-tags', id]` cache) and a distinct toast `t('servers:tags_save_failed')` fires — the rest of the PATCH stays committed. This keeps partial failures observable and avoids silently discarding the user's name/group edits when only the tag sub-request fails. +- On PUT success, optimistic cache update as described above writes both `['servers']` (table row) and `['server-tags', id]` (editor re-open freshness). ### Phasing @@ -179,7 +182,7 @@ Existing keys reused: `card_load`, `col_cpu`, `col_memory`, `col_disk`, `col_net `crates/server/tests/` integration coverage: -- `server_tags_crud` — PUT then GET returns same list; dedup + trim; 400 on too many / too long / invalid chars; RBAC (member GET 200, member PUT 403). +- `server_tags_crud` — PUT then GET returns same list; dedup + trim; 400 on too many / too long / invalid chars; RBAC: `unauthenticated GET 401`, `unauthenticated PUT 401`, `member GET 200`, `member PUT 403`, `admin PUT 200`. - `full_sync_includes_tags` — seed two servers each with two tags, open the browser WS, assert the first `full_sync` frame contains `tags: ["a","b"]` for each. No new unit test for `build_full_sync` shape beyond the integration test; existing WS tests cover the rest. @@ -242,4 +245,8 @@ None at spec-approval time. All resolved during brainstorming: - `MiniBar` retains its public signature; it is refactored internally rather than reduced to a wrapper. - `PUT /api/servers/:id/tags` does not touch `servers.updated_at`, to keep `last_active` honest for offline rows. - `NetworkCell` must treat `traffic_limit <= 0` identically to `null`/`undefined` (both fall back to 1 TiB default). -- `DEFAULT_TRAFFIC_LIMIT_BYTES` is hoisted to `apps/web/src/lib/traffic.ts` so `ServerCard` and `NetworkCell` share it. +- `DEFAULT_TRAFFIC_LIMIT_BYTES` is hoisted to `apps/web/src/lib/traffic.ts`, and a `computeTrafficQuota({ entry, netInTransfer, netOutTransfer }) => { used, limit, pct }` helper is the single source of truth — `ServerCard` is updated to consume it so the grid and table cannot drift on rules like "prefer `cycle_in + cycle_out` when quota is configured, else fall back to `net_in_transfer + net_out_transfer`". +- `features` is added to `STATIC_FIELDS` alongside `tags` and `cpu_cores`, closing a pre-existing clobber-on-update bug that was masked by `full_sync` hydration. +- Offline Network cell keeps rendering its traffic quota bar (server-level data survives agent offline); only the live `↓in ↑out` sub-line speeds collapse. +- `build_full_sync` fetches tags with a single ordered query grouped in memory (no N+1). +- `ServerEditDialog` saves tags via a sequential PATCH-then-PUT flow with per-step error isolation so partial failures are surfaced to the user rather than silently dropped. From 702bafaba5fd63ca4948e8ea5d415a9acc8d21d8 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 01:21:23 +0800 Subject: [PATCH 18/43] docs(superpowers): implementation plan for servers table row visual redesign --- ...04-17-servers-table-row-visual-redesign.md | 2422 +++++++++++++++++ 1 file changed, 2422 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-17-servers-table-row-visual-redesign.md diff --git a/docs/superpowers/plans/2026-04-17-servers-table-row-visual-redesign.md b/docs/superpowers/plans/2026-04-17-servers-table-row-visual-redesign.md new file mode 100644 index 00000000..841b399c --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-servers-table-row-visual-redesign.md @@ -0,0 +1,2422 @@ +# Servers Table Row Visual Redesign Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild every row in `/servers?view=table` so each metric cell is a two-line block (icon + bar + % on top, compact sub-data below), replace the status text badge with a pulsing dot column, show `server_tags` under the server name, and surface monthly traffic quota usage as a bar in the Network cell. + +**Architecture:** Phase A is purely frontend — a new shared `lib/traffic.ts` primitive, a `` primitive in the servers route, and a rewrite of every cell in `index.cells.tsx`. Phase B is additive backend — `tags` + `cpu_cores` are added to the `ServerStatus` WS payload and a small `server_tags` REST surface is exposed; the frontend gains a `useServerTags` hook and a tag editor inside `ServerEditDialog`. No database migration is required (both `servers.cpu_cores` and the `server_tags` table already exist). + +**Tech Stack:** React 19, TanStack Router + Query, shadcn/ui, lucide-react icons, Vitest + @testing-library/react, Axum 0.8, sea-orm, utoipa, tokio_tungstenite (integration tests). + +**Spec:** `docs/superpowers/specs/2026-04-17-servers-table-row-visual-redesign-design.md` + +--- + +## File Map + +### Phase A — frontend (no backend changes) + +| File | Action | Responsibility | +|------|--------|----------------| +| `apps/web/src/locales/en/servers.json` | Modify | Add tag / uptime / validation keys | +| `apps/web/src/locales/zh/servers.json` | Modify | Same keys in Chinese | +| `apps/web/src/lib/traffic.ts` | Create | `DEFAULT_TRAFFIC_LIMIT_BYTES` + `computeTrafficQuota` helper | +| `apps/web/src/lib/traffic.test.ts` | Create | Unit tests for the helper | +| `apps/web/src/components/server/server-card.tsx` | Modify | Consume `computeTrafficQuota` instead of inlined logic | +| `apps/web/src/hooks/use-servers-ws.ts` | Modify | Add `tags`, `cpu_cores` to `ServerMetrics`; extend `STATIC_FIELDS` + default guard to cover `[]` arrays | +| `apps/web/src/hooks/use-servers-ws.test.ts` | Create | Unit tests for `mergeServerUpdate` guard | +| `apps/web/src/components/server/status-dot.tsx` | Create | `` — pulsing/muted dot | +| `apps/web/src/components/server/status-dot.test.tsx` | Create | Unit test | +| `apps/web/src/components/server/tag-chip.tsx` | Create | `` with stable-hash palette | +| `apps/web/src/components/server/tag-chip.test.tsx` | Create | Unit test | +| `apps/web/src/routes/_authed/servers/index.cells.tsx` | Rewrite | New `MetricBarRow`, `CpuCell`, `MemoryCell`, `DiskCell`, `NetworkCell`, `UptimeCell`, `NameCell` | +| `apps/web/src/routes/_authed/servers/index.cells.test.tsx` | Rewrite | New cell tests (keeps the file path, replaces old tests) | +| `apps/web/src/routes/_authed/servers/index.tsx` | Modify | New column set (status-dot first, status text column dropped), `useTrafficOverview` wired to `NetworkCell` | + +### Phase B — backend + editor + +| File | Action | Responsibility | +|------|--------|----------------| +| `crates/common/src/types.rs` | Modify | Add `tags: Vec` and `cpu_cores: Option` to `ServerStatus` | +| `crates/server/src/router/ws/browser.rs` | Modify | `build_full_sync` populates both fields (single grouped query for tags) | +| `crates/server/src/service/server_tag.rs` | Create | Validation + CRUD service (`list_tags`, `set_tags`) | +| `crates/server/src/service/mod.rs` | Modify | `pub mod server_tag;` | +| `crates/server/src/router/api/server_tag.rs` | Create | REST router: `GET /api/servers/:id/tags`, `PUT /api/servers/:id/tags` | +| `crates/server/src/router/api/mod.rs` | Modify | Mount new read/write sub-routers | +| `crates/server/tests/server_tags.rs` | Create | Integration test: RBAC + validation + full_sync payload shape | +| `apps/web/src/locales/en/servers.json` | Modify | Additional tag-editor keys (save/revert toasts) | +| `apps/web/src/locales/zh/servers.json` | Modify | Same | +| `apps/web/src/hooks/use-server-tags.ts` | Create | `useServerTags(id)` + `useUpdateServerTags(id)` with optimistic cache update | +| `apps/web/src/components/server/server-edit-dialog.tsx` | Modify | Tags editor + sequential PATCH-then-PUT save | +| `tests/servers/table-row-visual-redesign.md` | Create | Manual QA checklist | + +--- + +## Chunk 1: Shared primitives & merge guard + +### Task 1: Add i18n keys for new labels + +**Files:** +- Modify: `apps/web/src/locales/en/servers.json` +- Modify: `apps/web/src/locales/zh/servers.json` + +- [ ] **Step 1: Add English keys** + +Insert after the `"edit_failed": "Failed to update server"` line: + +```json +"tags_label": "Tags", +"tags_hint": "Comma or space separated, up to 8 tags, 16 chars each", +"tags_placeholder": "prod, db, web", +"tags_validation_too_many": "At most 8 tags", +"tags_validation_too_long": "Each tag must be ≤16 chars", +"tags_validation_invalid_char": "Only letters, digits, and ._- are allowed", +"tags_save_failed": "Failed to save tags", +"last_seen_ago": "last seen {{time}}", +"offline_label": "offline", +``` + +- [ ] **Step 2: Add Chinese keys** + +Insert after the `"edit_failed"` line in `zh/servers.json`: + +```json +"tags_label": "标签", +"tags_hint": "使用逗号或空格分隔,最多 8 个标签,每个 16 字符以内", +"tags_placeholder": "prod, db, web", +"tags_validation_too_many": "最多 8 个标签", +"tags_validation_too_long": "单个标签最多 16 字符", +"tags_validation_invalid_char": "只允许字母、数字、`._-`", +"tags_save_failed": "保存标签失败", +"last_seen_ago": "最后上线 {{time}}", +"offline_label": "离线", +``` + +- [ ] **Step 3: Verify JSON is valid** + +Run: `bun run --cwd apps/web typecheck` (TypeScript resource files are validated by the build; if the project uses `bun x tsc --noEmit` it will also surface JSON parse errors through imports) + +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/locales/en/servers.json apps/web/src/locales/zh/servers.json +git commit -m "feat(web): add i18n keys for servers table tags and uptime labels" +``` + +--- + +### Task 2: Shared `lib/traffic.ts` primitive (TDD) + +**Files:** +- Create: `apps/web/src/lib/traffic.ts` +- Create: `apps/web/src/lib/traffic.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `apps/web/src/lib/traffic.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import type { TrafficOverviewItem } from '@/hooks/use-traffic-overview' +import { computeTrafficQuota, DEFAULT_TRAFFIC_LIMIT_BYTES } from './traffic' + +const GB = 1024 ** 3 +const TB = 1024 ** 4 + +function entry(overrides: Partial): TrafficOverviewItem { + return { + billing_cycle: null, + cycle_in: 0, + cycle_out: 0, + days_remaining: null, + name: 'srv', + percent_used: null, + server_id: 'srv-1', + traffic_limit: null, + ...overrides + } +} + +describe('computeTrafficQuota', () => { + it('uses cycle_in + cycle_out when entry present', () => { + const result = computeTrafficQuota({ + entry: entry({ cycle_in: 50 * GB, cycle_out: 43.2 * GB, traffic_limit: 1 * TB }), + netInTransfer: 999, + netOutTransfer: 999 + }) + expect(result.used).toBe(50 * GB + 43.2 * GB) + expect(result.limit).toBe(1 * TB) + expect(result.pct).toBeCloseTo(((50 + 43.2) / 1024) * 100, 1) + }) + + it('falls back to net_in_transfer + net_out_transfer when entry is undefined', () => { + const result = computeTrafficQuota({ + entry: undefined, + netInTransfer: 10 * GB, + netOutTransfer: 5 * GB + }) + expect(result.used).toBe(15 * GB) + expect(result.limit).toBe(DEFAULT_TRAFFIC_LIMIT_BYTES) + expect(DEFAULT_TRAFFIC_LIMIT_BYTES).toBe(TB) + }) + + it('falls back to default limit when traffic_limit is null', () => { + const result = computeTrafficQuota({ + entry: entry({ traffic_limit: null }), + netInTransfer: 0, + netOutTransfer: 0 + }) + expect(result.limit).toBe(DEFAULT_TRAFFIC_LIMIT_BYTES) + }) + + it('falls back to default limit when traffic_limit <= 0', () => { + const result = computeTrafficQuota({ + entry: entry({ traffic_limit: 0 }), + netInTransfer: 0, + netOutTransfer: 0 + }) + expect(result.limit).toBe(DEFAULT_TRAFFIC_LIMIT_BYTES) + + const negative = computeTrafficQuota({ + entry: entry({ traffic_limit: -1 }), + netInTransfer: 0, + netOutTransfer: 0 + }) + expect(negative.limit).toBe(DEFAULT_TRAFFIC_LIMIT_BYTES) + }) + + it('clamps pct to 100 when used exceeds limit', () => { + const result = computeTrafficQuota({ + entry: entry({ cycle_in: 2 * TB, cycle_out: 0, traffic_limit: 1 * TB }), + netInTransfer: 0, + netOutTransfer: 0 + }) + expect(result.pct).toBe(100) + }) + + it('returns 0 pct when limit resolves to the default and used is 0', () => { + const result = computeTrafficQuota({ + entry: undefined, + netInTransfer: 0, + netOutTransfer: 0 + }) + expect(result.pct).toBe(0) + }) +}) +``` + +- [ ] **Step 2: Run the tests (should fail with import error)** + +Run: `bun run --cwd apps/web test src/lib/traffic.test.ts` +Expected: FAIL with "Failed to resolve './traffic'" (module not found). + +- [ ] **Step 3: Create the primitive** + +Create `apps/web/src/lib/traffic.ts`: + +```ts +import type { TrafficOverviewItem } from '@/hooks/use-traffic-overview' + +export const DEFAULT_TRAFFIC_LIMIT_BYTES = 1024 ** 4 + +export interface TrafficQuota { + used: number + limit: number + pct: number +} + +interface ComputeInput { + entry: TrafficOverviewItem | undefined + netInTransfer: number + netOutTransfer: number +} + +export function computeTrafficQuota({ entry, netInTransfer, netOutTransfer }: ComputeInput): TrafficQuota { + const used = entry ? entry.cycle_in + entry.cycle_out : netInTransfer + netOutTransfer + const rawLimit = entry?.traffic_limit ?? null + const limit = rawLimit != null && rawLimit > 0 ? rawLimit : DEFAULT_TRAFFIC_LIMIT_BYTES + const rawPct = limit > 0 ? (used / limit) * 100 : 0 + const pct = Math.min(rawPct, 100) + return { used, limit, pct } +} +``` + +- [ ] **Step 4: Run the tests (should pass)** + +Run: `bun run --cwd apps/web test src/lib/traffic.test.ts` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/lib/traffic.ts apps/web/src/lib/traffic.test.ts +git commit -m "feat(web): add shared traffic quota helper" +``` + +--- + +### Task 3: Refactor `ServerCard` to use the shared helper + +**Files:** +- Modify: `apps/web/src/components/server/server-card.tsx` + +- [ ] **Step 1: Replace inlined traffic math** + +In `apps/web/src/components/server/server-card.tsx`: + +- Delete the line `const DEFAULT_TRAFFIC_LIMIT_BYTES = 1024 ** 4 // 1 TiB fallback when no quota configured` +- Replace the block that starts with `const trafficEntry = trafficOverview?.find(...)` through `const trafficRingPct = Math.min(trafficRawPct, 100)` with: + +```tsx +const trafficEntry = trafficOverview?.find((entry) => entry.server_id === server.id) +const { used: trafficUsed, limit: trafficLimit, pct: trafficRingPct } = computeTrafficQuota({ + entry: trafficEntry, + netInTransfer: server.net_in_transfer, + netOutTransfer: server.net_out_transfer +}) +const trafficDaysRemaining = trafficEntry?.days_remaining ?? null +``` + +- Add an import at the top of the file: + +```tsx +import { computeTrafficQuota } from '@/lib/traffic' +``` + +- [ ] **Step 2: Run the existing ServerCard tests** + +Run: `bun run --cwd apps/web test server-card` +Expected: PASS (no behavior change). + +- [ ] **Step 3: Run the full frontend test suite** + +Run: `bun run --cwd apps/web test` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/components/server/server-card.tsx +git commit -m "refactor(web): ServerCard consumes shared computeTrafficQuota helper" +``` + +--- + +### Task 4: Extend `mergeServerUpdate` guard and `ServerMetrics` interface + +**Files:** +- Modify: `apps/web/src/hooks/use-servers-ws.ts` +- Create: `apps/web/src/hooks/use-servers-ws.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `apps/web/src/hooks/use-servers-ws.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { mergeServerUpdate, type ServerMetrics } from './use-servers-ws' + +function baseServer(overrides: Partial = {}): ServerMetrics { + return { + id: 'srv-1', + name: 'srv', + online: true, + country_code: null, + cpu: 0, + cpu_name: null, + cpu_cores: null, + disk_read_bytes_per_sec: 0, + disk_total: 0, + disk_used: 0, + disk_write_bytes_per_sec: 0, + group_id: null, + last_active: 0, + load1: 0, + load5: 0, + load15: 0, + mem_total: 0, + mem_used: 0, + net_in_speed: 0, + net_in_transfer: 0, + net_out_speed: 0, + net_out_transfer: 0, + os: null, + process_count: 0, + region: null, + swap_total: 0, + swap_used: 0, + tags: [], + tcp_conn: 0, + udp_conn: 0, + uptime: 0, + features: [], + ...overrides + } +} + +describe('mergeServerUpdate static-fields guard', () => { + it('preserves prior tags when incoming frame carries tags: []', () => { + const prev = [baseServer({ tags: ['prod', 'web'] })] + const incoming = [baseServer({ tags: [], cpu: 42 })] + const result = mergeServerUpdate(prev, incoming) + expect(result[0].tags).toEqual(['prod', 'web']) + expect(result[0].cpu).toBe(42) + }) + + it('preserves prior features when incoming frame carries features: []', () => { + const prev = [baseServer({ features: ['docker'] })] + const incoming = [baseServer({ features: [], cpu: 10 })] + const result = mergeServerUpdate(prev, incoming) + expect(result[0].features).toEqual(['docker']) + expect(result[0].cpu).toBe(10) + }) + + it('preserves prior cpu_cores when incoming frame carries cpu_cores: null', () => { + const prev = [baseServer({ cpu_cores: 8 })] + const incoming = [baseServer({ cpu_cores: null, cpu: 5 })] + const result = mergeServerUpdate(prev, incoming) + expect(result[0].cpu_cores).toBe(8) + }) + + it('overwrites prior tags with non-empty incoming array', () => { + const prev = [baseServer({ tags: ['old'] })] + const incoming = [baseServer({ tags: ['new-a', 'new-b'] })] + const result = mergeServerUpdate(prev, incoming) + expect(result[0].tags).toEqual(['new-a', 'new-b']) + }) +}) +``` + +- [ ] **Step 2: Run the tests (expected to fail)** + +Run: `bun run --cwd apps/web test use-servers-ws` +Expected: FAIL — existing `ServerMetrics` is missing `tags` / `cpu_cores` fields; guard does not cover `[]`. + +- [ ] **Step 3: Extend `ServerMetrics` and `STATIC_FIELDS`** + +In `apps/web/src/hooks/use-servers-ws.ts`: + +- Inside `interface ServerMetrics { ... }`, add these fields (alphabetically in the interface, respecting existing ordering): + +```ts + cpu_cores?: number | null + features?: string[] // already declared? confirm; if present, ensure optional + tags?: string[] +``` + +Note: `features?: string[]` is **already declared**; do not duplicate. Only add `cpu_cores` and `tags`. + +- Extend `STATIC_FIELDS`: + +```ts +const STATIC_FIELDS = new Set([ + 'mem_total', + 'swap_total', + 'disk_total', + 'cpu_name', + 'cpu_cores', + 'os', + 'region', + 'country_code', + 'group_id', + 'tags', + 'features' +]) +``` + +- Extend `mergeServerUpdate` default-value guard: + +```ts +const isStaticDefault = + STATIC_FIELDS.has(key) && + (value === null || + value === 0 || + (Array.isArray(value) && value.length === 0)) +``` + +- [ ] **Step 4: Run the tests (expected to pass)** + +Run: `bun run --cwd apps/web test use-servers-ws` +Expected: PASS (4 tests). + +- [ ] **Step 5: Run the full frontend test suite** + +Run: `bun run --cwd apps/web test` +Expected: PASS (no regressions). + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/hooks/use-servers-ws.ts apps/web/src/hooks/use-servers-ws.test.ts +git commit -m "feat(web): guard static array fields in ServerMetrics merge" +``` + +--- + +## Chunk 2: Cell primitives (Phase A rewrites) + +### Task 5: `` component (TDD) + +**Files:** +- Create: `apps/web/src/components/server/status-dot.tsx` +- Create: `apps/web/src/components/server/status-dot.test.tsx` + +- [ ] **Step 1: Write failing tests** + +```tsx +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { StatusDot } from './status-dot' + +describe('StatusDot', () => { + it('renders pulsing emerald dot when online', () => { + const { container } = render() + const el = container.querySelector('[data-slot="status-dot"]') + expect(el?.className).toMatch(/animate-pulse/) + expect(el?.className).toMatch(/bg-emerald-500/) + }) + + it('renders muted dot without pulse when offline', () => { + const { container } = render() + const el = container.querySelector('[data-slot="status-dot"]') + expect(el?.className).not.toMatch(/animate-pulse/) + expect(el?.className).toMatch(/bg-muted-foreground/) + }) +}) +``` + +- [ ] **Step 2: Run (fail: module missing)** + +Run: `bun run --cwd apps/web test status-dot` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```tsx +import { cn } from '@/lib/utils' + +interface StatusDotProps { + className?: string + online: boolean +} + +export function StatusDot({ online, className }: StatusDotProps) { + return ( + + ) +} +``` + +- [ ] **Step 4: Run (pass)** + +Run: `bun run --cwd apps/web test status-dot` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/components/server/status-dot.tsx apps/web/src/components/server/status-dot.test.tsx +git commit -m "feat(web): add StatusDot pulsing indicator" +``` + +--- + +### Task 6: `` component (TDD) + +**Files:** +- Create: `apps/web/src/components/server/tag-chip.tsx` +- Create: `apps/web/src/components/server/tag-chip.test.tsx` + +- [ ] **Step 1: Write failing tests** + +```tsx +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { TagChipRow } from './tag-chip' + +describe('TagChipRow', () => { + it('renders nothing when tags is empty', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders nothing when tags is undefined', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders a chip per tag', () => { + render() + expect(screen.getByText('prod')).toBeDefined() + expect(screen.getByText('web')).toBeDefined() + }) + + it('assigns the same palette color to the same tag across renders', () => { + const { container, rerender } = render() + const first = container.querySelector('[data-slot="tag-chip"]')?.className + rerender() + const second = container.querySelector('[data-slot="tag-chip"]')?.className + expect(first).toBe(second) + }) + + it('adds title attr on the chip element for tooltip / truncate fallback', () => { + render() + const chip = screen.getByText('long-tag-value') + expect(chip.getAttribute('title')).toBe('long-tag-value') + }) +}) +``` + +- [ ] **Step 2: Run (fail: module missing)** + +Run: `bun run --cwd apps/web test tag-chip` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```tsx +import { cn } from '@/lib/utils' + +const PALETTE = [ + 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400', + 'bg-sky-500/15 text-sky-700 dark:text-sky-400', + 'bg-amber-500/15 text-amber-700 dark:text-amber-400', + 'bg-rose-500/15 text-rose-700 dark:text-rose-400', + 'bg-violet-500/15 text-violet-700 dark:text-violet-400', + 'bg-slate-500/15 text-slate-700 dark:text-slate-300' +] as const + +function hashTag(tag: string): number { + let h = 0 + for (let i = 0; i < tag.length; i++) { + h = (h * 31 + tag.charCodeAt(i)) | 0 + } + return Math.abs(h) % PALETTE.length +} + +interface TagChipRowProps { + className?: string + tags: string[] | undefined +} + +export function TagChipRow({ tags, className }: TagChipRowProps) { + if (!tags || tags.length === 0) { + return null + } + return ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ ) +} +``` + +- [ ] **Step 4: Run (pass)** + +Run: `bun run --cwd apps/web test tag-chip` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/components/server/tag-chip.tsx apps/web/src/components/server/tag-chip.test.tsx +git commit -m "feat(web): add TagChipRow with stable-hash palette" +``` + +--- + +### Task 7: Rewrite `index.cells.tsx` — `MetricBarRow` primitive + tests + +**Files:** +- Modify: `apps/web/src/routes/_authed/servers/index.cells.tsx` +- Modify: `apps/web/src/routes/_authed/servers/index.cells.test.tsx` + +Because the cells are all inter-dependent and the existing tests reference the old rendering (`card_load X.YY`, `↺ 2.0 MB/s`, `Σ ↓...`), we rewrite both the component file and the test file in one atomic task first (new `MetricBarRow` + the CpuCell, MemoryCell, DiskCell, NetworkCell rewrites all happen here), then extend the tests for the new cells in the next task. + +- [ ] **Step 1: Back up intent — note the current exports** + +The current `index.cells.tsx` exports: `MiniBar`, `CpuCell`, `MemoryCell`, `DiskCell`, `NetworkCell`. The rewrite must keep exporting `CpuCell`, `MemoryCell`, `DiskCell`, `NetworkCell` (consumed in `index.tsx`). `MiniBar` is no longer used elsewhere in `src/` (`rg -n "from.*index.cells" apps/web/src` will show only `index.tsx`); it will be removed. + +Run: `rg -n "import.*MiniBar" apps/web/src` +Expected: empty (no other callers). + +- [ ] **Step 2: Write the failing test skeleton for `MetricBarRow`** + +Replace the entire content of `apps/web/src/routes/_authed/servers/index.cells.test.tsx` with: + +```tsx +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { MetricBarRow } from './index.cells' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})) + +export function makeServer(overrides: Partial = {}): ServerMetrics { + return { + id: 'srv-1', + name: 'test-server', + online: true, + country_code: null, + cpu: 0, + cpu_cores: null, + cpu_name: null, + disk_read_bytes_per_sec: 0, + disk_total: 500_000_000_000, + disk_used: 120_000_000_000, + disk_write_bytes_per_sec: 0, + features: [], + group_id: null, + last_active: 0, + load1: 0, + load5: 0, + load15: 0, + mem_total: 8_000_000_000, + mem_used: 3_200_000_000, + net_in_speed: 0, + net_in_transfer: 0, + net_out_speed: 0, + net_out_transfer: 0, + os: null, + process_count: 0, + region: null, + swap_total: 0, + swap_used: 0, + tags: [], + tcp_conn: 0, + udp_conn: 0, + uptime: 0, + ...overrides + } +} + +describe('MetricBarRow', () => { + it('renders green bar below 70%', () => { + const { container } = render() + const fill = container.querySelector('[data-slot="metric-bar-fill"]') + expect(fill?.className).toMatch(/bg-emerald-500/) + }) + + it('renders amber bar at 70% and below 90%', () => { + const { container } = render() + const fill = container.querySelector('[data-slot="metric-bar-fill"]') + expect(fill?.className).toMatch(/bg-amber-500/) + }) + + it('renders red bar at 90%+', () => { + const { container } = render() + const fill = container.querySelector('[data-slot="metric-bar-fill"]') + expect(fill?.className).toMatch(/bg-red-500/) + }) + + it('rounds the percentage to 0 decimals', () => { + render() + expect(screen.getByText('43%')).toBeDefined() + }) + + it('clamps percentage to [0, 100]', () => { + render() + expect(screen.getByText('100%')).toBeDefined() + render() + expect(screen.getByText('0%')).toBeDefined() + }) + + it('renders the supplied icon slot', () => { + render(} pct={10} />) + expect(screen.getByTestId('cpu-icon')).toBeDefined() + }) +}) +``` + +- [ ] **Step 3: Run (fail: `MetricBarRow` not exported)** + +Run: `bun run --cwd apps/web test index.cells` +Expected: FAIL with "MetricBarRow is not exported" (or similar). + +- [ ] **Step 4: Introduce the new `MetricBarRow` primitive** + +Replace the entire content of `apps/web/src/routes/_authed/servers/index.cells.tsx` with: + +```tsx +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +export function getBarColor(pct: number): string { + if (pct > 90) return 'bg-red-500' + if (pct > 70) return 'bg-amber-500' + return 'bg-emerald-500' +} + +export function getBarTextColor(pct: number): string { + if (pct > 90) return 'text-red-600 dark:text-red-400' + if (pct > 70) return 'text-amber-600 dark:text-amber-400' + return 'text-foreground' +} + +interface MetricBarRowProps { + ariaLabel?: string + icon: ReactNode + pct: number + valueClassName?: string +} + +export function MetricBarRow({ icon, pct, ariaLabel, valueClassName }: MetricBarRowProps) { + const clamped = Math.min(100, Math.max(0, pct)) + const colorBg = getBarColor(clamped) + const colorText = getBarTextColor(clamped) + return ( +
+ {icon !== null && {icon}} +
+
+
+ + {Math.round(clamped)}% + +
+ ) +} +``` + +The remainder of `index.cells.tsx` (the `CpuCell`, `MemoryCell`, `DiskCell`, `NetworkCell`, plus the new `UptimeCell` and `NameCell`) will be implemented in subsequent tasks on top of this primitive. For now, leave placeholders that preserve the existing column integration by temporarily re-exporting the old cells — **however**, to avoid breaking the route, also delete the old `MiniBar`-based implementations immediately and supply stubs that will be replaced in Tasks 8–13. + +Append to `index.cells.tsx`: + +```tsx +import type { ServerMetrics } from '@/hooks/use-servers-ws' + +// Temporary stubs — replaced in Tasks 8–13. +export function CpuCell(_: { server: ServerMetrics }) { return } +export function MemoryCell(_: { server: ServerMetrics }) { return } +export function DiskCell(_: { server: ServerMetrics }) { return } +export function NetworkCell(_: { server: ServerMetrics }) { return } +``` + +- [ ] **Step 5: Run `MetricBarRow` tests (pass), other cell tests (fail)** + +Run: `bun run --cwd apps/web test index.cells` +Expected: `MetricBarRow` PASS (6 tests); other cell describes from the original file are now removed so only `MetricBarRow` runs. + +- [ ] **Step 6: Run app-wide lint / typecheck** + +Run: `bun run --cwd apps/web typecheck && bun x ultracite check apps/web/src/routes/_authed/servers/index.cells.tsx` +Expected: no new errors (stubs are typed). + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/routes/_authed/servers/index.cells.tsx apps/web/src/routes/_authed/servers/index.cells.test.tsx +git commit -m "refactor(web): introduce MetricBarRow primitive in servers cells" +``` + +--- + +### Task 8: `` rewrite (TDD) + +**Files:** +- Modify: `apps/web/src/routes/_authed/servers/index.cells.tsx` +- Modify: `apps/web/src/routes/_authed/servers/index.cells.test.tsx` + +- [ ] **Step 1: Append failing tests** + +Append inside `index.cells.test.tsx`: + +```tsx +import { CpuCell } from './index.cells' + +describe('CpuCell', () => { + it('renders cores + load when cpu_cores is present', () => { + render() + expect(screen.getByText('12%')).toBeDefined() + expect(screen.getByText(/8 cores · load 1\.23/)).toBeDefined() + }) + + it('falls back to load-only when cpu_cores is null (Phase A)', () => { + render() + expect(screen.queryByText(/cores/)).toBeNull() + expect(screen.getByText(/load 1\.23/)).toBeDefined() + }) + + it('hides sub-line when offline', () => { + render() + expect(screen.queryByText(/cores/)).toBeNull() + expect(screen.queryByText(/load/)).toBeNull() + }) +}) +``` + +- [ ] **Step 2: Run (fail: stub returns 0% / no load text)** + +Run: `bun run --cwd apps/web test index.cells` +Expected: CpuCell tests FAIL. + +- [ ] **Step 3: Replace the CpuCell stub** + +Add the `Cpu` import at the top of `index.cells.tsx`: + +```tsx +import { Cpu } from 'lucide-react' +``` + +Replace the `CpuCell` stub with: + +```tsx +export function CpuCell({ server }: { server: ServerMetrics }) { + if (!server.online) { + return + } + const cores = server.cpu_cores ?? null + return ( +
+
+ ) +} +``` + +- [ ] **Step 4: Run (pass)** + +Run: `bun run --cwd apps/web test index.cells` +Expected: CpuCell PASS (3 tests) + MetricBarRow still PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/routes/_authed/servers/index.cells.tsx apps/web/src/routes/_authed/servers/index.cells.test.tsx +git commit -m "feat(web): CpuCell shows cores + load with Phase A fallback" +``` + +--- + +### Task 9: `` rewrite (TDD) + +- [ ] **Step 1: Append failing tests** + +```tsx +import { MemoryCell } from './index.cells' + +describe('MemoryCell', () => { + it('renders used/total + swap pct', () => { + render( + + ) + expect(screen.getByText(/7\.2 GB \/ 16\.0 GB/)).toBeDefined() + expect(screen.getByText(/swap/)).toBeDefined() + expect(screen.getByText(/3%/)).toBeDefined() + }) + + it('renders 0% swap when swap_total is 0', () => { + render( + + ) + expect(screen.getByText(/swap 0%/)).toBeDefined() + }) + + it('hides sub-line when offline', () => { + render() + expect(screen.queryByText(/swap/)).toBeNull() + }) +}) +``` + +- [ ] **Step 2: Run (fail)** +- [ ] **Step 3: Replace the `MemoryCell` stub** + +Import `MemoryStick` in the lucide import line: + +```tsx +import { Cpu, MemoryStick } from 'lucide-react' +``` + +Add the helper `formatBytes` import near the top: + +```tsx +import { formatBytes } from '@/lib/utils' +``` + +Replace the stub: + +```tsx +export function MemoryCell({ server }: { server: ServerMetrics }) { + if (!server.online) { + return + } + const pct = server.mem_total > 0 ? (server.mem_used / server.mem_total) * 100 : 0 + const swapPct = server.swap_total > 0 ? (server.swap_used / server.swap_total) * 100 : 0 + const swapColor = getBarTextColor(swapPct) + return ( +
+
+ ) +} +``` + +- [ ] **Step 4: Run (pass)** +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/routes/_authed/servers/index.cells.tsx apps/web/src/routes/_authed/servers/index.cells.test.tsx +git commit -m "feat(web): MemoryCell shows used/total + swap pct" +``` + +--- + +### Task 10: `` rewrite (TDD) + +- [ ] **Step 1: Append failing tests** + +```tsx +import { DiskCell } from './index.cells' + +describe('DiskCell', () => { + it('shows usage bar + r/w speeds when online', () => { + render( + + ) + expect(screen.getByText('60%')).toBeDefined() + expect(screen.getByText(/2\.0 MB\/s/)).toBeDefined() + expect(screen.getByText(/500\.0 KB\/s/)).toBeDefined() + }) + + it('hides r/w sub when offline', () => { + render( + + ) + expect(screen.queryByText(/KB\/s/)).toBeNull() + }) + + it('renders 0% when disk_total is 0', () => { + render() + expect(screen.getByText('0%')).toBeDefined() + }) +}) +``` + +- [ ] **Step 2: Run (fail)** +- [ ] **Step 3: Replace the `DiskCell` stub** + +Add `HardDrive`, `ArrowDown`, `ArrowUp` imports: + +```tsx +import { ArrowDown, ArrowUp, Cpu, HardDrive, MemoryStick } from 'lucide-react' +``` + +Add `formatSpeed` to the utils import: + +```tsx +import { formatBytes, formatSpeed } from '@/lib/utils' +``` + +Replace the stub: + +```tsx +export function DiskCell({ server }: { server: ServerMetrics }) { + if (!server.online) { + return + } + const pct = server.disk_total > 0 ? (server.disk_used / server.disk_total) * 100 : 0 + return ( +
+
+ ) +} +``` + +- [ ] **Step 4: Run (pass)** +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/routes/_authed/servers/index.cells.tsx apps/web/src/routes/_authed/servers/index.cells.test.tsx +git commit -m "feat(web): DiskCell shows usage + disk I/O with lucide icons" +``` + +--- + +### Task 11: `` rewrite (TDD) + +**Design note:** this is the only cell that takes external data (`TrafficOverviewItem | undefined`). We lift `useTrafficOverview` to the page level (`index.tsx`) and pass the per-row entry through a prop. + +- [ ] **Step 1: Append failing tests** + +```tsx +import { NetworkCell } from './index.cells' +import type { TrafficOverviewItem } from '@/hooks/use-traffic-overview' + +const GB = 1024 ** 3 +const TB = 1024 ** 4 + +function makeEntry(overrides: Partial): TrafficOverviewItem { + return { + billing_cycle: null, + cycle_in: 0, + cycle_out: 0, + days_remaining: null, + name: 'srv', + percent_used: null, + server_id: 'srv-1', + traffic_limit: null, + ...overrides + } +} + +describe('NetworkCell', () => { + it('renders traffic-quota bar + used/limit + live ↓↑ when online', () => { + render( + + ) + expect(screen.getByText('9%')).toBeDefined() + expect(screen.getByText(/93\.2 GB \/ 1\.0 TB/)).toBeDefined() + expect(screen.getByText(/1\.1 MB\/s/)).toBeDefined() + expect(screen.getByText(/332\.0 KB\/s/)).toBeDefined() + }) + + it('falls back to net_in_transfer + 1 TiB default when entry is undefined', () => { + render( + + ) + // 3 GB / 1 TiB ≈ 0.29% → rounds to 0% + expect(screen.getByText('0%')).toBeDefined() + expect(screen.getByText(/3\.0 GB \/ 1\.0 TB/)).toBeDefined() + }) + + it('renders traffic-quota bar even when offline (server-level data)', () => { + render( + + ) + expect(screen.getByText(/10%/)).toBeDefined() + expect(screen.getByText(/100\.0 GB \/ 1\.0 TB/)).toBeDefined() + expect(screen.queryByText(/MB\/s/)).toBeNull() + expect(screen.queryByText(/KB\/s/)).toBeNull() + }) + + it('treats traffic_limit <= 0 as fallback to default', () => { + render( + + ) + expect(screen.getByText(/1\.0 TB/)).toBeDefined() + }) +}) +``` + +- [ ] **Step 2: Run (fail)** +- [ ] **Step 3: Replace the `NetworkCell` stub** + +Add `Network` to the lucide import: + +```tsx +import { ArrowDown, ArrowUp, Cpu, HardDrive, MemoryStick, Network } from 'lucide-react' +``` + +Add traffic import: + +```tsx +import { computeTrafficQuota } from '@/lib/traffic' +import type { TrafficOverviewItem } from '@/hooks/use-traffic-overview' +``` + +Replace the stub: + +```tsx +interface NetworkCellProps { + entry: TrafficOverviewItem | undefined + server: ServerMetrics +} + +export function NetworkCell({ server, entry }: NetworkCellProps) { + const { used, limit, pct } = computeTrafficQuota({ + entry, + netInTransfer: server.net_in_transfer, + netOutTransfer: server.net_out_transfer + }) + return ( +
+
+ ) +} +``` + +- [ ] **Step 4: Run (pass)** +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/routes/_authed/servers/index.cells.tsx apps/web/src/routes/_authed/servers/index.cells.test.tsx +git commit -m "feat(web): NetworkCell shows traffic quota bar + live speeds" +``` + +--- + +### Task 12: `` + `` (TDD) + +**Files:** +- Modify: `apps/web/src/routes/_authed/servers/index.cells.tsx` +- Modify: `apps/web/src/routes/_authed/servers/index.cells.test.tsx` + +- [ ] **Step 1: Append failing tests** + +```tsx +import { NameCell, UptimeCell } from './index.cells' + +describe('UptimeCell', () => { + const NOW = 1_700_000_000 + const _originalNow = Date.now + beforeEach(() => { + Date.now = () => NOW * 1000 + }) + afterEach(() => { + Date.now = _originalNow + }) + + it('shows uptime + OS line when online', () => { + render( + + ) + expect(screen.getByText(/23d/)).toBeDefined() + expect(screen.getByText(/Ubuntu 22\.04/)).toBeDefined() + }) + + it('shows offline + last-seen relative when offline', () => { + render( + + ) + expect(screen.getByText(/offline/i)).toBeDefined() + expect(screen.getByText(/last_seen_ago/)).toBeDefined() + }) +}) + +describe('NameCell', () => { + it('renders single-line layout when no tags', () => { + const { container } = render( + + ) + expect(screen.getByText('tokyo-1')).toBeDefined() + expect(container.querySelector('[data-slot="tag-chip"]')).toBeNull() + }) + + it('renders chips under the name when tags present', () => { + render() + expect(screen.getByText('prod')).toBeDefined() + expect(screen.getByText('web')).toBeDefined() + }) +}) +``` + +Note: the test file will need `beforeEach`/`afterEach` imports at the top. Update the top import: + +```tsx +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +``` + +- [ ] **Step 2: Run (fail)** +- [ ] **Step 3: Implement `UptimeCell` and `NameCell`** + +Add imports to `index.cells.tsx`: + +```tsx +import { Link } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { Clock } from 'lucide-react' // extend the existing lucide import +import { TagChipRow } from '@/components/server/tag-chip' +import { countryCodeToFlag, formatUptime } from '@/lib/utils' +``` + +Add at the bottom of `index.cells.tsx`: + +```tsx +function osEmoji(os: string | null): string { + if (!os) return '' + const l = os.toLowerCase() + if (l.includes('ubuntu') || l.includes('debian') || l.includes('linux')) return '🐧' + if (l.includes('windows')) return '🪟' + if (l.includes('macos') || l.includes('darwin')) return '🍎' + if (l.includes('freebsd') || l.includes('openbsd')) return '😈' + return '' +} + +function relativeTime(thenSec: number, nowMs = Date.now()): string { + const diffSec = Math.max(0, Math.floor(nowMs / 1000) - thenSec) + if (diffSec < 60) return `${diffSec}s ago` + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago` + if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago` + return `${Math.floor(diffSec / 86400)}d ago` +} + +export function UptimeCell({ server }: { server: ServerMetrics }) { + const { t } = useTranslation(['servers']) + const emoji = osEmoji(server.os) + if (!server.online) { + return ( +
+ {t('offline_label')} + + {t('last_seen_ago', { time: relativeTime(server.last_active) })} + +
+ ) + } + return ( +
+ + + {server.os && ( + + {emoji && {emoji}} + {server.os} + + )} +
+ ) +} + +export function NameCell({ server }: { server: ServerMetrics }) { + const flag = countryCodeToFlag(server.country_code) + return ( +
+ + {flag && {flag}} + {server.name} + + +
+ ) +} +``` + +- [ ] **Step 4: Run (pass)** +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/routes/_authed/servers/index.cells.tsx apps/web/src/routes/_authed/servers/index.cells.test.tsx +git commit -m "feat(web): UptimeCell and NameCell with tags support" +``` + +--- + +### Task 13: Update `index.tsx` columns (status-dot first, Network+Uptime+Name use new cells) + +**Files:** +- Modify: `apps/web/src/routes/_authed/servers/index.tsx` + +- [ ] **Step 1: Wire the new cells and add traffic-overview query** + +At the top of the file, add: + +```tsx +import { CircleDot } from 'lucide-react' +import { useTrafficOverview } from '@/hooks/use-traffic-overview' +import { StatusDot } from '@/components/server/status-dot' +import { CpuCell, DiskCell, MemoryCell, NameCell, NetworkCell, UptimeCell } from './index.cells' +``` + +Remove the following imports (no longer used): +- `StatusBadge` (replaced by `StatusDot`) +- `CircleDot` **keep** — still used for filter icon in the new dot column meta +- Individual cell imports from `./index.cells` were already present; update to the new list + +Inside `ServersListPage`, near the other queries: + +```tsx +const { data: trafficOverview = [] } = useTrafficOverview() +``` + +Inside `useMemo` for `columns`, replace: + +```tsx + { + id: 'select', + ... +``` + +- Keep the `select` column unchanged. +- **Insert a new `status-dot` column immediately after `select` and before `name`:** + +```tsx + { + id: 'status-dot', + accessorFn: (row) => (row.online ? 'online' : 'offline'), + enableSorting: false, + header: () => null, + cell: ({ row }) => , + filterFn: arrayIncludesFilter, + enableColumnFilter: true, + size: 36, + meta: { + className: 'w-9', + label: t('col_status'), + variant: 'select', + options: statusOptions, + icon: CircleDot + } + }, +``` + +- **Delete the old `status` column** (`id: 'status'` with the `StatusBadge` cell). + +- Replace the `name` column's `cell` with: + +```tsx + cell: ({ row }) => , +``` + +(`UpgradeBadgeCell` must still render somewhere — append it inside `NameCell`'s link row; see Task 13 addendum below.) + +- Replace the `network` column's `cell` with: + +```tsx + cell: ({ row }) => { + const entry = trafficOverview.find((e) => e.server_id === row.original.id) + return + }, +``` + +- Replace the `uptime` column's `cell` with: + +```tsx + cell: ({ row }) => , +``` + +- **Addendum: move `UpgradeBadgeCell` into `NameCell`.** Because `NameCell` now owns the Name layout, modify `NameCell` (in `index.cells.tsx`) to accept and render an optional right-side slot, OR pass `UpgradeBadgeCell` via composition. Simplest: add a `rightSlot` prop to `NameCell` and, in `index.tsx`'s column cell, pass `` as `rightSlot`. + +Update `NameCell` signature in `index.cells.tsx`: + +```tsx +export function NameCell({ server, rightSlot }: { server: ServerMetrics; rightSlot?: ReactNode }) { + ... + return ( +
+
+ + {flag && {flag}} + {server.name} + + {rightSlot} +
+ +
+ ) +} +``` + +In `index.tsx`, the `name` column cell becomes: + +```tsx + cell: ({ row }) => } />, +``` + +- [ ] **Step 2: Run the frontend test suite** + +Run: `bun run --cwd apps/web test` +Expected: PASS (cells + existing route tests). + +- [ ] **Step 3: Run ultracite** + +Run: `bun x ultracite check apps/web/src/routes/_authed/servers/` +Expected: no errors. + +- [ ] **Step 4: Run typecheck** + +Run: `bun run --cwd apps/web typecheck` +Expected: exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/routes/_authed/servers/index.tsx apps/web/src/routes/_authed/servers/index.cells.tsx +git commit -m "feat(web): servers table adopts new cells with status-dot column" +``` + +--- + +### Task 14: Phase A green — full lint / typecheck / test + +- [ ] **Step 1: Frontend tests** + +Run: `bun run --cwd apps/web test` +Expected: all green. + +- [ ] **Step 2: Ultracite** + +Run: `bun x ultracite check` +Expected: clean. + +- [ ] **Step 3: Typecheck** + +Run: `bun run typecheck` +Expected: clean. + +- [ ] **Step 4: Manual smoke (if dev env available)** + +Run `make web-dev-prod` (if configured) or run the local server and visit `/servers?view=table`. Visually verify pulsing dot, dual-line cells, traffic bar, tag row absent (Phase A, no tags pushed). + +- [ ] **Step 5: Mark Phase A done** + +```bash +git tag --annotate phase-a-complete -m "servers table visual refactor Phase A" +``` + +(Tag is local; push is explicit and not part of this plan.) + +--- + +## Chunk 3: Backend — tags and cpu_cores on the wire (Phase B) + +### Task 15: Add `tags` and `cpu_cores` to `ServerStatus` + +**Files:** +- Modify: `crates/common/src/types.rs` + +- [ ] **Step 1: Edit the struct** + +In `crates/common/src/types.rs`, locate the `ServerStatus` struct (around line 141) and add the two new fields at the end (before the closing brace), with `#[serde(default)]`: + +```rust + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub cpu_cores: Option, +``` + +- [ ] **Step 2: Update the existing deserialization test for backward-compat (if any)** + +Run: `cargo test -p serverbee-common` +Expected: PASS — `#[serde(default)]` guarantees old payloads still deserialize. + +- [ ] **Step 3: Commit** + +```bash +git add crates/common/src/types.rs +git commit -m "feat(common): extend ServerStatus with tags and cpu_cores" +``` + +--- + +### Task 16: Fetch + populate in `build_full_sync` + +**Files:** +- Modify: `crates/server/src/router/ws/browser.rs` + +- [ ] **Step 1: Add imports** + +Near the top of `browser.rs`, add: + +```rust +use std::collections::HashMap; +use sea_orm::{EntityTrait, QueryOrder}; +use crate::entity::server_tag; +``` + +(Confirm existing imports; only add what's missing.) + +- [ ] **Step 2: Group-query tags once** + +Inside `build_full_sync`, after reading the servers list and before the per-server `ServerStatus` construction loop, add: + +```rust +let tags_rows = server_tag::Entity::find() + .order_by_asc(server_tag::Column::ServerId) + .order_by_asc(server_tag::Column::Tag) + .all(&state.db) + .await + .unwrap_or_default(); +let mut tags_by_server: HashMap> = HashMap::new(); +for row in tags_rows { + tags_by_server.entry(row.server_id).or_default().push(row.tag); +} +``` + +- [ ] **Step 3: Populate the new fields inside the `ServerStatus { ... }` literal** + +Inside the struct-literal inside `build_full_sync`, add: + +```rust + tags: tags_by_server.remove(&server.id).unwrap_or_default(), + cpu_cores: server.cpu_cores, +``` + +(Place them alphabetically within the literal to match project style if possible.) + +- [ ] **Step 4: Also zero them out in `update_report` in `agent_manager.rs`** + +In `crates/server/src/service/agent_manager.rs::update_report`, inside the `ServerStatus { ... }` literal that constructs the incremental-update payload, add (if not already present): + +```rust + tags: Vec::new(), + cpu_cores: None, +``` + +- [ ] **Step 5: Build** + +Run: `cargo build --workspace` +Expected: success. + +- [ ] **Step 6: Commit** + +```bash +git add crates/server/src/router/ws/browser.rs crates/server/src/service/agent_manager.rs +git commit -m "feat(server): include tags and cpu_cores in ServerStatus full_sync" +``` + +--- + +### Task 17: `service/server_tag.rs` — validation and CRUD service (TDD) + +**Files:** +- Create: `crates/server/src/service/server_tag.rs` +- Modify: `crates/server/src/service/mod.rs` + +- [ ] **Step 1: Register the module** + +In `crates/server/src/service/mod.rs`, add: + +```rust +pub mod server_tag; +``` + +- [ ] **Step 2: Write the unit tests first** + +Create `crates/server/src/service/server_tag.rs` with the tests at the bottom: + +```rust +use sea_orm::{DatabaseConnection, EntityTrait, QueryOrder, Set, TransactionTrait}; +use crate::entity::server_tag; +use crate::error::AppError; + +pub const MAX_TAGS: usize = 8; +pub const MAX_TAG_LEN: usize = 16; + +pub fn validate_tags(raw: &[String]) -> Result, AppError> { + if raw.len() > MAX_TAGS { + return Err(AppError::Validation(format!( + "at most {MAX_TAGS} tags" + ))); + } + let mut seen = std::collections::BTreeSet::new(); + for tag in raw { + let trimmed = tag.trim().to_string(); + if trimmed.is_empty() { + continue; + } + if trimmed.chars().count() > MAX_TAG_LEN { + return Err(AppError::Validation(format!( + "tag '{trimmed}' exceeds {MAX_TAG_LEN} chars" + ))); + } + if !trimmed.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) { + return Err(AppError::Validation(format!( + "tag '{trimmed}' contains invalid characters" + ))); + } + seen.insert(trimmed); + } + Ok(seen.into_iter().collect()) +} + +pub async fn list_tags(db: &DatabaseConnection, server_id: &str) -> Result, AppError> { + let rows = server_tag::Entity::find() + .filter(server_tag::Column::ServerId.eq(server_id)) + .order_by_asc(server_tag::Column::Tag) + .all(db) + .await?; + Ok(rows.into_iter().map(|r| r.tag).collect()) +} + +pub async fn set_tags( + db: &DatabaseConnection, + server_id: &str, + tags: Vec, +) -> Result, AppError> { + let normalized = validate_tags(&tags)?; + let txn = db.begin().await?; + server_tag::Entity::delete_many() + .filter(server_tag::Column::ServerId.eq(server_id)) + .exec(&txn) + .await?; + for tag in &normalized { + server_tag::ActiveModel { + server_id: Set(server_id.to_string()), + tag: Set(tag.clone()), + } + .insert(&txn) + .await?; + } + txn.commit().await?; + Ok(normalized) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_rejects_too_many() { + let tags: Vec = (0..9).map(|i| format!("t{i}")).collect(); + assert!(validate_tags(&tags).is_err()); + } + + #[test] + fn validate_rejects_too_long() { + let tags = vec!["a".repeat(17)]; + assert!(validate_tags(&tags).is_err()); + } + + #[test] + fn validate_rejects_invalid_chars() { + assert!(validate_tags(&vec!["bad space".into()]).is_err()); + assert!(validate_tags(&vec!["bad/slash".into()]).is_err()); + } + + #[test] + fn validate_trims_and_dedupes_and_sorts() { + let got = validate_tags(&vec![" b ".into(), "a".into(), "b".into()]).unwrap(); + assert_eq!(got, vec!["a".to_string(), "b".to_string()]); + } + + #[test] + fn validate_skips_empty_after_trim() { + let got = validate_tags(&vec![" ".into(), "a".into()]).unwrap(); + assert_eq!(got, vec!["a".to_string()]); + } + + #[test] + fn validate_allows_underscore_dash_dot() { + assert!(validate_tags(&vec!["db_primary".into(), "db-secondary".into(), "v1.0".into()]).is_ok()); + } +} +``` + +- [ ] **Step 3: Add the missing `ColumnTrait` import (sea-orm filtering)** + +At the top of `server_tag.rs`: + +```rust +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait}; +``` + +(Replace the earlier imports if duplicate.) + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p serverbee-server --lib service::server_tag` +Expected: PASS (6 unit tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/server/src/service/server_tag.rs crates/server/src/service/mod.rs +git commit -m "feat(server): add server_tag service with validation" +``` + +--- + +### Task 18: REST router for tags + +**Files:** +- Create: `crates/server/src/router/api/server_tag.rs` +- Modify: `crates/server/src/router/api/mod.rs` + +- [ ] **Step 1: Create the handlers** + +Create `crates/server/src/router/api/server_tag.rs`: + +```rust +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::routing::{get, put}; +use axum::{Json, Router}; +use serde::Deserialize; + +use crate::error::{ApiResponse, AppError, ok}; +use crate::service::server_tag; +use crate::state::AppState; + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct SetTagsRequest { + tags: Vec, +} + +/// Read router — all authenticated users. +pub fn read_router() -> Router> { + Router::new().route("/servers/{id}/tags", get(get_tags)) +} + +/// Write router — admin only (mounted under the require_admin layer in api::mod). +pub fn write_router() -> Router> { + Router::new().route("/servers/{id}/tags", put(put_tags)) +} + +#[utoipa::path( + get, + path = "/api/servers/{id}/tags", + operation_id = "get_server_tags", + tag = "server-tags", + params(("id" = String, Path, description = "Server ID")), + responses( + (status = 200, description = "Tags for the server", body = Vec), + (status = 401, description = "Unauthenticated"), + ), + security(("session_cookie" = []), ("api_key" = []), ("bearer_token" = [])) +)] +async fn get_tags( + State(state): State>, + Path(id): Path, +) -> Result>>, AppError> { + let tags = server_tag::list_tags(&state.db, &id).await?; + ok(tags) +} + +#[utoipa::path( + put, + path = "/api/servers/{id}/tags", + operation_id = "set_server_tags", + tag = "server-tags", + params(("id" = String, Path, description = "Server ID")), + request_body = SetTagsRequest, + responses( + (status = 200, description = "Canonical tag list after update", body = Vec), + (status = 400, description = "Validation error"), + (status = 401, description = "Unauthenticated"), + (status = 403, description = "Forbidden (non-admin)"), + ), + security(("session_cookie" = []), ("api_key" = []), ("bearer_token" = [])) +)] +async fn put_tags( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> Result>>, AppError> { + let normalized = server_tag::set_tags(&state.db, &id, body.tags).await?; + ok(normalized) +} +``` + +- [ ] **Step 2: Mount the router** + +In `crates/server/src/router/api/mod.rs`: + +- Add `pub mod server_tag;` at the top (alphabetically — after `server_group`). +- Mount `read_router` alongside other `read_router`s: + +```rust + .merge(server_tag::read_router()) +``` + +- Mount `write_router` alongside other `write_router`s (inside the `require_admin` layer): + +```rust + .merge(server_tag::write_router()) +``` + +- [ ] **Step 3: Build and clippy** + +Run: `cargo clippy -p serverbee-server -- -D warnings` +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add crates/server/src/router/api/server_tag.rs crates/server/src/router/api/mod.rs +git commit -m "feat(server): add /api/servers/:id/tags read/write routes" +``` + +--- + +### Task 19: Integration tests for tags + full_sync + +**Files:** +- Create: `crates/server/tests/server_tags.rs` + +- [ ] **Step 1: Reuse the test helpers** + +The existing `crates/server/tests/integration.rs` defines `start_test_server`, `http_client`, `login_admin`, `register_agent`. Because those helpers are `async fn` in a separate test binary, copy-paste the minimal set needed into the new `server_tags.rs` (Rust integration tests don't share modules across files). + +Create `crates/server/tests/server_tags.rs`: + +```rust +// Copy `start_test_server`, `http_client`, `login_admin`, `register_agent` verbatim +// from tests/integration.rs. (Integration test binaries don't share modules.) + +// ...helpers above... + +#[tokio::test] +async fn unauthenticated_get_tags_returns_401() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + let resp = client + .get(format!("{}/api/servers/unknown/tags", base_url)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 401); +} + +#[tokio::test] +async fn unauthenticated_put_tags_returns_401() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + let resp = client + .put(format!("{}/api/servers/unknown/tags", base_url)) + .json(&serde_json::json!({"tags": ["a"]})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 401); +} + +#[tokio::test] +async fn admin_put_then_get_roundtrips() { + let (base_url, _tmp) = start_test_server().await; + let admin = http_client(); + login_admin(&admin, &base_url).await; + + // Use a known server id (the agent-register flow creates one) + let (server_id, _token) = register_agent(&admin, &base_url).await; + + let resp = admin + .put(format!("{}/api/servers/{server_id}/tags", base_url)) + .json(&serde_json::json!({"tags": ["b", "a", "b", " c "]})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + let data: Vec = serde_json::from_value(body["data"].clone()).unwrap(); + assert_eq!(data, vec!["a", "b", "c"]); + + let resp = admin.get(format!("{}/api/servers/{server_id}/tags", base_url)).send().await.unwrap(); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + let data: Vec = serde_json::from_value(body["data"].clone()).unwrap(); + assert_eq!(data, vec!["a", "b", "c"]); +} + +#[tokio::test] +async fn admin_put_rejects_too_many_tags() { + let (base_url, _tmp) = start_test_server().await; + let admin = http_client(); + login_admin(&admin, &base_url).await; + let (server_id, _token) = register_agent(&admin, &base_url).await; + let many: Vec = (0..9).map(|i| format!("t{i}")).collect(); + let resp = admin + .put(format!("{}/api/servers/{server_id}/tags", base_url)) + .json(&serde_json::json!({"tags": many})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400); +} + +#[tokio::test] +async fn admin_put_rejects_invalid_char() { + let (base_url, _tmp) = start_test_server().await; + let admin = http_client(); + login_admin(&admin, &base_url).await; + let (server_id, _token) = register_agent(&admin, &base_url).await; + let resp = admin + .put(format!("{}/api/servers/{server_id}/tags", base_url)) + .json(&serde_json::json!({"tags": ["has space"]})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400); +} +``` + +Add a second test binary file for full_sync inclusion; but to minimize new files, extend the test above with a full_sync WebSocket open and assertion. Alternatively, use the existing `integration.rs` test binary — **prefer** adding a test to `integration.rs` (same binary, shared helpers) rather than duplicating helpers. + +Simpler: **put the full_sync assertion directly in `integration.rs`** (not in the new `server_tags.rs` file). Add to `integration.rs`: + +```rust +#[tokio::test] +async fn browser_ws_full_sync_includes_tags_and_cpu_cores() { + let (base_url, _tmp) = start_test_server().await; + let admin = http_client(); + login_admin(&admin, &base_url).await; + let (server_id, _token) = register_agent(&admin, &base_url).await; + + // Seed tags + admin + .put(format!("{}/api/servers/{server_id}/tags", base_url)) + .json(&serde_json::json!({"tags": ["alpha", "beta"]})) + .send() + .await + .unwrap(); + + // Open the browser WS; use the existing session cookie from `admin` + // (extract cookie; copy the pattern used for other browser WS tests in this file). + // The first message should be `full_sync` and each server should include `tags`. + + // ...copy-paste the pattern from an existing browser-ws test in this file and + // assert: json["servers"][0]["tags"] == ["alpha","beta"] and json["servers"][0]["cpu_cores"] is null or an integer. +} +``` + +(The reason for splitting: integration.rs already has the browser-ws helpers; the new server_tags.rs only exercises REST. If browser-ws helpers are not already present in integration.rs, inline the `tokio_tungstenite::connect_async` call here with the session cookie header.) + +- [ ] **Step 2: Run** + +Run: `cargo test -p serverbee-server --test server_tags` +Run: `cargo test -p serverbee-server --test integration browser_ws_full_sync_includes_tags_and_cpu_cores` +Expected: all PASS. + +- [ ] **Step 3: Commit** + +```bash +git add crates/server/tests/server_tags.rs crates/server/tests/integration.rs +git commit -m "test(server): cover tags CRUD + RBAC and full_sync payload inclusion" +``` + +--- + +### Task 20: Backend green — clippy + tests + +- [ ] **Step 1:** `cargo clippy --workspace -- -D warnings` +- [ ] **Step 2:** `cargo test --workspace` +- [ ] **Step 3:** Expected: all PASS. + +--- + +## Chunk 4: Frontend — tag editor in ServerEditDialog (Phase B) + +### Task 21: `use-server-tags.ts` hook (TDD) + +**Files:** +- Create: `apps/web/src/hooks/use-server-tags.ts` +- Create: `apps/web/src/hooks/use-server-tags.test.ts` + +- [ ] **Step 1: Write failing tests** + +```ts +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { useServerTags, useUpdateServerTags } from './use-server-tags' + +const server = setupServer() +beforeAll(() => server.listen()) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +function wrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return { + qc, + wrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ) + } +} + +describe('useServerTags', () => { + it('fetches GET /api/servers/:id/tags', async () => { + server.use( + http.get('/api/servers/srv-1/tags', () => HttpResponse.json({ data: ['a', 'b'] })) + ) + const { wrapper: Wrapper } = wrapper() + const { result } = renderHook(() => useServerTags('srv-1'), { wrapper: Wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual(['a', 'b']) + }) +}) + +describe('useUpdateServerTags', () => { + it('PUTs tags and optimistically patches both caches', async () => { + server.use( + http.put('/api/servers/srv-1/tags', async ({ request }) => { + const body = (await request.json()) as { tags: string[] } + return HttpResponse.json({ data: body.tags.toSorted() }) + }) + ) + const { qc, wrapper: Wrapper } = wrapper() + qc.setQueryData(['server-tags', 'srv-1'], ['old']) + qc.setQueryData(['servers'], [{ id: 'srv-1', tags: ['old'] }]) + const { result } = renderHook(() => useUpdateServerTags('srv-1'), { wrapper: Wrapper }) + await result.current.mutateAsync(['b', 'a']) + expect(qc.getQueryData(['server-tags', 'srv-1'])).toEqual(['a', 'b']) + expect((qc.getQueryData(['servers']) as Array<{ id: string; tags: string[] }>)[0].tags).toEqual(['a', 'b']) + }) +}) +``` + +- [ ] **Step 2: Run (fail — module missing; msw may also require setup)** + +Run: `bun run --cwd apps/web test use-server-tags` +Expected: FAIL. + +If `msw/node` isn't installed, skip the MSW-based test and use a lightweight `vi.fn()` for `fetch` instead (the existing test setup probably uses this pattern — check `apps/web/src/test-setup.ts` and mirror it). + +- [ ] **Step 3: Implement the hook** + +Create `apps/web/src/hooks/use-server-tags.ts`: + +```ts +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { api } from '@/lib/api-client' + +export function useServerTags(serverId: string, enabled = true) { + return useQuery({ + queryKey: ['server-tags', serverId], + queryFn: () => api.get(`/api/servers/${serverId}/tags`), + enabled: enabled && !!serverId, + staleTime: 60_000 + }) +} + +export function useUpdateServerTags(serverId: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (tags) => api.put(`/api/servers/${serverId}/tags`, { tags }), + onSuccess: (data) => { + queryClient.setQueryData(['server-tags', serverId], data) + queryClient.setQueryData(['servers'], (prev) => + prev?.map((s) => (s.id === serverId ? { ...s, tags: data } : s)) + ) + } + }) +} +``` + +- [ ] **Step 4: Run (pass)** + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/hooks/use-server-tags.ts apps/web/src/hooks/use-server-tags.test.ts +git commit -m "feat(web): useServerTags + useUpdateServerTags with optimistic cache" +``` + +--- + +### Task 22: Tags editor in `ServerEditDialog` + +**Files:** +- Modify: `apps/web/src/components/server/server-edit-dialog.tsx` + +- [ ] **Step 1: Add tag editor state and query** + +Inside `ServerEditDialog`: + +```tsx +import { useServerTags, useUpdateServerTags } from '@/hooks/use-server-tags' + +// ... inside the component +const [tagsInput, setTagsInput] = useState('') +const [tagsDirty, setTagsDirty] = useState(false) +const { data: initialTags } = useServerTags(server.id, open) +const tagsMutation = useUpdateServerTags(server.id) + +useEffect(() => { + if (open && initialTags) { + setTagsInput(initialTags.join(', ')) + setTagsDirty(false) + } +}, [open, initialTags]) +``` + +- [ ] **Step 2: Parse and validate on the client** + +Add a pure helper inside the file (not exported): + +```tsx +function parseTagsInput(raw: string): { tags: string[]; error: string | null } { + const parts = raw.split(/[\s,]+/).map((t) => t.trim()).filter(Boolean) + const seen = new Set() + const deduped: string[] = [] + for (const tag of parts) { + if (tag.length > 16) return { tags: [], error: 'tags_validation_too_long' } + if (!/^[A-Za-z0-9_.\-]+$/.test(tag)) return { tags: [], error: 'tags_validation_invalid_char' } + if (seen.has(tag)) continue + seen.add(tag) + deduped.push(tag) + } + if (deduped.length > 8) return { tags: [], error: 'tags_validation_too_many' } + return { tags: deduped.sort(), error: null } +} +``` + +- [ ] **Step 3: Render the editor block inside the Basic fieldset** + +Insert after the `Public Remark` field: + +```tsx + + { + setTagsInput(e.target.value) + setTagsDirty(true) + }} + placeholder={t('tags_placeholder')} + type="text" + value={tagsInput} + /> +

{t('tags_hint')}

+
+``` + +- [ ] **Step 4: Sequential save in `handleSubmit`** + +Modify `handleSubmit`: + +```tsx +const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + const parsed = parseTagsInput(tagsInput) + if (parsed.error) { + toast.error(t(parsed.error as never)) + return + } + const payload: UpdateServerInput = { /* ...existing... */ } + try { + await mutation.mutateAsync(payload) + } catch (err) { + toast.error(err instanceof Error ? err.message : t('edit_failed')) + return + } + if (tagsDirty) { + try { + await tagsMutation.mutateAsync(parsed.tags) + } catch (err) { + // Revert the input so UX reflects the rollback; the PATCH stays committed. + if (initialTags) setTagsInput(initialTags.join(', ')) + toast.error(err instanceof Error ? err.message : t('tags_save_failed')) + return + } + } + toast.success(t('edit_success', { defaultValue: 'Server updated successfully' })) + onClose() +} +``` + +- [ ] **Step 5: Update the Save button disabled state** + +```tsx + +``` + +- [ ] **Step 6: Run full frontend tests** + +Run: `bun run --cwd apps/web test` +Expected: all PASS. + +Run: `bun x ultracite check apps/web/src/components/server/server-edit-dialog.tsx` +Run: `bun run --cwd apps/web typecheck` +Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/components/server/server-edit-dialog.tsx +git commit -m "feat(web): ServerEditDialog tags editor with sequential save" +``` + +--- + +### Task 23: Manual QA checklist + +**Files:** +- Create: `tests/servers/table-row-visual-redesign.md` + +- [ ] **Step 1: Write the checklist** + +```markdown +# Manual QA — Servers Table Row Visual Redesign + +**Spec:** `docs/superpowers/specs/2026-04-17-servers-table-row-visual-redesign-design.md` + +## Prereqs +- Admin login. +- At least 3 servers with mixed state: online + traffic configured, online + no traffic limit, offline. + +## Checks + +- [ ] `/servers?view=table` — first column renders a pulsing green dot for online, a muted grey dot for offline. +- [ ] The text-badge `Status` column is gone; the filter pill in the toolbar still offers `Online/Offline`. +- [ ] CPU cell: bar + `%` on top (colored by threshold), `{N} cores · load X.XX` below. If `cpu_cores` is not yet exposed (legacy agent), falls back to `load X.XX`. +- [ ] Memory cell: `{used} / {total} · swap X%`. Swap color reflects threshold. +- [ ] Disk cell: bar + `%`, `↓ {read} ↑ {write}` below. +- [ ] Network cell: traffic quota bar (uses `/api/traffic/overview`), `{used} / {limit} · ↓in ↑out`. If no quota configured, uses 1 TiB fallback. +- [ ] Offline row: metric cells show `—`, Network quota bar still visible, Uptime shows `offline` + `last seen Xh ago`. Tag chips still visible. +- [ ] Name cell: flag + name + UpgradeBadge on line 1, tag chips on line 2 when tags are set. +- [ ] Edit dialog: type `prod, db, web` → save → chips appear in the row. +- [ ] Edit dialog validations: 9 tags / 17-char tag / `has space` → error toast, no PUT fires. +- [ ] Edit dialog partial failure: set a name + invalid tags → only name change persists (expected because tags didn't change); set a name + valid tags but force PUT 500 (via browser devtools network throttling) → PATCH persists, tag input reverts, `tags_save_failed` toast fires. +- [ ] Breakpoints: network column hides below `lg:`, group/uptime hide below `xl:`. +- [ ] Viewport 1920×963 screenshot matches spec mockup proportions. +- [ ] `bun run test` green; `cargo test --workspace` green; `cargo clippy --workspace -- -D warnings` clean; `bun x ultracite check` clean; `bun run typecheck` clean. +``` + +- [ ] **Step 2: Commit** + +```bash +git add tests/servers/table-row-visual-redesign.md +git commit -m "test(servers): manual QA checklist for table row redesign" +``` + +--- + +### Task 24: Final green — all checks + +- [ ] **Step 1: Rust** + +```bash +cargo test --workspace +cargo clippy --workspace -- -D warnings +``` + +Expected: all PASS. + +- [ ] **Step 2: Frontend** + +```bash +bun run --cwd apps/web test +bun x ultracite check +bun run typecheck +``` + +Expected: all PASS. + +- [ ] **Step 3: Build** + +```bash +cargo build --workspace +cd apps/web && bun run build +``` + +Expected: both succeed. + +- [ ] **Step 4: Announce readiness** + +Open a PR with body summarizing Phase A (frontend visual refactor) and Phase B (tags backend + editor). Link to the spec at `docs/superpowers/specs/2026-04-17-servers-table-row-visual-redesign-design.md` and to this plan. + +--- + +## Notes for the Implementer + +- **Test file rewrite warning (Task 7):** the existing `index.cells.test.tsx` contains test data and regex constants that no longer apply once cells are rewritten. The instruction is to **replace the entire file content** — do not try to merge old and new assertions. +- **`UpgradeBadgeCell` relocation:** previously rendered inline inside the `name` column cell. After the rewrite it lives inside `NameCell` via the `rightSlot` prop. Don't let it go missing — it is the tiny "upgrade in progress" badge and removing it breaks an existing feature. +- **`MiniBar` removal:** confirm via `rg -n "MiniBar" apps/web/src` that no callers remain after the rewrite (the old `index.cells.tsx` was the sole exporter). +- **Phase A → Phase B ordering:** Phase A's frontend-only changes depend on the `ServerMetrics` TS interface being extended with optional `tags?` / `cpu_cores?`. Do NOT add those fields as required. Phase B's backend additions use `#[serde(default)]`, guaranteeing old WebSocket payloads parse cleanly even before the frontend is deployed. +- **Phase C (live tag propagation):** explicitly out of scope; do not add a `tags_changed` WS event in this plan. +- **i18n keys added:** `tags_label`, `tags_hint`, `tags_placeholder`, `tags_validation_too_many`, `tags_validation_too_long`, `tags_validation_invalid_char`, `tags_save_failed`, `last_seen_ago`, `offline_label`. Both `en` and `zh`. + +--- From bca52c459e76e676b7285153c56cbfd602dc9716 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 01:22:04 +0800 Subject: [PATCH 19/43] docs(superpowers): split large chunk into cells vs page-wiring chunks --- .../plans/2026-04-17-servers-table-row-visual-redesign.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-04-17-servers-table-row-visual-redesign.md b/docs/superpowers/plans/2026-04-17-servers-table-row-visual-redesign.md index 841b399c..08380e5f 100644 --- a/docs/superpowers/plans/2026-04-17-servers-table-row-visual-redesign.md +++ b/docs/superpowers/plans/2026-04-17-servers-table-row-visual-redesign.md @@ -455,7 +455,7 @@ git commit -m "feat(web): guard static array fields in ServerMetrics merge" --- -## Chunk 2: Cell primitives (Phase A rewrites) +## Chunk 2: Cell primitives & CPU/Memory/Disk cells (Phase A) ### Task 5: `` component (TDD) @@ -1107,6 +1107,8 @@ git commit -m "feat(web): DiskCell shows usage + disk I/O with lucide icons" --- +## Chunk 3: Network / Uptime / Name cells & page wiring (Phase A) + ### Task 11: `` rewrite (TDD) **Design note:** this is the only cell that takes external data (`TrafficOverviewItem | undefined`). We lift `useTrafficOverview` to the page level (`index.tsx`) and pass the per-row entry through a prop. @@ -1571,7 +1573,7 @@ git tag --annotate phase-a-complete -m "servers table visual refactor Phase A" --- -## Chunk 3: Backend — tags and cpu_cores on the wire (Phase B) +## Chunk 4: Backend — tags and cpu_cores on the wire (Phase B) ### Task 15: Add `tags` and `cpu_cores` to `ServerStatus` @@ -2089,7 +2091,7 @@ git commit -m "test(server): cover tags CRUD + RBAC and full_sync payload inclus --- -## Chunk 4: Frontend — tag editor in ServerEditDialog (Phase B) +## Chunk 5: Frontend — tag editor in ServerEditDialog (Phase B) ### Task 21: `use-server-tags.ts` hook (TDD) From 37009224c5733214da6796439de8105d8c7359a5 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 01:33:07 +0800 Subject: [PATCH 20/43] docs(superpowers): address plan review feedback across all 5 chunks --- ...04-17-servers-table-row-visual-redesign.md | 404 ++++++++++++++---- 1 file changed, 312 insertions(+), 92 deletions(-) diff --git a/docs/superpowers/plans/2026-04-17-servers-table-row-visual-redesign.md b/docs/superpowers/plans/2026-04-17-servers-table-row-visual-redesign.md index 08380e5f..a9dd3e2d 100644 --- a/docs/superpowers/plans/2026-04-17-servers-table-row-visual-redesign.md +++ b/docs/superpowers/plans/2026-04-17-servers-table-row-visual-redesign.md @@ -24,7 +24,7 @@ | `apps/web/src/lib/traffic.test.ts` | Create | Unit tests for the helper | | `apps/web/src/components/server/server-card.tsx` | Modify | Consume `computeTrafficQuota` instead of inlined logic | | `apps/web/src/hooks/use-servers-ws.ts` | Modify | Add `tags`, `cpu_cores` to `ServerMetrics`; extend `STATIC_FIELDS` + default guard to cover `[]` arrays | -| `apps/web/src/hooks/use-servers-ws.test.ts` | Create | Unit tests for `mergeServerUpdate` guard | +| `apps/web/src/hooks/use-servers-ws.test.ts` | Modify | Append new `describe('mergeServerUpdate static-fields guard')` block (file already exists with existing tests — do NOT replace) | | `apps/web/src/components/server/status-dot.tsx` | Create | `` — pulsing/muted dot | | `apps/web/src/components/server/status-dot.test.tsx` | Create | Unit test | | `apps/web/src/components/server/tag-chip.tsx` | Create | `` with stable-hash palette | @@ -94,9 +94,15 @@ Insert after the `"edit_failed"` line in `zh/servers.json`: - [ ] **Step 3: Verify JSON is valid** -Run: `bun run --cwd apps/web typecheck` (TypeScript resource files are validated by the build; if the project uses `bun x tsc --noEmit` it will also surface JSON parse errors through imports) +Run these commands (JSON parse errors are surfaced directly; typecheck alone will not catch malformed translation JSON): -Expected: exit 0. +```bash +node -e "JSON.parse(require('node:fs').readFileSync('apps/web/src/locales/en/servers.json','utf8')); console.log('en ok')" +node -e "JSON.parse(require('node:fs').readFileSync('apps/web/src/locales/zh/servers.json','utf8')); console.log('zh ok')" +bun run --cwd apps/web typecheck +``` + +Expected: `en ok`, `zh ok`, and typecheck exits 0. - [ ] **Step 4: Commit** @@ -308,16 +314,18 @@ git commit -m "refactor(web): ServerCard consumes shared computeTrafficQuota hel **Files:** - Modify: `apps/web/src/hooks/use-servers-ws.ts` -- Create: `apps/web/src/hooks/use-servers-ws.test.ts` +- Modify: `apps/web/src/hooks/use-servers-ws.test.ts` (file already exists; APPEND a new describe block — do NOT replace existing content) -- [ ] **Step 1: Write failing tests** +- [ ] **Step 1: Append failing tests** -Create `apps/web/src/hooks/use-servers-ws.test.ts`: +**IMPORTANT:** This test file already exists with `mergeServerUpdate` / `setServerOnlineStatus` / `setServerCapabilities` / `handleWsMessage upgrade messages` describes and a `makeServer` helper. Do **not** overwrite the file. Either: -```ts -import { describe, expect, it } from 'vitest' -import { mergeServerUpdate, type ServerMetrics } from './use-servers-ws' +- Option A: **Append** a new `describe('mergeServerUpdate static-fields guard', …)` block and a locally-named helper `baseServer` (to avoid colliding with the existing `makeServer`), OR +- Option B: Extend the existing `makeServer` to propagate `tags` / `cpu_cores` / `features` via overrides and use it in the new describe. + +The code below uses Option A (local `baseServer`) — paste it at the bottom of the existing file. Assume `describe`, `expect`, `it`, `mergeServerUpdate`, and `ServerMetrics` are already imported by the existing file; do NOT add duplicate imports. +```ts function baseServer(overrides: Partial = {}): ServerMetrics { return { id: 'srv-1', @@ -660,10 +668,10 @@ Because the cells are all inter-dependent and the existing tests reference the o - [ ] **Step 1: Back up intent — note the current exports** -The current `index.cells.tsx` exports: `MiniBar`, `CpuCell`, `MemoryCell`, `DiskCell`, `NetworkCell`. The rewrite must keep exporting `CpuCell`, `MemoryCell`, `DiskCell`, `NetworkCell` (consumed in `index.tsx`). `MiniBar` is no longer used elsewhere in `src/` (`rg -n "from.*index.cells" apps/web/src` will show only `index.tsx`); it will be removed. +The current `index.cells.tsx` exports: `MiniBar`, `CpuCell`, `MemoryCell`, `DiskCell`, `NetworkCell`. The rewrite must keep exporting all of these (`MiniBar` is refactored internally per the spec to delegate to `MetricBarRow`, but its public signature `{ pct: number; sub?: ReactNode }` is preserved for back-compat). Run: `rg -n "import.*MiniBar" apps/web/src` -Expected: empty (no other callers). +Expected: current call sites listed (document them; they must still compile after the refactor). - [ ] **Step 2: Write the failing test skeleton for `MetricBarRow`** @@ -791,8 +799,10 @@ export function MetricBarRow({ icon, pct, ariaLabel, valueClassName }: MetricBar const clamped = Math.min(100, Math.max(0, pct)) const colorBg = getBarColor(clamped) const colorText = getBarTextColor(clamped) + // Only apply role="img" when an ariaLabel is supplied; otherwise the role would be unnamed (a11y anti-pattern). + const imgProps = ariaLabel ? ({ role: 'img' as const, 'aria-label': ariaLabel }) : {} return ( -
+
{icon !== null && {icon}}
@@ -807,13 +817,26 @@ export function MetricBarRow({ icon, pct, ariaLabel, valueClassName }: MetricBar } ``` -The remainder of `index.cells.tsx` (the `CpuCell`, `MemoryCell`, `DiskCell`, `NetworkCell`, plus the new `UptimeCell` and `NameCell`) will be implemented in subsequent tasks on top of this primitive. For now, leave placeholders that preserve the existing column integration by temporarily re-exporting the old cells — **however**, to avoid breaking the route, also delete the old `MiniBar`-based implementations immediately and supply stubs that will be replaced in Tasks 8–13. +**a11y note:** all call sites in `CpuCell`/`MemoryCell`/`DiskCell`/`NetworkCell` below MUST pass `ariaLabel` so the metric bar announces (e.g. `ariaLabel={`${t('col_cpu')}: ${cpu}%`}`). Cells that omit `ariaLabel` will render without `role="img"`, which is acceptable but less informative for screen readers. + +The remainder of `index.cells.tsx` (the `CpuCell`, `MemoryCell`, `DiskCell`, `NetworkCell`, plus the new `UptimeCell` and `NameCell`) will be implemented in subsequent tasks on top of this primitive. For now, supply minimal stubs so the route still compiles; they will be replaced in Tasks 8–13. `MiniBar` is also preserved (as a thin wrapper around `MetricBarRow`) per the spec's back-compat rule. Append to `index.cells.tsx`: ```tsx +import type { ReactNode } from 'react' import type { ServerMetrics } from '@/hooks/use-servers-ws' +// Back-compat: MiniBar keeps its existing public signature but now delegates to MetricBarRow. +export function MiniBar({ pct, sub }: { pct: number; sub?: ReactNode }) { + return ( +
+ + {sub !== undefined &&
{sub}
} +
+ ) +} + // Temporary stubs — replaced in Tasks 8–13. export function CpuCell(_: { server: ServerMetrics }) { return } export function MemoryCell(_: { server: ServerMetrics }) { return } @@ -928,6 +951,10 @@ git commit -m "feat(web): CpuCell shows cores + load with Phase A fallback" ### Task 9: `` rewrite (TDD) +**Files:** +- Modify: `apps/web/src/routes/_authed/servers/index.cells.tsx` +- Modify: `apps/web/src/routes/_authed/servers/index.cells.test.tsx` + - [ ] **Step 1: Append failing tests** ```tsx @@ -1017,6 +1044,10 @@ git commit -m "feat(web): MemoryCell shows used/total + swap pct" ### Task 10: `` rewrite (TDD) +**Files:** +- Modify: `apps/web/src/routes/_authed/servers/index.cells.tsx` +- Modify: `apps/web/src/routes/_authed/servers/index.cells.test.tsx` + - [ ] **Step 1: Append failing tests** ```tsx @@ -1414,10 +1445,9 @@ git commit -m "feat(web): UptimeCell and NameCell with tags support" - [ ] **Step 1: Wire the new cells and add traffic-overview query** -At the top of the file, add: +At the top of the file, ensure these imports exist (merge into existing import lines rather than creating duplicates; `CircleDot` is already imported from `lucide-react` in this file — keep it): ```tsx -import { CircleDot } from 'lucide-react' import { useTrafficOverview } from '@/hooks/use-traffic-overview' import { StatusDot } from '@/components/server/status-dot' import { CpuCell, DiskCell, MemoryCell, NameCell, NetworkCell, UptimeCell } from './index.cells' @@ -1425,9 +1455,10 @@ import { CpuCell, DiskCell, MemoryCell, NameCell, NetworkCell, UptimeCell } from Remove the following imports (no longer used): - `StatusBadge` (replaced by `StatusDot`) -- `CircleDot` **keep** — still used for filter icon in the new dot column meta - Individual cell imports from `./index.cells` were already present; update to the new list +**Keep:** `CircleDot` from `lucide-react` — still used for the new dot column's `meta.icon`. + Inside `ServersListPage`, near the other queries: ```tsx @@ -1518,25 +1549,50 @@ In `index.tsx`, the `name` column cell becomes: cell: ({ row }) => } />, ``` -- [ ] **Step 2: Run the frontend test suite** +- [ ] **Step 2: Add regression tests for filter-pill migration + NameCell rightSlot** + +Append to `apps/web/src/routes/_authed/servers/index.cells.test.tsx`: + +```tsx +describe('NameCell rightSlot', () => { + it('renders the rightSlot next to the server name', () => { + render(} />) + expect(screen.getByTestId('slot')).toBeDefined() + expect(screen.getByText('web-01')).toBeDefined() + }) +}) +``` + +If an existing route-level test file (`apps/web/src/routes/_authed/servers/index.test.tsx`) exists, append a test that renders the page (with a mocked servers-WS dataset containing one online + one offline row) and asserts: + +```tsx +// Pseudocode — adapt to existing test harness: +// 1) Open the toolbar filter-pill for "status". +// 2) Assert both "online" and "offline" options appear (sourced from the new `status-dot` column's meta.options). +// 3) Click "offline" and assert only the offline row remains visible. +``` + +If no such route-level test exists, add a minimal unit test in `index.cells.test.tsx` that imports the `statusOptions` array used by the column meta and asserts it contains `{ value: 'online' }` and `{ value: 'offline' }` so the filter pill source is guarded. + +- [ ] **Step 3: Run the frontend test suite** Run: `bun run --cwd apps/web test` Expected: PASS (cells + existing route tests). -- [ ] **Step 3: Run ultracite** +- [ ] **Step 4: Run ultracite** Run: `bun x ultracite check apps/web/src/routes/_authed/servers/` Expected: no errors. -- [ ] **Step 4: Run typecheck** +- [ ] **Step 5: Run typecheck** Run: `bun run --cwd apps/web typecheck` Expected: exit 0. -- [ ] **Step 5: Commit** +- [ ] **Step 6: Commit** ```bash -git add apps/web/src/routes/_authed/servers/index.tsx apps/web/src/routes/_authed/servers/index.cells.tsx +git add apps/web/src/routes/_authed/servers/index.tsx apps/web/src/routes/_authed/servers/index.cells.tsx apps/web/src/routes/_authed/servers/index.cells.test.tsx git commit -m "feat(web): servers table adopts new cells with status-dot column" ``` @@ -1687,12 +1743,18 @@ In `crates/server/src/service/mod.rs`, add: pub mod server_tag; ``` -- [ ] **Step 2: Write the unit tests first** +- [ ] **Step 2: Write the full service module (implementation + unit tests) in one pass** + +**Wire contract reminder:** `set_tags` must only mutate the `server_tag` table. It must NOT touch `servers.updated_at` — per spec, editing tags should never make an offline server appear to have just phoned home. The code below respects this rule by using only `server_tag::Entity` operations inside the transaction. -Create `crates/server/src/service/server_tag.rs` with the tests at the bottom: +Create `crates/server/src/service/server_tag.rs` (final import block; no step-3 fix-up later): ```rust -use sea_orm::{DatabaseConnection, EntityTrait, QueryOrder, Set, TransactionTrait}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, + TransactionTrait, +}; + use crate::entity::server_tag; use crate::error::AppError; @@ -1799,22 +1861,12 @@ mod tests { } ``` -- [ ] **Step 3: Add the missing `ColumnTrait` import (sea-orm filtering)** - -At the top of `server_tag.rs`: - -```rust -use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait}; -``` - -(Replace the earlier imports if duplicate.) - -- [ ] **Step 4: Run tests** +- [ ] **Step 3: Run tests** Run: `cargo test -p serverbee-server --lib service::server_tag` Expected: PASS (6 unit tests). -- [ ] **Step 5: Commit** +- [ ] **Step 4: Commit** ```bash git add crates/server/src/service/server_tag.rs crates/server/src/service/mod.rs @@ -1940,18 +1992,46 @@ git commit -m "feat(server): add /api/servers/:id/tags read/write routes" **Files:** - Create: `crates/server/tests/server_tags.rs` +- Modify: `crates/server/tests/integration.rs` (append one new `#[tokio::test]` — do not replace existing content) -- [ ] **Step 1: Reuse the test helpers** +> **Important:** Rust integration test files compile as separate binaries and do not share `mod` code. Rather than duplicate helpers, this task: +> 1. Adds **all** new test cases (REST CRUD, admin, member RBAC, validation, browser-WS full_sync) as new `#[tokio::test]` functions inside the existing `integration.rs` so they can reuse `start_test_server`, `http_client`, `login_admin`, `register_agent`. +> 2. Leaves `crates/server/tests/server_tags.rs` as a tiny stub that re-verifies the service module compiles in isolation — OR it is skipped entirely and all tests live in `integration.rs`. **Prefer the latter** (all tests go into `integration.rs`) to minimize helper duplication. -The existing `crates/server/tests/integration.rs` defines `start_test_server`, `http_client`, `login_admin`, `register_agent`. Because those helpers are `async fn` in a separate test binary, copy-paste the minimal set needed into the new `server_tags.rs` (Rust integration tests don't share modules across files). +- [ ] **Step 1: Add a `register_member` helper to `integration.rs`** -Create `crates/server/tests/server_tags.rs`: +Locate the existing `login_admin` helper (line ~124) and append the following below it: ```rust -// Copy `start_test_server`, `http_client`, `login_admin`, `register_agent` verbatim -// from tests/integration.rs. (Integration test binaries don't share modules.) +/// Register a member-role user with a fresh client; returns (client_cookies_stored, username). +async fn register_member(base_url: &str) -> reqwest::Client { + let client = http_client(); + let username = format!("member-{}", uuid::Uuid::new_v4().simple()); + let resp = client + .post(format!("{}/api/auth/register", base_url)) + .json(&json!({ "username": username, "password": "memberpass" })) + .send() + .await + .expect("register member failed"); + assert_eq!(resp.status(), 200, "member register should succeed"); + let resp = client + .post(format!("{}/api/auth/login", base_url)) + .json(&json!({ "username": username, "password": "memberpass" })) + .send() + .await + .expect("login member failed"); + assert_eq!(resp.status(), 200, "member login should succeed"); + client +} +``` -// ...helpers above... +**Verify before adding:** this helper assumes `/api/auth/register` exists for user registration. If the project uses a different pattern (e.g. admin-created users), adjust accordingly by reading `crates/server/src/router/api/auth.rs` and mirroring the canonical user-creation flow. If no public registration exists, create the member via a direct SQL insert using the same pattern as `AuthService::init_admin`. + +- [ ] **Step 2: Append the tags tests directly to `integration.rs`** + +At the bottom of `crates/server/tests/integration.rs`, append: + +```rust #[tokio::test] async fn unauthenticated_get_tags_returns_401() { @@ -2035,49 +2115,187 @@ async fn admin_put_rejects_invalid_char() { .unwrap(); assert_eq!(resp.status(), 400); } -``` -Add a second test binary file for full_sync inclusion; but to minimize new files, extend the test above with a full_sync WebSocket open and assertion. Alternatively, use the existing `integration.rs` test binary — **prefer** adding a test to `integration.rs` (same binary, shared helpers) rather than duplicating helpers. +#[tokio::test] +async fn admin_put_rejects_too_long_tag() { + let (base_url, _tmp) = start_test_server().await; + let admin = http_client(); + login_admin(&admin, &base_url).await; + let (server_id, _token) = register_agent(&admin, &base_url).await; + let seventeen = "a".repeat(17); + let resp = admin + .put(format!("{}/api/servers/{server_id}/tags", base_url)) + .json(&serde_json::json!({"tags": [seventeen]})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400); +} -Simpler: **put the full_sync assertion directly in `integration.rs`** (not in the new `server_tags.rs` file). Add to `integration.rs`: +#[tokio::test] +async fn member_get_tags_returns_200() { + let (base_url, _tmp) = start_test_server().await; + let admin = http_client(); + login_admin(&admin, &base_url).await; + let (server_id, _token) = register_agent(&admin, &base_url).await; + admin + .put(format!("{}/api/servers/{server_id}/tags", base_url)) + .json(&serde_json::json!({"tags": ["prod"]})) + .send() + .await + .unwrap(); + + let member = register_member(&base_url).await; + let resp = member + .get(format!("{}/api/servers/{server_id}/tags", base_url)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + let data: Vec = serde_json::from_value(body["data"].clone()).unwrap(); + assert_eq!(data, vec!["prod"]); +} + +#[tokio::test] +async fn member_put_tags_returns_403() { + let (base_url, _tmp) = start_test_server().await; + let admin = http_client(); + login_admin(&admin, &base_url).await; + let (server_id, _token) = register_agent(&admin, &base_url).await; + + let member = register_member(&base_url).await; + let resp = member + .put(format!("{}/api/servers/{server_id}/tags", base_url)) + .json(&serde_json::json!({"tags": ["prod"]})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 403); +} -```rust #[tokio::test] async fn browser_ws_full_sync_includes_tags_and_cpu_cores() { + // This test opens a browser WebSocket and asserts the first `full_sync` frame + // now carries `tags` and `cpu_cores` fields per the Phase B wire contract. + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + let (base_url, _tmp) = start_test_server().await; let admin = http_client(); login_admin(&admin, &base_url).await; let (server_id, _token) = register_agent(&admin, &base_url).await; - // Seed tags - admin + // Seed tags via the REST endpoint. + let resp = admin .put(format!("{}/api/servers/{server_id}/tags", base_url)) .json(&serde_json::json!({"tags": ["alpha", "beta"]})) .send() .await .unwrap(); + assert_eq!(resp.status(), 200); - // Open the browser WS; use the existing session cookie from `admin` - // (extract cookie; copy the pattern used for other browser WS tests in this file). - // The first message should be `full_sync` and each server should include `tags`. + // Extract the session cookie from the admin reqwest client. reqwest's cookie jar + // is not directly accessible, so re-issue a GET and capture the Set-Cookie / Cookie + // header from a fresh login response. + // + // Simpler: login through a fresh client that exposes the raw Set-Cookie. + let raw_client = reqwest::Client::builder() + .cookie_store(false) + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap(); + let login_resp = raw_client + .post(format!("{}/api/auth/login", base_url)) + .json(&serde_json::json!({ "username": "admin", "password": "testpass" })) + .send() + .await + .unwrap(); + let set_cookie = login_resp + .headers() + .get("set-cookie") + .expect("set-cookie header") + .to_str() + .unwrap() + .to_string(); + // Take everything up to the first ';' — that's the cookie name=value pair. + let cookie_value = set_cookie.split(';').next().unwrap().to_string(); + + // Build ws URL: http://127.0.0.1:PORT/api/ws/servers → ws://127.0.0.1:PORT/api/ws/servers + let ws_url = base_url.replace("http://", "ws://") + "/api/ws/servers"; + let mut request = ws_url.into_client_request().unwrap(); + request.headers_mut().insert( + "Cookie", + tokio_tungstenite::tungstenite::http::HeaderValue::from_str(&cookie_value).unwrap(), + ); + + let (mut ws, _resp) = tokio_tungstenite::connect_async(request) + .await + .expect("browser ws should connect"); - // ...copy-paste the pattern from an existing browser-ws test in this file and - // assert: json["servers"][0]["tags"] == ["alpha","beta"] and json["servers"][0]["cpu_cores"] is null or an integer. + // Read the first frame → must be FullSync. + use futures_util::StreamExt; + let msg = tokio::time::timeout(std::time::Duration::from_secs(5), ws.next()) + .await + .expect("ws recv timeout") + .expect("ws closed") + .expect("ws error"); + let text = msg.to_text().unwrap().to_string(); + let json: serde_json::Value = serde_json::from_str(&text).unwrap(); + + // FullSync shape: { "type": "full_sync", "servers": [ { ..., "tags": [...], "cpu_cores": null|int } ] } + let servers = json["servers"].as_array().expect("servers array present"); + let ours = servers + .iter() + .find(|s| s["id"].as_str() == Some(&server_id)) + .expect("our server present in full_sync"); + assert_eq!( + ours["tags"].as_array().unwrap().iter().map(|t| t.as_str().unwrap()).collect::>(), + vec!["alpha", "beta"], + ); + // cpu_cores is Option — either JSON null or an integer. Both are accepted. + assert!( + ours.get("cpu_cores").map_or(false, |v| v.is_null() || v.is_i64()), + "cpu_cores field must be present (null or integer), got {:?}", + ours.get("cpu_cores"), + ); } ``` -(The reason for splitting: integration.rs already has the browser-ws helpers; the new server_tags.rs only exercises REST. If browser-ws helpers are not already present in integration.rs, inline the `tokio_tungstenite::connect_async` call here with the session cookie header.) +- [ ] **Step 3: Stub `server_tags.rs` (optional)** -- [ ] **Step 2: Run** +If the project convention prefers a per-feature integration test binary, create a minimal `crates/server/tests/server_tags.rs`: + +```rust +// All tests for the /api/servers/:id/tags feature live in tests/integration.rs +// so they can share the `start_test_server`, `login_admin`, `register_agent`, and +// `register_member` helpers without duplication. This file is intentionally empty. +``` + +Otherwise, omit the file entirely and update the chunk's File Map. + +- [ ] **Step 4: Run** + +```bash +cargo test -p serverbee-server --test integration unauthenticated_get_tags_returns_401 +cargo test -p serverbee-server --test integration unauthenticated_put_tags_returns_401 +cargo test -p serverbee-server --test integration admin_put_then_get_roundtrips +cargo test -p serverbee-server --test integration admin_put_rejects_too_many_tags +cargo test -p serverbee-server --test integration admin_put_rejects_invalid_char +cargo test -p serverbee-server --test integration admin_put_rejects_too_long_tag +cargo test -p serverbee-server --test integration member_get_tags_returns_200 +cargo test -p serverbee-server --test integration member_put_tags_returns_403 +cargo test -p serverbee-server --test integration browser_ws_full_sync_includes_tags_and_cpu_cores +``` -Run: `cargo test -p serverbee-server --test server_tags` -Run: `cargo test -p serverbee-server --test integration browser_ws_full_sync_includes_tags_and_cpu_cores` Expected: all PASS. -- [ ] **Step 3: Commit** +- [ ] **Step 5: Commit** ```bash -git add crates/server/tests/server_tags.rs crates/server/tests/integration.rs +git add crates/server/tests/integration.rs +# if you created the stub: +# git add crates/server/tests/server_tags.rs git commit -m "test(server): cover tags CRUD + RBAC and full_sync payload inclusion" ``` @@ -2097,39 +2315,41 @@ git commit -m "test(server): cover tags CRUD + RBAC and full_sync payload inclus **Files:** - Create: `apps/web/src/hooks/use-server-tags.ts` -- Create: `apps/web/src/hooks/use-server-tags.test.ts` +- Create: `apps/web/src/hooks/use-server-tags.test.tsx` (MUST use `.tsx` — the test body contains JSX for the `QueryClientProvider` wrapper) + +> **Dependency note:** `msw` is NOT listed in `apps/web/package.json`, and the existing test setup (`apps/web/src/test/setup.ts`) only registers `@testing-library/jest-dom`. Do NOT add `msw` for a single hook test — use `vi.spyOn(globalThis, 'fetch')` instead (same pattern used elsewhere in the repo's web tests). - [ ] **Step 1: Write failing tests** -```ts +Create `apps/web/src/hooks/use-server-tags.test.tsx` with the body below. Note the `.tsx` extension — required because of the JSX inside `wrapper`. + +```tsx import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { renderHook, waitFor } from '@testing-library/react' -import { http, HttpResponse } from 'msw' -import { setupServer } from 'msw/node' -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { useServerTags, useUpdateServerTags } from './use-server-tags' -const server = setupServer() -beforeAll(() => server.listen()) -afterEach(() => server.resetHandlers()) -afterAll(() => server.close()) - -function wrapper() { +function harness() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - return { - qc, - wrapper: ({ children }: { children: React.ReactNode }) => ( - {children} - ) - } + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + return { qc, Wrapper } } +afterEach(() => { + vi.restoreAllMocks() +}) + describe('useServerTags', () => { it('fetches GET /api/servers/:id/tags', async () => { - server.use( - http.get('/api/servers/srv-1/tags', () => HttpResponse.json({ data: ['a', 'b'] })) + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ data: ['a', 'b'] }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) ) - const { wrapper: Wrapper } = wrapper() + const { Wrapper } = harness() const { result } = renderHook(() => useServerTags('srv-1'), { wrapper: Wrapper }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data).toEqual(['a', 'b']) @@ -2137,14 +2357,15 @@ describe('useServerTags', () => { }) describe('useUpdateServerTags', () => { - it('PUTs tags and optimistically patches both caches', async () => { - server.use( - http.put('/api/servers/srv-1/tags', async ({ request }) => { - const body = (await request.json()) as { tags: string[] } - return HttpResponse.json({ data: body.tags.toSorted() }) + it('PUTs tags and patches both caches on success', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (_input, init) => { + const body = JSON.parse((init as RequestInit).body as string) as { tags: string[] } + return new Response(JSON.stringify({ data: [...body.tags].sort() }), { + status: 200, + headers: { 'Content-Type': 'application/json' } }) - ) - const { qc, wrapper: Wrapper } = wrapper() + }) + const { qc, Wrapper } = harness() qc.setQueryData(['server-tags', 'srv-1'], ['old']) qc.setQueryData(['servers'], [{ id: 'srv-1', tags: ['old'] }]) const { result } = renderHook(() => useUpdateServerTags('srv-1'), { wrapper: Wrapper }) @@ -2155,12 +2376,10 @@ describe('useUpdateServerTags', () => { }) ``` -- [ ] **Step 2: Run (fail — module missing; msw may also require setup)** +- [ ] **Step 2: Run (fail — module missing)** Run: `bun run --cwd apps/web test use-server-tags` -Expected: FAIL. - -If `msw/node` isn't installed, skip the MSW-based test and use a lightweight `vi.fn()` for `fetch` instead (the existing test setup probably uses this pattern — check `apps/web/src/test-setup.ts` and mirror it). +Expected: FAIL (module not found). - [ ] **Step 3: Implement the hook** @@ -2199,7 +2418,7 @@ export function useUpdateServerTags(serverId: string) { - [ ] **Step 5: Commit** ```bash -git add apps/web/src/hooks/use-server-tags.ts apps/web/src/hooks/use-server-tags.test.ts +git add apps/web/src/hooks/use-server-tags.ts apps/web/src/hooks/use-server-tags.test.tsx git commit -m "feat(web): useServerTags + useUpdateServerTags with optimistic cache" ``` @@ -2361,7 +2580,8 @@ git commit -m "feat(web): ServerEditDialog tags editor with sequential save" - [ ] Name cell: flag + name + UpgradeBadge on line 1, tag chips on line 2 when tags are set. - [ ] Edit dialog: type `prod, db, web` → save → chips appear in the row. - [ ] Edit dialog validations: 9 tags / 17-char tag / `has space` → error toast, no PUT fires. -- [ ] Edit dialog partial failure: set a name + invalid tags → only name change persists (expected because tags didn't change); set a name + valid tags but force PUT 500 (via browser devtools network throttling) → PATCH persists, tag input reverts, `tags_save_failed` toast fires. +- [ ] Edit dialog client-validation blocks submit: set a name + client-invalid tags (e.g. `bad space` or 17-char tag) → a validation toast fires, **no PATCH and no PUT are issued** (verify in browser devtools Network tab), the dialog stays open. +- [ ] Edit dialog partial failure (PATCH ok, PUT fails): set a name + valid tags, force `PUT /api/servers/:id/tags` to return 500 (e.g. via browser devtools "block request URL" or a mock worker) → PATCH persists (server list shows the new name after dialog closes), tag input reverts to the last-known tags, `tags_save_failed` toast fires, dialog stays open. - [ ] Breakpoints: network column hides below `lg:`, group/uptime hide below `xl:`. - [ ] Viewport 1920×963 screenshot matches spec mockup proportions. - [ ] `bun run test` green; `cargo test --workspace` green; `cargo clippy --workspace -- -D warnings` clean; `bun x ultracite check` clean; `bun run typecheck` clean. From bc03d99adec5193ad50123f82a2e502e6f6f3ca9 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 08:47:49 +0800 Subject: [PATCH 21/43] feat(common): extend ServerStatus with tags and cpu_cores --- crates/common/src/types.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index a8bc2bfa..6e826c8d 100644 --- a/crates/common/src/types.rs +++ b/crates/common/src/types.rs @@ -172,6 +172,10 @@ pub struct ServerStatus { pub disk_read_bytes_per_sec: u64, #[serde(default)] pub disk_write_bytes_per_sec: u64, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub cpu_cores: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] From 940744aec41507d64e7b2bf50111be16a524c779 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 08:48:36 +0800 Subject: [PATCH 22/43] feat(web): add i18n keys for servers table tags and uptime labels --- apps/web/src/locales/en/servers.json | 9 +++++++++ apps/web/src/locales/zh/servers.json | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/apps/web/src/locales/en/servers.json b/apps/web/src/locales/en/servers.json index 9dfdea9a..7bebb56a 100644 --- a/apps/web/src/locales/en/servers.json +++ b/apps/web/src/locales/en/servers.json @@ -133,6 +133,15 @@ "edit_limit_upload": "Upload Only", "edit_limit_download": "Download Only", "edit_failed": "Failed to update server", + "tags_label": "Tags", + "tags_hint": "Comma or space separated, up to 8 tags, 16 chars each", + "tags_placeholder": "prod, db, web", + "tags_validation_too_many": "At most 8 tags", + "tags_validation_too_long": "Each tag must be ≤16 chars", + "tags_validation_invalid_char": "Only letters, digits, and ._- are allowed", + "tags_save_failed": "Failed to save tags", + "last_seen_ago": "last seen {{time}}", + "offline_label": "offline", "edit_billing_start_day": "Billing Start Day", "edit_billing_start_day_placeholder": "Leave empty for natural month (1st)", diff --git a/apps/web/src/locales/zh/servers.json b/apps/web/src/locales/zh/servers.json index 023be7c1..09ad3bbe 100644 --- a/apps/web/src/locales/zh/servers.json +++ b/apps/web/src/locales/zh/servers.json @@ -133,6 +133,15 @@ "edit_limit_upload": "仅上传", "edit_limit_download": "仅下载", "edit_failed": "更新服务器失败", + "tags_label": "标签", + "tags_hint": "使用逗号或空格分隔,最多 8 个标签,每个 16 字符以内", + "tags_placeholder": "prod, db, web", + "tags_validation_too_many": "最多 8 个标签", + "tags_validation_too_long": "单个标签最多 16 字符", + "tags_validation_invalid_char": "只允许字母、数字、`._-`", + "tags_save_failed": "保存标签失败", + "last_seen_ago": "最后上线 {{time}}", + "offline_label": "离线", "edit_billing_start_day": "计费起始日", "edit_billing_start_day_placeholder": "留空则使用自然月(1日)", From 8e95aec4a3b35f6b885dfdfb6485abb55d2e8c38 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 08:49:21 +0800 Subject: [PATCH 23/43] feat(web): add shared traffic quota helper --- apps/web/src/lib/traffic.test.ts | 87 ++++++++++++++++++++++++++++++++ apps/web/src/lib/traffic.ts | 24 +++++++++ 2 files changed, 111 insertions(+) create mode 100644 apps/web/src/lib/traffic.test.ts create mode 100644 apps/web/src/lib/traffic.ts diff --git a/apps/web/src/lib/traffic.test.ts b/apps/web/src/lib/traffic.test.ts new file mode 100644 index 00000000..6111d7db --- /dev/null +++ b/apps/web/src/lib/traffic.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import type { TrafficOverviewItem } from '@/hooks/use-traffic-overview' +import { computeTrafficQuota, DEFAULT_TRAFFIC_LIMIT_BYTES } from './traffic' + +const GB = 1024 ** 3 +const TB = 1024 ** 4 + +function entry(overrides: Partial): TrafficOverviewItem { + return { + billing_cycle: null, + cycle_in: 0, + cycle_out: 0, + days_remaining: null, + name: 'srv', + percent_used: null, + server_id: 'srv-1', + traffic_limit: null, + ...overrides + } +} + +describe('computeTrafficQuota', () => { + it('uses cycle_in + cycle_out when entry present', () => { + const result = computeTrafficQuota({ + entry: entry({ cycle_in: 50 * GB, cycle_out: 43.2 * GB, traffic_limit: 1 * TB }), + netInTransfer: 999, + netOutTransfer: 999 + }) + expect(result.used).toBe(50 * GB + 43.2 * GB) + expect(result.limit).toBe(1 * TB) + expect(result.pct).toBeCloseTo(((50 + 43.2) / 1024) * 100, 1) + }) + + it('falls back to net_in_transfer + net_out_transfer when entry is undefined', () => { + const result = computeTrafficQuota({ + entry: undefined, + netInTransfer: 10 * GB, + netOutTransfer: 5 * GB + }) + expect(result.used).toBe(15 * GB) + expect(result.limit).toBe(DEFAULT_TRAFFIC_LIMIT_BYTES) + expect(DEFAULT_TRAFFIC_LIMIT_BYTES).toBe(TB) + }) + + it('falls back to default limit when traffic_limit is null', () => { + const result = computeTrafficQuota({ + entry: entry({ traffic_limit: null }), + netInTransfer: 0, + netOutTransfer: 0 + }) + expect(result.limit).toBe(DEFAULT_TRAFFIC_LIMIT_BYTES) + }) + + it('falls back to default limit when traffic_limit <= 0', () => { + const result = computeTrafficQuota({ + entry: entry({ traffic_limit: 0 }), + netInTransfer: 0, + netOutTransfer: 0 + }) + expect(result.limit).toBe(DEFAULT_TRAFFIC_LIMIT_BYTES) + + const negative = computeTrafficQuota({ + entry: entry({ traffic_limit: -1 }), + netInTransfer: 0, + netOutTransfer: 0 + }) + expect(negative.limit).toBe(DEFAULT_TRAFFIC_LIMIT_BYTES) + }) + + it('clamps pct to 100 when used exceeds limit', () => { + const result = computeTrafficQuota({ + entry: entry({ cycle_in: 2 * TB, cycle_out: 0, traffic_limit: 1 * TB }), + netInTransfer: 0, + netOutTransfer: 0 + }) + expect(result.pct).toBe(100) + }) + + it('returns 0 pct when limit resolves to the default and used is 0', () => { + const result = computeTrafficQuota({ + entry: undefined, + netInTransfer: 0, + netOutTransfer: 0 + }) + expect(result.pct).toBe(0) + }) +}) diff --git a/apps/web/src/lib/traffic.ts b/apps/web/src/lib/traffic.ts new file mode 100644 index 00000000..9f3c4203 --- /dev/null +++ b/apps/web/src/lib/traffic.ts @@ -0,0 +1,24 @@ +import type { TrafficOverviewItem } from '@/hooks/use-traffic-overview' + +export const DEFAULT_TRAFFIC_LIMIT_BYTES = 1024 ** 4 + +export interface TrafficQuota { + used: number + limit: number + pct: number +} + +interface ComputeInput { + entry: TrafficOverviewItem | undefined + netInTransfer: number + netOutTransfer: number +} + +export function computeTrafficQuota({ entry, netInTransfer, netOutTransfer }: ComputeInput): TrafficQuota { + const used = entry ? entry.cycle_in + entry.cycle_out : netInTransfer + netOutTransfer + const rawLimit = entry?.traffic_limit ?? null + const limit = rawLimit != null && rawLimit > 0 ? rawLimit : DEFAULT_TRAFFIC_LIMIT_BYTES + const rawPct = limit > 0 ? (used / limit) * 100 : 0 + const pct = Math.min(rawPct, 100) + return { used, limit, pct } +} From 644dce54ff3be00e90a1c6b81987b84e94a3b444 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 08:50:05 +0800 Subject: [PATCH 24/43] refactor(web): ServerCard consumes shared computeTrafficQuota helper --- .../web/src/components/server/server-card.tsx | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/server/server-card.tsx b/apps/web/src/components/server/server-card.tsx index bb4bb19b..956b14fb 100644 --- a/apps/web/src/components/server/server-card.tsx +++ b/apps/web/src/components/server/server-card.tsx @@ -11,6 +11,7 @@ import type { ServerMetrics } from '@/hooks/use-servers-ws' import { useTrafficOverview } from '@/hooks/use-traffic-overview' import { getLatencyBarColor, isLatencyFailure } from '@/lib/network-latency-constants' import { latencyColorClass } from '@/lib/network-types' +import { computeTrafficQuota } from '@/lib/traffic' import { countryCodeToFlag, formatBytes, formatSpeed, formatUptime } from '@/lib/utils' import { useUpgradeJobsStore } from '@/stores/upgrade-jobs-store' import { buildServerCardNetworkState, type ServerCardMetricPoint } from './server-card-network-data' @@ -105,8 +106,6 @@ function formatLoad(load: number): string { return load.toFixed(2) } -const DEFAULT_TRAFFIC_LIMIT_BYTES = 1024 ** 4 // 1 TiB fallback when no quota configured - function averageLossRatio(point: ServerCardMetricPoint): number | null { if (point.targets.length === 0) { return null @@ -184,15 +183,15 @@ const ServerCardInner = ({ server }: ServerCardProps) => { ) const trafficEntry = trafficOverview?.find((entry) => entry.server_id === server.id) - const trafficUsed = trafficEntry - ? trafficEntry.cycle_in + trafficEntry.cycle_out - : server.net_in_transfer + server.net_out_transfer - const trafficLimit = - trafficEntry?.traffic_limit != null && trafficEntry.traffic_limit > 0 - ? trafficEntry.traffic_limit - : DEFAULT_TRAFFIC_LIMIT_BYTES - const trafficRawPct = trafficLimit > 0 ? (trafficUsed / trafficLimit) * 100 : 0 - const trafficRingPct = Math.min(trafficRawPct, 100) + const { + used: trafficUsed, + limit: trafficLimit, + pct: trafficRingPct + } = computeTrafficQuota({ + entry: trafficEntry, + netInTransfer: server.net_in_transfer, + netOutTransfer: server.net_out_transfer + }) const trafficDaysRemaining = trafficEntry?.days_remaining ?? null return ( From 755abf297623fa17aca285287b23ac10e1db1bf5 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 08:50:29 +0800 Subject: [PATCH 25/43] feat(server): include tags and cpu_cores in ServerStatus full_sync --- crates/server/src/router/ws/browser.rs | 16 ++++++++++++++++ crates/server/src/service/agent_manager.rs | 2 ++ 2 files changed, 18 insertions(+) diff --git a/crates/server/src/router/ws/browser.rs b/crates/server/src/router/ws/browser.rs index 270fbba7..a3a59130 100644 --- a/crates/server/src/router/ws/browser.rs +++ b/crates/server/src/router/ws/browser.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use axum::Router; @@ -7,7 +8,9 @@ use axum::http::HeaderMap; use axum::response::{IntoResponse, Response}; use axum::routing::get; use futures_util::{SinkExt, StreamExt}; +use sea_orm::{EntityTrait, QueryOrder}; +use crate::entity::server_tag; use crate::service::agent_manager::aggregate_disk_io; use crate::service::auth::AuthService; use crate::service::server::ServerService; @@ -260,6 +263,17 @@ async fn build_full_sync(state: &Arc) -> BrowserMessage { } }; + let tags_rows = server_tag::Entity::find() + .order_by_asc(server_tag::Column::ServerId) + .order_by_asc(server_tag::Column::Tag) + .all(&state.db) + .await + .unwrap_or_default(); + let mut tags_by_server: HashMap> = HashMap::new(); + for row in tags_rows { + tags_by_server.entry(row.server_id).or_default().push(row.tag); + } + let statuses: Vec = servers .into_iter() .map(|server| { @@ -348,6 +362,8 @@ async fn build_full_sync(state: &Arc) -> BrowserMessage { features: serde_json::from_str(&server.features).unwrap_or_default(), disk_read_bytes_per_sec, disk_write_bytes_per_sec, + tags: tags_by_server.remove(&server.id).unwrap_or_default(), + cpu_cores: server.cpu_cores, } }) .collect(); diff --git a/crates/server/src/service/agent_manager.rs b/crates/server/src/service/agent_manager.rs index aee15e74..e8441c96 100644 --- a/crates/server/src/service/agent_manager.rs +++ b/crates/server/src/service/agent_manager.rs @@ -231,6 +231,8 @@ impl AgentManager { features: vec![], disk_read_bytes_per_sec, disk_write_bytes_per_sec, + tags: Vec::new(), + cpu_cores: None, }; let _ = self.browser_tx.send(BrowserMessage::Update { From b1647bd87e6825a14aa6f192e4e0f209127ac87c Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 08:51:13 +0800 Subject: [PATCH 26/43] feat(web): guard static array fields in ServerMetrics merge --- apps/web/src/hooks/use-servers-ws.test.ts | 70 +++++++++++++++++++++++ apps/web/src/hooks/use-servers-ws.ts | 11 +++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/use-servers-ws.test.ts b/apps/web/src/hooks/use-servers-ws.test.ts index f9c2087e..b83ca196 100644 --- a/apps/web/src/hooks/use-servers-ws.test.ts +++ b/apps/web/src/hooks/use-servers-ws.test.ts @@ -209,3 +209,73 @@ describe('handleWsMessage upgrade messages', () => { expect(job?.finished_at).not.toBeNull() }) }) + +function baseServer(overrides: Partial = {}): ServerMetrics { + return { + id: 'srv-1', + name: 'srv', + online: true, + country_code: null, + cpu: 0, + cpu_name: null, + cpu_cores: null, + disk_read_bytes_per_sec: 0, + disk_total: 0, + disk_used: 0, + disk_write_bytes_per_sec: 0, + group_id: null, + last_active: 0, + load1: 0, + load5: 0, + load15: 0, + mem_total: 0, + mem_used: 0, + net_in_speed: 0, + net_in_transfer: 0, + net_out_speed: 0, + net_out_transfer: 0, + os: null, + process_count: 0, + region: null, + swap_total: 0, + swap_used: 0, + tags: [], + tcp_conn: 0, + udp_conn: 0, + uptime: 0, + features: [], + ...overrides + } +} + +describe('mergeServerUpdate static-fields guard', () => { + it('preserves prior tags when incoming frame carries tags: []', () => { + const prev = [baseServer({ tags: ['prod', 'web'] })] + const incoming = [baseServer({ tags: [], cpu: 42 })] + const result = mergeServerUpdate(prev, incoming) + expect(result[0].tags).toEqual(['prod', 'web']) + expect(result[0].cpu).toBe(42) + }) + + it('preserves prior features when incoming frame carries features: []', () => { + const prev = [baseServer({ features: ['docker'] })] + const incoming = [baseServer({ features: [], cpu: 10 })] + const result = mergeServerUpdate(prev, incoming) + expect(result[0].features).toEqual(['docker']) + expect(result[0].cpu).toBe(10) + }) + + it('preserves prior cpu_cores when incoming frame carries cpu_cores: null', () => { + const prev = [baseServer({ cpu_cores: 8 })] + const incoming = [baseServer({ cpu_cores: null, cpu: 5 })] + const result = mergeServerUpdate(prev, incoming) + expect(result[0].cpu_cores).toBe(8) + }) + + it('overwrites prior tags with non-empty incoming array', () => { + const prev = [baseServer({ tags: ['old'] })] + const incoming = [baseServer({ tags: ['new-a', 'new-b'] })] + const result = mergeServerUpdate(prev, incoming) + expect(result[0].tags).toEqual(['new-a', 'new-b']) + }) +}) diff --git a/apps/web/src/hooks/use-servers-ws.ts b/apps/web/src/hooks/use-servers-ws.ts index bac91f66..040ff6a6 100644 --- a/apps/web/src/hooks/use-servers-ws.ts +++ b/apps/web/src/hooks/use-servers-ws.ts @@ -16,6 +16,7 @@ interface ServerMetrics { capabilities?: number country_code: string | null cpu: number + cpu_cores?: number | null cpu_name: string | null disk_read_bytes_per_sec: number disk_total: number @@ -43,6 +44,7 @@ interface ServerMetrics { region: string | null swap_total: number swap_used: number + tags?: string[] tcp_conn: number udp_conn: number uptime: number @@ -89,10 +91,13 @@ const STATIC_FIELDS = new Set([ 'swap_total', 'disk_total', 'cpu_name', + 'cpu_cores', 'os', 'region', 'country_code', - 'group_id' + 'group_id', + 'tags', + 'features' ]) export function mergeServerUpdate(prev: ServerMetrics[], incoming: ServerMetrics[]): ServerMetrics[] { @@ -102,7 +107,9 @@ export function mergeServerUpdate(prev: ServerMetrics[], incoming: ServerMetrics if (idx >= 0) { const merged = { ...updated[idx] } for (const [key, value] of Object.entries(server)) { - const isStaticDefault = STATIC_FIELDS.has(key) && (value === null || value === 0) + const isStaticDefault = + STATIC_FIELDS.has(key) && + (value === null || value === 0 || (Array.isArray(value) && value.length === 0)) if (!isStaticDefault) { ;(merged as Record)[key] = value } From 809232b4f39000db47ae111215411cf9b7397ce7 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 08:51:59 +0800 Subject: [PATCH 27/43] feat(server): add server_tag service with validation --- crates/server/src/service/mod.rs | 1 + crates/server/src/service/server_tag.rs | 117 ++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 crates/server/src/service/server_tag.rs diff --git a/crates/server/src/service/mod.rs b/crates/server/src/service/mod.rs index 39ed9037..159ad379 100644 --- a/crates/server/src/service/mod.rs +++ b/crates/server/src/service/mod.rs @@ -20,6 +20,7 @@ pub mod oauth; pub mod ping; pub mod record; pub mod server; +pub mod server_tag; pub mod service_monitor; pub mod status_page; pub mod task_scheduler; diff --git a/crates/server/src/service/server_tag.rs b/crates/server/src/service/server_tag.rs new file mode 100644 index 00000000..da488d3d --- /dev/null +++ b/crates/server/src/service/server_tag.rs @@ -0,0 +1,117 @@ +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, + TransactionTrait, +}; + +use crate::entity::server_tag; +use crate::error::AppError; + +pub const MAX_TAGS: usize = 8; +pub const MAX_TAG_LEN: usize = 16; + +pub fn validate_tags(raw: &[String]) -> Result, AppError> { + if raw.len() > MAX_TAGS { + return Err(AppError::Validation(format!("at most {MAX_TAGS} tags"))); + } + let mut seen = std::collections::BTreeSet::new(); + for tag in raw { + let trimmed = tag.trim().to_string(); + if trimmed.is_empty() { + continue; + } + if trimmed.chars().count() > MAX_TAG_LEN { + return Err(AppError::Validation(format!( + "tag '{trimmed}' exceeds {MAX_TAG_LEN} chars" + ))); + } + if !trimmed + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) + { + return Err(AppError::Validation(format!( + "tag '{trimmed}' contains invalid characters" + ))); + } + seen.insert(trimmed); + } + Ok(seen.into_iter().collect()) +} + +pub async fn list_tags(db: &DatabaseConnection, server_id: &str) -> Result, AppError> { + let rows = server_tag::Entity::find() + .filter(server_tag::Column::ServerId.eq(server_id)) + .order_by_asc(server_tag::Column::Tag) + .all(db) + .await?; + Ok(rows.into_iter().map(|r| r.tag).collect()) +} + +pub async fn set_tags( + db: &DatabaseConnection, + server_id: &str, + tags: Vec, +) -> Result, AppError> { + let normalized = validate_tags(&tags)?; + let txn = db.begin().await?; + server_tag::Entity::delete_many() + .filter(server_tag::Column::ServerId.eq(server_id)) + .exec(&txn) + .await?; + for tag in &normalized { + server_tag::ActiveModel { + server_id: Set(server_id.to_string()), + tag: Set(tag.clone()), + } + .insert(&txn) + .await?; + } + txn.commit().await?; + Ok(normalized) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_rejects_too_many() { + let tags: Vec = (0..9).map(|i| format!("t{i}")).collect(); + assert!(validate_tags(&tags).is_err()); + } + + #[test] + fn validate_rejects_too_long() { + let tags = vec!["a".repeat(17)]; + assert!(validate_tags(&tags).is_err()); + } + + #[test] + fn validate_rejects_invalid_chars() { + assert!(validate_tags(&["bad space".into()]).is_err()); + assert!(validate_tags(&["bad/slash".into()]).is_err()); + } + + #[test] + fn validate_trims_and_dedupes_and_sorts() { + let got = validate_tags(&[" b ".into(), "a".into(), "b".into()]).unwrap(); + assert_eq!(got, vec!["a".to_string(), "b".to_string()]); + } + + #[test] + fn validate_skips_empty_after_trim() { + let got = validate_tags(&[" ".into(), "a".into()]).unwrap(); + assert_eq!(got, vec!["a".to_string()]); + } + + #[test] + fn validate_allows_underscore_dash_dot() { + assert!( + validate_tags(&[ + "db_primary".into(), + "db-secondary".into(), + "v1.0".into() + ]) + .is_ok() + ); + } +} From a848e606433aa9cbe653b848439360b61e883fdf Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 08:53:12 +0800 Subject: [PATCH 28/43] feat(server): add /api/servers/:id/tags read/write routes --- crates/server/src/router/api/mod.rs | 3 + crates/server/src/router/api/server_tag.rs | 69 ++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 crates/server/src/router/api/server_tag.rs diff --git a/crates/server/src/router/api/mod.rs b/crates/server/src/router/api/mod.rs index 0b3f3edb..8d45e6cf 100644 --- a/crates/server/src/router/api/mod.rs +++ b/crates/server/src/router/api/mod.rs @@ -16,6 +16,7 @@ pub mod oauth; pub mod ping; pub mod server; pub mod server_group; +pub mod server_tag; pub mod service_monitor; pub mod setting; pub mod status; @@ -50,6 +51,7 @@ pub fn router(state: Arc) -> Router> { .merge(agent::read_router()) .merge(server::read_router()) .merge(server_group::read_router()) + .merge(server_tag::read_router()) .merge(ping::read_router()) .merge(network_probe::read_router()) .merge(file::read_router()) @@ -67,6 +69,7 @@ pub fn router(state: Arc) -> Router> { Router::new() .merge(server::write_router()) .merge(server_group::write_router()) + .merge(server_tag::write_router()) .merge(ping::write_router()) .merge(network_probe::write_router()) .merge(file::write_router( diff --git a/crates/server/src/router/api/server_tag.rs b/crates/server/src/router/api/server_tag.rs new file mode 100644 index 00000000..7df8be5d --- /dev/null +++ b/crates/server/src/router/api/server_tag.rs @@ -0,0 +1,69 @@ +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::routing::{get, put}; +use axum::{Json, Router}; +use serde::Deserialize; + +use crate::error::{ApiResponse, AppError, ok}; +use crate::service::server_tag; +use crate::state::AppState; + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct SetTagsRequest { + tags: Vec, +} + +/// Read router — all authenticated users. +pub fn read_router() -> Router> { + Router::new().route("/servers/{id}/tags", get(get_tags)) +} + +/// Write router — admin only (mounted under the require_admin layer in api::mod). +pub fn write_router() -> Router> { + Router::new().route("/servers/{id}/tags", put(put_tags)) +} + +#[utoipa::path( + get, + path = "/api/servers/{id}/tags", + operation_id = "get_server_tags", + tag = "server-tags", + params(("id" = String, Path, description = "Server ID")), + responses( + (status = 200, description = "Tags for the server", body = Vec), + (status = 401, description = "Unauthenticated"), + ), + security(("session_cookie" = []), ("api_key" = []), ("bearer_token" = [])) +)] +async fn get_tags( + State(state): State>, + Path(id): Path, +) -> Result>>, AppError> { + let tags = server_tag::list_tags(&state.db, &id).await?; + ok(tags) +} + +#[utoipa::path( + put, + path = "/api/servers/{id}/tags", + operation_id = "set_server_tags", + tag = "server-tags", + params(("id" = String, Path, description = "Server ID")), + request_body = SetTagsRequest, + responses( + (status = 200, description = "Canonical tag list after update", body = Vec), + (status = 422, description = "Validation error"), + (status = 401, description = "Unauthenticated"), + (status = 403, description = "Forbidden (non-admin)"), + ), + security(("session_cookie" = []), ("api_key" = []), ("bearer_token" = [])) +)] +async fn put_tags( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> Result>>, AppError> { + let normalized = server_tag::set_tags(&state.db, &id, body.tags).await?; + ok(normalized) +} From c01a8456f97ff656371edd025febe1ed516fe898 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 08:56:23 +0800 Subject: [PATCH 29/43] test(server): cover tags CRUD + RBAC and full_sync payload inclusion --- crates/server/tests/integration.rs | 268 +++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) diff --git a/crates/server/tests/integration.rs b/crates/server/tests/integration.rs index 6cb711d7..09beb7b1 100644 --- a/crates/server/tests/integration.rs +++ b/crates/server/tests/integration.rs @@ -3779,3 +3779,271 @@ async fn test_security_headers_present() { Some("none"), ); } + +// ── Server tags (Task 19) ──────────────────────────────────────────────────── + +/// Admin creates a new member user and returns a fresh logged-in member client. +/// Uses the admin-only POST /api/users endpoint because no public registration +/// endpoint exists in this project (mirrors the pattern from `test_member_read_only`). +async fn register_member(base_url: &str) -> reqwest::Client { + let admin = http_client(); + login_admin(&admin, base_url).await; + + let username = format!("member-{}", uuid::Uuid::new_v4().simple()); + let create_resp = admin + .post(format!("{}/api/users", base_url)) + .json(&json!({ + "username": username, + "password": "memberpass", + "role": "member" + })) + .send() + .await + .expect("admin should create member user"); + assert_eq!( + create_resp.status(), + 200, + "admin-created member user should succeed" + ); + + let member = http_client(); + let login_resp = member + .post(format!("{}/api/auth/login", base_url)) + .json(&json!({ "username": username, "password": "memberpass" })) + .send() + .await + .expect("login member failed"); + assert_eq!(login_resp.status(), 200, "member login should succeed"); + member +} + +#[tokio::test] +async fn unauthenticated_get_tags_returns_401() { + let (base_url, _tmp) = start_test_server().await; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("plain http client"); + let resp = client + .get(format!("{}/api/servers/unknown/tags", base_url)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 401); +} + +#[tokio::test] +async fn unauthenticated_put_tags_returns_401() { + let (base_url, _tmp) = start_test_server().await; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("plain http client"); + let resp = client + .put(format!("{}/api/servers/unknown/tags", base_url)) + .json(&json!({ "tags": ["a"] })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 401); +} + +#[tokio::test] +async fn admin_put_then_get_roundtrips() { + let (base_url, _tmp) = start_test_server().await; + let admin = http_client(); + login_admin(&admin, &base_url).await; + + let (server_id, _token) = register_agent(&admin, &base_url).await; + + let resp = admin + .put(format!("{}/api/servers/{server_id}/tags", base_url)) + .json(&json!({ "tags": ["b", "a", "b", " c "] })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + let data: Vec = serde_json::from_value(body["data"].clone()).unwrap(); + assert_eq!(data, vec!["a", "b", "c"]); + + let resp = admin + .get(format!("{}/api/servers/{server_id}/tags", base_url)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + let data: Vec = serde_json::from_value(body["data"].clone()).unwrap(); + assert_eq!(data, vec!["a", "b", "c"]); +} + +#[tokio::test] +async fn admin_put_rejects_too_many_tags() { + let (base_url, _tmp) = start_test_server().await; + let admin = http_client(); + login_admin(&admin, &base_url).await; + let (server_id, _token) = register_agent(&admin, &base_url).await; + let many: Vec = (0..9).map(|i| format!("t{i}")).collect(); + let resp = admin + .put(format!("{}/api/servers/{server_id}/tags", base_url)) + .json(&json!({ "tags": many })) + .send() + .await + .unwrap(); + // AppError::Validation maps to 422 UNPROCESSABLE_ENTITY per crates/server/src/error.rs. + assert_eq!(resp.status(), 422); +} + +#[tokio::test] +async fn admin_put_rejects_invalid_char() { + let (base_url, _tmp) = start_test_server().await; + let admin = http_client(); + login_admin(&admin, &base_url).await; + let (server_id, _token) = register_agent(&admin, &base_url).await; + let resp = admin + .put(format!("{}/api/servers/{server_id}/tags", base_url)) + .json(&json!({ "tags": ["has space"] })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 422); +} + +#[tokio::test] +async fn admin_put_rejects_too_long_tag() { + let (base_url, _tmp) = start_test_server().await; + let admin = http_client(); + login_admin(&admin, &base_url).await; + let (server_id, _token) = register_agent(&admin, &base_url).await; + let seventeen = "a".repeat(17); + let resp = admin + .put(format!("{}/api/servers/{server_id}/tags", base_url)) + .json(&json!({ "tags": [seventeen] })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 422); +} + +#[tokio::test] +async fn member_get_tags_returns_200() { + let (base_url, _tmp) = start_test_server().await; + let admin = http_client(); + login_admin(&admin, &base_url).await; + let (server_id, _token) = register_agent(&admin, &base_url).await; + admin + .put(format!("{}/api/servers/{server_id}/tags", base_url)) + .json(&json!({ "tags": ["prod"] })) + .send() + .await + .unwrap(); + + let member = register_member(&base_url).await; + let resp = member + .get(format!("{}/api/servers/{server_id}/tags", base_url)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + let data: Vec = serde_json::from_value(body["data"].clone()).unwrap(); + assert_eq!(data, vec!["prod"]); +} + +#[tokio::test] +async fn member_put_tags_returns_403() { + let (base_url, _tmp) = start_test_server().await; + let admin = http_client(); + login_admin(&admin, &base_url).await; + let (server_id, _token) = register_agent(&admin, &base_url).await; + + let member = register_member(&base_url).await; + let resp = member + .put(format!("{}/api/servers/{server_id}/tags", base_url)) + .json(&json!({ "tags": ["prod"] })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 403); +} + +#[tokio::test] +async fn browser_ws_full_sync_includes_tags_and_cpu_cores() { + let (base_url, _tmp) = start_test_server().await; + let admin = http_client(); + login_admin(&admin, &base_url).await; + let (server_id, _token) = register_agent(&admin, &base_url).await; + + // Seed tags via the REST endpoint. + let resp = admin + .put(format!("{}/api/servers/{server_id}/tags", base_url)) + .json(&json!({ "tags": ["alpha", "beta"] })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + // Acquire a raw Set-Cookie header (reqwest's cookie jar isn't directly readable). + let raw_client = reqwest::Client::builder() + .cookie_store(false) + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let login_resp = raw_client + .post(format!("{}/api/auth/login", base_url)) + .json(&json!({ "username": "admin", "password": "testpass" })) + .send() + .await + .unwrap(); + let set_cookie = login_resp + .headers() + .get("set-cookie") + .expect("set-cookie header") + .to_str() + .unwrap() + .to_string(); + let cookie_value = set_cookie.split(';').next().unwrap().to_string(); + + let ws_url = base_url.replace("http://", "ws://") + "/api/ws/servers"; + let mut request = ws_url.into_client_request().unwrap(); + request.headers_mut().insert( + "Cookie", + HeaderValue::from_str(&cookie_value).unwrap(), + ); + + let (mut ws, _resp) = tokio_tungstenite::connect_async(request) + .await + .expect("browser ws should connect"); + + let msg = tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .expect("ws recv timeout") + .expect("ws closed") + .expect("ws error"); + let text = msg.to_text().unwrap().to_string(); + let json: serde_json::Value = serde_json::from_str(&text).unwrap(); + + assert_eq!(json["type"], "full_sync"); + let servers = json["servers"].as_array().expect("servers array present"); + let ours = servers + .iter() + .find(|s| s["id"].as_str() == Some(&server_id)) + .expect("our server present in full_sync"); + assert_eq!( + ours["tags"] + .as_array() + .unwrap() + .iter() + .map(|t| t.as_str().unwrap()) + .collect::>(), + vec!["alpha", "beta"], + ); + assert!( + ours.get("cpu_cores") + .is_some_and(|v| v.is_null() || v.is_i64()), + "cpu_cores field must be present (null or integer), got {:?}", + ours.get("cpu_cores"), + ); +} From fad3901a0b02b34a93d5d652679e9fa85ae0229f Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 09:06:48 +0800 Subject: [PATCH 30/43] feat(web): add StatusDot pulsing indicator --- .../src/components/server/status-dot.test.tsx | 19 +++++++++++++++ apps/web/src/components/server/status-dot.tsx | 23 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 apps/web/src/components/server/status-dot.test.tsx create mode 100644 apps/web/src/components/server/status-dot.tsx diff --git a/apps/web/src/components/server/status-dot.test.tsx b/apps/web/src/components/server/status-dot.test.tsx new file mode 100644 index 00000000..451d8254 --- /dev/null +++ b/apps/web/src/components/server/status-dot.test.tsx @@ -0,0 +1,19 @@ +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { StatusDot } from './status-dot' + +describe('StatusDot', () => { + it('renders pulsing emerald dot when online', () => { + const { container } = render() + const el = container.querySelector('[data-slot="status-dot"]') + expect(el?.className).toMatch(/animate-pulse/) + expect(el?.className).toMatch(/bg-emerald-500/) + }) + + it('renders muted dot without pulse when offline', () => { + const { container } = render() + const el = container.querySelector('[data-slot="status-dot"]') + expect(el?.className).not.toMatch(/animate-pulse/) + expect(el?.className).toMatch(/bg-muted-foreground/) + }) +}) diff --git a/apps/web/src/components/server/status-dot.tsx b/apps/web/src/components/server/status-dot.tsx new file mode 100644 index 00000000..23c5b03a --- /dev/null +++ b/apps/web/src/components/server/status-dot.tsx @@ -0,0 +1,23 @@ +import { cn } from '@/lib/utils' + +interface StatusDotProps { + className?: string + online: boolean +} + +export function StatusDot({ online, className }: StatusDotProps) { + return ( + + ) +} From 3c6e54fda674ecea3833ddb829b7f10e8038ba6e Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 09:07:11 +0800 Subject: [PATCH 31/43] feat(web): useServerTags + useUpdateServerTags with optimistic cache --- apps/web/src/hooks/use-server-tags.test.tsx | 50 +++++++++++++++++++++ apps/web/src/hooks/use-server-tags.ts | 25 +++++++++++ 2 files changed, 75 insertions(+) create mode 100644 apps/web/src/hooks/use-server-tags.test.tsx create mode 100644 apps/web/src/hooks/use-server-tags.ts diff --git a/apps/web/src/hooks/use-server-tags.test.tsx b/apps/web/src/hooks/use-server-tags.test.tsx new file mode 100644 index 00000000..f206c187 --- /dev/null +++ b/apps/web/src/hooks/use-server-tags.test.tsx @@ -0,0 +1,50 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useServerTags, useUpdateServerTags } from './use-server-tags' + +function harness() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + return { qc, Wrapper } +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('useServerTags', () => { + it('fetches GET /api/servers/:id/tags', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ data: ['a', 'b'] }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + const { Wrapper } = harness() + const { result } = renderHook(() => useServerTags('srv-1'), { wrapper: Wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual(['a', 'b']) + }) +}) + +describe('useUpdateServerTags', () => { + it('PUTs tags and patches both caches on success', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (_input, init) => { + const body = JSON.parse((init as RequestInit).body as string) as { tags: string[] } + return new Response(JSON.stringify({ data: [...body.tags].sort() }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + }) + const { qc, Wrapper } = harness() + qc.setQueryData(['server-tags', 'srv-1'], ['old']) + qc.setQueryData(['servers'], [{ id: 'srv-1', tags: ['old'] }]) + const { result } = renderHook(() => useUpdateServerTags('srv-1'), { wrapper: Wrapper }) + await result.current.mutateAsync(['b', 'a']) + expect(qc.getQueryData(['server-tags', 'srv-1'])).toEqual(['a', 'b']) + expect((qc.getQueryData(['servers']) as Array<{ id: string; tags: string[] }>)[0].tags).toEqual(['a', 'b']) + }) +}) diff --git a/apps/web/src/hooks/use-server-tags.ts b/apps/web/src/hooks/use-server-tags.ts new file mode 100644 index 00000000..3b64883b --- /dev/null +++ b/apps/web/src/hooks/use-server-tags.ts @@ -0,0 +1,25 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { api } from '@/lib/api-client' + +export function useServerTags(serverId: string, enabled = true) { + return useQuery({ + queryKey: ['server-tags', serverId], + queryFn: () => api.get(`/api/servers/${serverId}/tags`), + enabled: enabled && !!serverId, + staleTime: 60_000 + }) +} + +export function useUpdateServerTags(serverId: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (tags) => api.put(`/api/servers/${serverId}/tags`, { tags }), + onSuccess: (data) => { + queryClient.setQueryData(['server-tags', serverId], data) + queryClient.setQueryData(['servers'], (prev) => + prev?.map((s) => (s.id === serverId ? { ...s, tags: data } : s)) + ) + } + }) +} From a74ad0a94cff7ee947b4da1838e937045e25e10e Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 09:07:24 +0800 Subject: [PATCH 32/43] feat(web): add TagChipRow with stable-hash palette --- .../src/components/server/tag-chip.test.tsx | 35 ++++++++++++++ apps/web/src/components/server/tag-chip.tsx | 46 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 apps/web/src/components/server/tag-chip.test.tsx create mode 100644 apps/web/src/components/server/tag-chip.tsx diff --git a/apps/web/src/components/server/tag-chip.test.tsx b/apps/web/src/components/server/tag-chip.test.tsx new file mode 100644 index 00000000..ad24d218 --- /dev/null +++ b/apps/web/src/components/server/tag-chip.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { TagChipRow } from './tag-chip' + +describe('TagChipRow', () => { + it('renders nothing when tags is empty', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders nothing when tags is undefined', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders a chip per tag', () => { + render() + expect(screen.getByText('prod')).toBeDefined() + expect(screen.getByText('web')).toBeDefined() + }) + + it('assigns the same palette color to the same tag across renders', () => { + const { container, rerender } = render() + const first = container.querySelector('[data-slot="tag-chip"]')?.className + rerender() + const second = container.querySelector('[data-slot="tag-chip"]')?.className + expect(first).toBe(second) + }) + + it('adds title attr on the chip element for tooltip / truncate fallback', () => { + render() + const chip = screen.getByText('long-tag-value') + expect(chip.getAttribute('title')).toBe('long-tag-value') + }) +}) diff --git a/apps/web/src/components/server/tag-chip.tsx b/apps/web/src/components/server/tag-chip.tsx new file mode 100644 index 00000000..262766b8 --- /dev/null +++ b/apps/web/src/components/server/tag-chip.tsx @@ -0,0 +1,46 @@ +import { cn } from '@/lib/utils' + +const PALETTE = [ + 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400', + 'bg-sky-500/15 text-sky-700 dark:text-sky-400', + 'bg-amber-500/15 text-amber-700 dark:text-amber-400', + 'bg-rose-500/15 text-rose-700 dark:text-rose-400', + 'bg-violet-500/15 text-violet-700 dark:text-violet-400', + 'bg-slate-500/15 text-slate-700 dark:text-slate-300' +] as const + +function hashTag(tag: string): number { + let h = 0 + for (let i = 0; i < tag.length; i++) { + h = (h * 31 + tag.charCodeAt(i)) | 0 + } + return Math.abs(h) % PALETTE.length +} + +interface TagChipRowProps { + className?: string + tags: string[] | undefined +} + +export function TagChipRow({ tags, className }: TagChipRowProps) { + if (!tags || tags.length === 0) { + return null + } + return ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ ) +} From b1e17763fe9dac4f7d0dda0c3d635bdd26f98f7c Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 09:09:45 +0800 Subject: [PATCH 33/43] feat(web): ServerEditDialog tags editor with sequential save --- .../components/server/server-edit-dialog.tsx | 129 ++++++++++++++---- 1 file changed, 103 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/server/server-edit-dialog.tsx b/apps/web/src/components/server/server-edit-dialog.tsx index a515f0b4..f64d9dcd 100644 --- a/apps/web/src/components/server/server-edit-dialog.tsx +++ b/apps/web/src/components/server/server-edit-dialog.tsx @@ -7,9 +7,39 @@ import { Checkbox } from '@/components/ui/checkbox' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { useServerTags, useUpdateServerTags } from '@/hooks/use-server-tags' import { api } from '@/lib/api-client' import type { ServerGroup, ServerResponse, UpdateServerInput } from '@/lib/api-schema' +const TAG_SPLIT_RE = /[\s,]+/ +const TAG_VALID_RE = /^[A-Za-z0-9_.-]+$/ + +function parseTagsInput(raw: string): { tags: string[]; error: string | null } { + const parts = raw + .split(TAG_SPLIT_RE) + .map((t) => t.trim()) + .filter(Boolean) + const seen = new Set() + const deduped: string[] = [] + for (const tag of parts) { + if (tag.length > 16) { + return { tags: [], error: 'tags_validation_too_long' } + } + if (!TAG_VALID_RE.test(tag)) { + return { tags: [], error: 'tags_validation_invalid_char' } + } + if (seen.has(tag)) { + continue + } + seen.add(tag) + deduped.push(tag) + } + if (deduped.length > 8) { + return { tags: [], error: 'tags_validation_too_many' } + } + return { tags: deduped.sort(), error: null } +} + interface ServerEditDialogProps { onClose: () => void open: boolean @@ -34,6 +64,8 @@ export function ServerEditDialog({ server, open, onClose }: ServerEditDialogProp ) const [trafficLimitType, setTrafficLimitType] = useState(server.traffic_limit_type ?? 'sum') const [billingStartDay, setBillingStartDay] = useState(server.billing_start_day?.toString() ?? '') + const [tagsInput, setTagsInput] = useState('') + const [tagsDirty, setTagsDirty] = useState(false) const { data: groups } = useQuery({ queryKey: ['server-groups'], @@ -42,6 +74,9 @@ export function ServerEditDialog({ server, open, onClose }: ServerEditDialogProp enabled: open }) + const { data: initialTags } = useServerTags(server.id, open) + const tagsMutation = useUpdateServerTags(server.id) + useEffect(() => { if (open) { setName(server.name) @@ -60,6 +95,13 @@ export function ServerEditDialog({ server, open, onClose }: ServerEditDialogProp } }, [open, server]) + useEffect(() => { + if (open && initialTags) { + setTagsInput(initialTags.join(', ')) + setTagsDirty(false) + } + }, [open, initialTags]) + const mutation = useMutation({ mutationFn: (payload: UpdateServerInput) => api.put(`/api/servers/${server.id}`, payload), onSuccess: (data) => { @@ -68,32 +110,53 @@ export function ServerEditDialog({ server, open, onClose }: ServerEditDialogProp } }) - const handleSubmit = (e: FormEvent) => { + const buildPayload = (): UpdateServerInput => ({ + name, + weight, + hidden, + group_id: groupId || null, + remark: remark || null, + public_remark: publicRemark || null, + price: price ? Number.parseFloat(price) : null, + billing_cycle: billingCycle || null, + currency: currency || null, + expired_at: expiredAt ? `${expiredAt}T00:00:00Z` : null, + traffic_limit: trafficLimit ? Math.round(Number.parseFloat(trafficLimit) * 1024 ** 3) : null, + traffic_limit_type: trafficLimitType || null, + billing_start_day: billingStartDay ? Number.parseInt(billingStartDay, 10) : null + }) + + const saveTags = async (tags: string[]): Promise => { + try { + await tagsMutation.mutateAsync(tags) + return true + } catch (err) { + if (initialTags) { + setTagsInput(initialTags.join(', ')) + } + toast.error(err instanceof Error ? err.message : t('tags_save_failed')) + return false + } + } + + const handleSubmit = async (e: FormEvent) => { e.preventDefault() - const payload: UpdateServerInput = { - name, - weight, - hidden, - group_id: groupId || null, - remark: remark || null, - public_remark: publicRemark || null, - price: price ? Number.parseFloat(price) : null, - billing_cycle: billingCycle || null, - currency: currency || null, - expired_at: expiredAt ? `${expiredAt}T00:00:00Z` : null, - traffic_limit: trafficLimit ? Math.round(Number.parseFloat(trafficLimit) * 1024 ** 3) : null, - traffic_limit_type: trafficLimitType || null, - billing_start_day: billingStartDay ? Number.parseInt(billingStartDay, 10) : null + const parsed = parseTagsInput(tagsInput) + if (parsed.error) { + toast.error(t(parsed.error)) + return } - mutation.mutate(payload, { - onSuccess: () => { - toast.success(t('edit_success', { defaultValue: 'Server updated successfully' })) - onClose() - }, - onError: (err) => { - toast.error(err instanceof Error ? err.message : t('edit_failed')) - } - }) + try { + await mutation.mutateAsync(buildPayload()) + } catch (err) { + toast.error(err instanceof Error ? err.message : t('edit_failed')) + return + } + if (tagsDirty && !(await saveTags(parsed.tags))) { + return + } + toast.success(t('edit_success', { defaultValue: 'Server updated successfully' })) + onClose() } return ( @@ -187,6 +250,20 @@ export function ServerEditDialog({ server, open, onClose }: ServerEditDialogProp value={publicRemark} /> + + { + setTagsInput(e.target.value) + setTagsDirty(true) + }} + placeholder={t('tags_placeholder')} + type="text" + value={tagsInput} + /> +

{t('tags_hint')}

+
{/* Billing */} @@ -316,8 +393,8 @@ export function ServerEditDialog({ server, open, onClose }: ServerEditDialogProp -
From bce24ef6adc71397bc6c54a715e11054c5b1660e Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 09:14:16 +0800 Subject: [PATCH 34/43] refactor(web): introduce MetricBarRow primitive in servers cells --- .../_authed/servers/index.cells.test.tsx | 156 ++++-------------- .../routes/_authed/servers/index.cells.tsx | 131 ++++++--------- 2 files changed, 85 insertions(+), 202 deletions(-) diff --git a/apps/web/src/routes/_authed/servers/index.cells.test.tsx b/apps/web/src/routes/_authed/servers/index.cells.test.tsx index e85889c9..178ecb11 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.test.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.test.tsx @@ -1,41 +1,29 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import type { ServerMetrics } from '@/hooks/use-servers-ws' -import { CpuCell, DiskCell, MemoryCell, NetworkCell } from './index.cells' - -const CPU_LOAD_TEXT = /card_load\s+1\.23/ -const DISK_USAGE_TEXT = '111.8 GB / 465.7 GB' -const DISK_READ_TEXT = '↺ 2.0 MB/s' -const DISK_WRITE_TEXT = '↻ 1.1 MB/s' -const DISK_ZERO_READ_TEXT = '↺ 0 B/s' -const DISK_ZERO_WRITE_TEXT = '↻ 0 B/s' -const DISK_READ_ARROW_TEXT = /↺/ -const DISK_WRITE_ARROW_TEXT = /↻/ -const NETWORK_IN_SPEED_TEXT = '↓1.1 MB/s' -const NETWORK_OUT_SPEED_TEXT = '↑332.0 KB/s' -const NETWORK_ZERO_IN_SPEED_TEXT = '↓0 B/s' -const NETWORK_ZERO_OUT_SPEED_TEXT = '↑0 B/s' -const NETWORK_CUMULATIVE_TEXT = /^Σ ↓5\.0 GB\s*↑2\.0 GB$/ +import { MetricBarRow } from './index.cells' vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) -function makeServer(overrides: Partial = {}): ServerMetrics { +export function makeServer(overrides: Partial = {}): ServerMetrics { return { id: 'srv-1', name: 'test-server', online: true, country_code: null, - cpu: 45, + cpu: 0, + cpu_cores: null, cpu_name: null, disk_read_bytes_per_sec: 0, disk_total: 500_000_000_000, disk_used: 120_000_000_000, disk_write_bytes_per_sec: 0, + features: [], group_id: null, last_active: 0, - load1: 1.23, + load1: 0, load5: 0, load15: 0, mem_total: 8_000_000_000, @@ -49,6 +37,7 @@ function makeServer(overrides: Partial = {}): ServerMetrics { region: null, swap_total: 0, swap_used: 0, + tags: [], tcp_conn: 0, udp_conn: 0, uptime: 0, @@ -56,122 +45,39 @@ function makeServer(overrides: Partial = {}): ServerMetrics { } } -function hasTextContent(node: Element | null, pattern: RegExp): boolean { - return pattern.test(node?.textContent ?? '') -} - -describe('CpuCell', () => { - it('shows cpu percentage and load1', () => { - render() - expect(screen.getByText('45%')).toBeDefined() - expect(screen.getByText(CPU_LOAD_TEXT)).toBeDefined() +describe('MetricBarRow', () => { + it('renders green bar below 70%', () => { + const { container } = render() + const fill = container.querySelector('[data-slot="metric-bar-fill"]') + expect(fill?.className).toMatch(/bg-emerald-500/) }) -}) -describe('MemoryCell', () => { - it('shows used/total with percentage', () => { - render() - expect(screen.getByText('3.0 GB / 7.5 GB')).toBeDefined() - expect(screen.getByText('40%')).toBeDefined() + it('renders amber bar at 70% and below 90%', () => { + const { container } = render() + const fill = container.querySelector('[data-slot="metric-bar-fill"]') + expect(fill?.className).toMatch(/bg-amber-500/) }) - it('renders 0B / 0B when mem_total is zero', () => { - render() - expect(screen.getByText('0 B / 0 B')).toBeDefined() - expect(screen.getByText('0%')).toBeDefined() + it('renders red bar at 90%+', () => { + const { container } = render() + const fill = container.querySelector('[data-slot="metric-bar-fill"]') + expect(fill?.className).toMatch(/bg-red-500/) }) -}) - -describe('DiskCell', () => { - it('shows used/total and io row when online', () => { - render( - - ) - expect(screen.getByText(DISK_USAGE_TEXT)).toBeDefined() - expect(screen.getByText('24%')).toBeDefined() - expect(screen.getByText(DISK_READ_TEXT)).toBeDefined() - expect(screen.getByText(DISK_WRITE_TEXT)).toBeDefined() + it('rounds the percentage to 0 decimals', () => { + render() + expect(screen.getByText('43%')).toBeDefined() }) - it('shows used/total but hides io row when offline', () => { - render( - - ) - - expect(screen.getByText(DISK_USAGE_TEXT)).toBeDefined() - expect(screen.queryByText(DISK_READ_ARROW_TEXT)).toBeNull() - expect(screen.queryByText(DISK_WRITE_ARROW_TEXT)).toBeNull() - }) - - it('shows zero io speeds when online and idle', () => { - render( - - ) - - expect(screen.getByText(DISK_ZERO_READ_TEXT)).toBeDefined() - expect(screen.getByText(DISK_ZERO_WRITE_TEXT)).toBeDefined() - }) -}) - -describe('NetworkCell', () => { - it('shows live speeds and cumulative row when online', () => { - render( - - ) - - expect(screen.getByText(NETWORK_IN_SPEED_TEXT)).toBeDefined() - expect(screen.getByText(NETWORK_OUT_SPEED_TEXT)).toBeDefined() - expect(screen.getByText((_, node) => hasTextContent(node, NETWORK_CUMULATIVE_TEXT))).toBeDefined() + it('clamps percentage to [0, 100]', () => { + render() + expect(screen.getByText('100%')).toBeDefined() + render() + expect(screen.getByText('0%')).toBeDefined() }) - it('zeros live speeds but keeps cumulative row when offline', () => { - render( - - ) - - expect(screen.getByText(NETWORK_ZERO_IN_SPEED_TEXT)).toBeDefined() - expect(screen.getByText(NETWORK_ZERO_OUT_SPEED_TEXT)).toBeDefined() - expect(screen.queryByText(NETWORK_IN_SPEED_TEXT)).toBeNull() - expect(screen.queryByText(NETWORK_OUT_SPEED_TEXT)).toBeNull() - expect(screen.getByText((_, node) => hasTextContent(node, NETWORK_CUMULATIVE_TEXT))).toBeDefined() + it('renders the supplied icon slot', () => { + render(} pct={10} />) + expect(screen.getByTestId('cpu-icon')).toBeDefined() }) }) diff --git a/apps/web/src/routes/_authed/servers/index.cells.tsx b/apps/web/src/routes/_authed/servers/index.cells.tsx index 6b25a03e..612b82e0 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.tsx @@ -1,100 +1,77 @@ import type { ReactNode } from 'react' -import { useTranslation } from 'react-i18next' import type { ServerMetrics } from '@/hooks/use-servers-ws' -import { cn, formatBytes, formatSpeed } from '@/lib/utils' +import { cn } from '@/lib/utils' -function getBarColor(p: number): string { - if (p > 90) { +export function getBarColor(pct: number): string { + if (pct > 90) { return 'bg-red-500' } - if (p > 70) { + if (pct > 70) { return 'bg-amber-500' } return 'bg-emerald-500' } -export function MiniBar({ pct, sub }: { pct: number; sub?: ReactNode }) { - const p = Math.min(100, Math.max(0, pct)) - const color = getBarColor(p) - return ( -
-
-
-
-
- {p.toFixed(0)}% -
- {sub !== undefined && ( -
{sub}
- )} -
- ) +export function getBarTextColor(pct: number): string { + if (pct > 90) { + return 'text-red-600 dark:text-red-400' + } + if (pct > 70) { + return 'text-amber-600 dark:text-amber-400' + } + return 'text-foreground' } -export function CpuCell({ server }: { server: ServerMetrics }) { - const { t } = useTranslation(['servers']) - return ( - - {t('card_load')} {server.load1.toFixed(2)} - - } - /> - ) +interface MetricBarRowProps { + ariaLabel?: string + icon: ReactNode + pct: number + valueClassName?: string } -export function MemoryCell({ server }: { server: ServerMetrics }) { - const pct = server.mem_total > 0 ? (server.mem_used / server.mem_total) * 100 : 0 +export function MetricBarRow({ icon, pct, ariaLabel, valueClassName }: MetricBarRowProps) { + const clamped = Math.min(100, Math.max(0, pct)) + const colorBg = getBarColor(clamped) + const colorText = getBarTextColor(clamped) + // Only apply role="img" when an ariaLabel is supplied; otherwise the role would be unnamed (a11y anti-pattern). + const imgProps = ariaLabel ? { role: 'img' as const, 'aria-label': ariaLabel } : {} return ( - - {formatBytes(server.mem_used)} / {formatBytes(server.mem_total)} - - } - /> +
+ {icon !== null && {icon}} +
+
+
+ + {Math.round(clamped)}% + +
) } -export function DiskCell({ server }: { server: ServerMetrics }) { - const pct = server.disk_total > 0 ? (server.disk_used / server.disk_total) * 100 : 0 +// Back-compat: MiniBar keeps its existing public signature but now delegates to MetricBarRow. +export function MiniBar({ pct, sub }: { pct: number; sub?: ReactNode }) { return ( - - - {formatBytes(server.disk_used)} / {formatBytes(server.disk_total)} - - {server.online && ( - - ↺ {formatSpeed(server.disk_read_bytes_per_sec)} - ↻ {formatSpeed(server.disk_write_bytes_per_sec)} - - )} -
- } - /> +
+ + {sub !== undefined &&
{sub}
} +
) } -export function NetworkCell({ server }: { server: ServerMetrics }) { - const inSpeed = server.online ? server.net_in_speed : 0 - const outSpeed = server.online ? server.net_out_speed : 0 - - return ( -
- - ↓{formatSpeed(inSpeed)} - ↑{formatSpeed(outSpeed)} - - - Σ ↓{formatBytes(server.net_in_transfer)} - ↑{formatBytes(server.net_out_transfer)} - -
- ) +// Temporary stubs — replaced in Tasks 8–13. +export function CpuCell(_: { server: ServerMetrics }) { + return +} +export function MemoryCell(_: { server: ServerMetrics }) { + return +} +export function DiskCell(_: { server: ServerMetrics }) { + return +} +export function NetworkCell(_: { server: ServerMetrics }) { + return } From fdb76b8da519f637879a1a9d1d9c9693979df0bd Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 09:17:47 +0800 Subject: [PATCH 35/43] feat(web): CpuCell shows cores + load with Phase A fallback --- .../_authed/servers/index.cells.test.tsx | 22 +++++++++++++++++++ .../routes/_authed/servers/index.cells.tsx | 17 +++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/web/src/routes/_authed/servers/index.cells.test.tsx b/apps/web/src/routes/_authed/servers/index.cells.test.tsx index 178ecb11..2fff8fb1 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.test.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.test.tsx @@ -81,3 +81,25 @@ describe('MetricBarRow', () => { expect(screen.getByTestId('cpu-icon')).toBeDefined() }) }) + +import { CpuCell } from './index.cells' + +describe('CpuCell', () => { + it('renders cores + load when cpu_cores is present', () => { + render() + expect(screen.getByText('12%')).toBeDefined() + expect(screen.getByText(/8 cores · load 1\.23/)).toBeDefined() + }) + + it('falls back to load-only when cpu_cores is null (Phase A)', () => { + render() + expect(screen.queryByText(/cores/)).toBeNull() + expect(screen.getByText(/load 1\.23/)).toBeDefined() + }) + + it('hides sub-line when offline', () => { + render() + expect(screen.queryByText(/cores/)).toBeNull() + expect(screen.queryByText(/load/)).toBeNull() + }) +}) diff --git a/apps/web/src/routes/_authed/servers/index.cells.tsx b/apps/web/src/routes/_authed/servers/index.cells.tsx index 612b82e0..65cb466e 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.tsx @@ -1,3 +1,4 @@ +import { Cpu } from 'lucide-react' import type { ReactNode } from 'react' import type { ServerMetrics } from '@/hooks/use-servers-ws' import { cn } from '@/lib/utils' @@ -62,9 +63,19 @@ export function MiniBar({ pct, sub }: { pct: number; sub?: ReactNode }) { ) } -// Temporary stubs — replaced in Tasks 8–13. -export function CpuCell(_: { server: ServerMetrics }) { - return +export function CpuCell({ server }: { server: ServerMetrics }) { + if (!server.online) { + return + } + const cores = server.cpu_cores ?? null + return ( +
+
+ ) } export function MemoryCell(_: { server: ServerMetrics }) { return From f0acbb9d667069fd12b9ac98bbfe17ad0df92312 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 09:19:20 +0800 Subject: [PATCH 36/43] feat(web): MemoryCell shows used/total + swap pct --- .../_authed/servers/index.cells.test.tsx | 30 +++++++++++++++++++ .../routes/_authed/servers/index.cells.tsx | 22 +++++++++++--- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/apps/web/src/routes/_authed/servers/index.cells.test.tsx b/apps/web/src/routes/_authed/servers/index.cells.test.tsx index 2fff8fb1..f388ef56 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.test.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.test.tsx @@ -103,3 +103,33 @@ describe('CpuCell', () => { expect(screen.queryByText(/load/)).toBeNull() }) }) + +import { MemoryCell } from './index.cells' + +describe('MemoryCell', () => { + it('renders used/total + swap pct', () => { + render( + + ) + expect(screen.getByText(/7\.2 GB \/ 16\.0 GB/)).toBeDefined() + expect(screen.getByText(/swap/)).toBeDefined() + expect(screen.getByText(/3%/)).toBeDefined() + }) + + it('renders 0% swap when swap_total is 0', () => { + render() + expect(screen.getByText(/swap 0%/)).toBeDefined() + }) + + it('hides sub-line when offline', () => { + render() + expect(screen.queryByText(/swap/)).toBeNull() + }) +}) diff --git a/apps/web/src/routes/_authed/servers/index.cells.tsx b/apps/web/src/routes/_authed/servers/index.cells.tsx index 65cb466e..4bf4f297 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.tsx @@ -1,7 +1,7 @@ -import { Cpu } from 'lucide-react' +import { Cpu, MemoryStick } from 'lucide-react' import type { ReactNode } from 'react' import type { ServerMetrics } from '@/hooks/use-servers-ws' -import { cn } from '@/lib/utils' +import { cn, formatBytes } from '@/lib/utils' export function getBarColor(pct: number): string { if (pct > 90) { @@ -77,8 +77,22 @@ export function CpuCell({ server }: { server: ServerMetrics }) {
) } -export function MemoryCell(_: { server: ServerMetrics }) { - return +export function MemoryCell({ server }: { server: ServerMetrics }) { + if (!server.online) { + return + } + const pct = server.mem_total > 0 ? (server.mem_used / server.mem_total) * 100 : 0 + const swapPct = server.swap_total > 0 ? (server.swap_used / server.swap_total) * 100 : 0 + const swapColor = getBarTextColor(swapPct) + return ( +
+
+ ) } export function DiskCell(_: { server: ServerMetrics }) { return From 17b24e415eb7fa8c89ccde7fdd54255316bd202d Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 09:20:15 +0800 Subject: [PATCH 37/43] feat(web): DiskCell shows usage + disk I/O with lucide icons --- .../_authed/servers/index.cells.test.tsx | 35 +++++++++++++++++++ .../routes/_authed/servers/index.cells.tsx | 26 +++++++++++--- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/apps/web/src/routes/_authed/servers/index.cells.test.tsx b/apps/web/src/routes/_authed/servers/index.cells.test.tsx index f388ef56..f47883a0 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.test.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.test.tsx @@ -133,3 +133,38 @@ describe('MemoryCell', () => { expect(screen.queryByText(/swap/)).toBeNull() }) }) + +import { DiskCell } from './index.cells' + +describe('DiskCell', () => { + it('shows usage bar + r/w speeds when online', () => { + render( + + ) + expect(screen.getByText('60%')).toBeDefined() + expect(screen.getByText(/2\.0 MB\/s/)).toBeDefined() + expect(screen.getByText(/500\.0 KB\/s/)).toBeDefined() + }) + + it('hides r/w sub when offline', () => { + render( + + ) + expect(screen.queryByText(/KB\/s/)).toBeNull() + }) + + it('renders 0% when disk_total is 0', () => { + render() + expect(screen.getByText('0%')).toBeDefined() + }) +}) diff --git a/apps/web/src/routes/_authed/servers/index.cells.tsx b/apps/web/src/routes/_authed/servers/index.cells.tsx index 4bf4f297..75dca387 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.tsx @@ -1,7 +1,7 @@ -import { Cpu, MemoryStick } from 'lucide-react' +import { ArrowDown, ArrowUp, Cpu, HardDrive, MemoryStick } from 'lucide-react' import type { ReactNode } from 'react' import type { ServerMetrics } from '@/hooks/use-servers-ws' -import { cn, formatBytes } from '@/lib/utils' +import { cn, formatBytes, formatSpeed } from '@/lib/utils' export function getBarColor(pct: number): string { if (pct > 90) { @@ -94,8 +94,26 @@ export function MemoryCell({ server }: { server: ServerMetrics }) {
) } -export function DiskCell(_: { server: ServerMetrics }) { - return +export function DiskCell({ server }: { server: ServerMetrics }) { + if (!server.online) { + return + } + const pct = server.disk_total > 0 ? (server.disk_used / server.disk_total) * 100 : 0 + return ( +
+
+ ) } export function NetworkCell(_: { server: ServerMetrics }) { return From 8d58d5caed900ded5cc9524c28c3a0ae702728e9 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 09:24:23 +0800 Subject: [PATCH 38/43] feat(web): NetworkCell shows traffic quota bar + live speeds --- .../_authed/servers/index.cells.test.tsx | 65 +++++++++++++++++++ .../routes/_authed/servers/index.cells.tsx | 40 +++++++++++- 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/apps/web/src/routes/_authed/servers/index.cells.test.tsx b/apps/web/src/routes/_authed/servers/index.cells.test.tsx index f47883a0..78416061 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.test.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.test.tsx @@ -168,3 +168,68 @@ describe('DiskCell', () => { expect(screen.getByText('0%')).toBeDefined() }) }) + +import type { TrafficOverviewItem } from '@/hooks/use-traffic-overview' +import { NetworkCell } from './index.cells' + +const GB = 1024 ** 3 +const TB = 1024 ** 4 + +function makeEntry(overrides: Partial): TrafficOverviewItem { + return { + billing_cycle: null, + cycle_in: 0, + cycle_out: 0, + days_remaining: null, + name: 'srv', + percent_used: null, + server_id: 'srv-1', + traffic_limit: null, + ...overrides + } +} + +describe('NetworkCell', () => { + it('renders traffic-quota bar + used/limit + live ↓↑ when online', () => { + render( + + ) + expect(screen.getByText('9%')).toBeDefined() + expect(screen.getByText(/93\.2 GB \/ 1\.0 TB/)).toBeDefined() + expect(screen.getByText(/1\.1 MB\/s/)).toBeDefined() + expect(screen.getByText(/332\.0 KB\/s/)).toBeDefined() + }) + + it('falls back to net_in_transfer + 1 TiB default when entry is undefined', () => { + render( + + ) + // 3 GB / 1 TiB ≈ 0.29% → rounds to 0% + expect(screen.getByText('0%')).toBeDefined() + expect(screen.getByText(/3\.0 GB \/ 1\.0 TB/)).toBeDefined() + }) + + it('renders traffic-quota bar even when offline (server-level data)', () => { + render( + + ) + expect(screen.getByText(/10%/)).toBeDefined() + expect(screen.getByText(/100\.0 GB \/ 1\.0 TB/)).toBeDefined() + expect(screen.queryByText(/MB\/s/)).toBeNull() + expect(screen.queryByText(/KB\/s/)).toBeNull() + }) + + it('treats traffic_limit <= 0 as fallback to default', () => { + render() + expect(screen.getByText(/1\.0 TB/)).toBeDefined() + }) +}) diff --git a/apps/web/src/routes/_authed/servers/index.cells.tsx b/apps/web/src/routes/_authed/servers/index.cells.tsx index 75dca387..bcda7a07 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.tsx @@ -1,6 +1,8 @@ -import { ArrowDown, ArrowUp, Cpu, HardDrive, MemoryStick } from 'lucide-react' +import { ArrowDown, ArrowUp, Cpu, HardDrive, MemoryStick, Network } from 'lucide-react' import type { ReactNode } from 'react' import type { ServerMetrics } from '@/hooks/use-servers-ws' +import type { TrafficOverviewItem } from '@/hooks/use-traffic-overview' +import { computeTrafficQuota } from '@/lib/traffic' import { cn, formatBytes, formatSpeed } from '@/lib/utils' export function getBarColor(pct: number): string { @@ -115,6 +117,38 @@ export function DiskCell({ server }: { server: ServerMetrics }) {
) } -export function NetworkCell(_: { server: ServerMetrics }) { - return +interface NetworkCellProps { + entry: TrafficOverviewItem | undefined + server: ServerMetrics +} + +export function NetworkCell({ server, entry }: NetworkCellProps) { + const { used, limit, pct } = computeTrafficQuota({ + entry, + netInTransfer: server.net_in_transfer, + netOutTransfer: server.net_out_transfer + }) + return ( +
+
+ ) } From e86ad477a7afbc03607b19f53984d98911c2a9c4 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 09:26:06 +0800 Subject: [PATCH 39/43] feat(web): UptimeCell and NameCell with tags support --- .../_authed/servers/index.cells.test.tsx | 57 +++++++++++- .../routes/_authed/servers/index.cells.tsx | 88 ++++++++++++++++++- 2 files changed, 142 insertions(+), 3 deletions(-) diff --git a/apps/web/src/routes/_authed/servers/index.cells.test.tsx b/apps/web/src/routes/_authed/servers/index.cells.test.tsx index 78416061..c9c19774 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.test.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ServerMetrics } from '@/hooks/use-servers-ws' import { MetricBarRow } from './index.cells' @@ -7,6 +7,14 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) +vi.mock('@tanstack/react-router', () => ({ + Link: ({ children, ...props }: { children?: React.ReactNode; [k: string]: unknown }) => ( + + {children} + + ) +})) + export function makeServer(overrides: Partial = {}): ServerMetrics { return { id: 'srv-1', @@ -233,3 +241,50 @@ describe('NetworkCell', () => { expect(screen.getByText(/1\.0 TB/)).toBeDefined() }) }) + +import { NameCell, UptimeCell } from './index.cells' + +describe('UptimeCell', () => { + const NOW = 1_700_000_000 + const _originalNow = Date.now + beforeEach(() => { + Date.now = () => NOW * 1000 + }) + afterEach(() => { + Date.now = _originalNow + }) + + it('shows uptime + OS line when online', () => { + render( + + ) + expect(screen.getByText(/23d/)).toBeDefined() + expect(screen.getByText(/Ubuntu 22\.04/)).toBeDefined() + }) + + it('shows offline + last-seen relative when offline', () => { + render( + + ) + expect(screen.getByText(/offline/i)).toBeDefined() + expect(screen.getByText(/last_seen_ago/)).toBeDefined() + }) +}) + +describe('NameCell', () => { + it('renders single-line layout when no tags', () => { + const { container } = render() + expect(screen.getByText('tokyo-1')).toBeDefined() + expect(container.querySelector('[data-slot="tag-chip"]')).toBeNull() + }) + + it('renders chips under the name when tags present', () => { + render() + expect(screen.getByText('prod')).toBeDefined() + expect(screen.getByText('web')).toBeDefined() + }) +}) diff --git a/apps/web/src/routes/_authed/servers/index.cells.tsx b/apps/web/src/routes/_authed/servers/index.cells.tsx index bcda7a07..3e9022c0 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.tsx @@ -1,9 +1,12 @@ -import { ArrowDown, ArrowUp, Cpu, HardDrive, MemoryStick, Network } from 'lucide-react' +import { Link } from '@tanstack/react-router' +import { ArrowDown, ArrowUp, Clock, Cpu, HardDrive, MemoryStick, Network } from 'lucide-react' import type { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { TagChipRow } from '@/components/server/tag-chip' import type { ServerMetrics } from '@/hooks/use-servers-ws' import type { TrafficOverviewItem } from '@/hooks/use-traffic-overview' import { computeTrafficQuota } from '@/lib/traffic' -import { cn, formatBytes, formatSpeed } from '@/lib/utils' +import { cn, countryCodeToFlag, formatBytes, formatSpeed, formatUptime } from '@/lib/utils' export function getBarColor(pct: number): string { if (pct > 90) { @@ -152,3 +155,84 @@ export function NetworkCell({ server, entry }: NetworkCellProps) {
) } + +function osEmoji(os: string | null): string { + if (!os) { + return '' + } + const l = os.toLowerCase() + if (l.includes('ubuntu') || l.includes('debian') || l.includes('linux')) { + return '🐧' + } + if (l.includes('windows')) { + return '🪟' + } + if (l.includes('macos') || l.includes('darwin')) { + return '🍎' + } + if (l.includes('freebsd') || l.includes('openbsd')) { + return '😈' + } + return '' +} + +function relativeTime(thenSec: number, nowMs = Date.now()): string { + const diffSec = Math.max(0, Math.floor(nowMs / 1000) - thenSec) + if (diffSec < 60) { + return `${diffSec}s ago` + } + if (diffSec < 3600) { + return `${Math.floor(diffSec / 60)}m ago` + } + if (diffSec < 86_400) { + return `${Math.floor(diffSec / 3600)}h ago` + } + return `${Math.floor(diffSec / 86_400)}d ago` +} + +export function UptimeCell({ server }: { server: ServerMetrics }) { + const { t } = useTranslation(['servers']) + const emoji = osEmoji(server.os) + if (!server.online) { + return ( +
+ {t('offline_label')} + + {t('last_seen_ago', { time: relativeTime(server.last_active) })} + +
+ ) + } + return ( +
+ + + {server.os && ( + + {emoji && {emoji}} + {server.os} + + )} +
+ ) +} + +export function NameCell({ server }: { server: ServerMetrics }) { + const flag = countryCodeToFlag(server.country_code) + return ( +
+ + {flag && {flag}} + {server.name} + + +
+ ) +} From d227c130803b505e0870db3498244b2f84c84e6f Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 09:32:22 +0800 Subject: [PATCH 40/43] feat(web): servers table adopts new cells with status-dot column --- .../_authed/servers/index.cells.test.tsx | 121 ++++++++++-------- .../routes/_authed/servers/index.cells.tsx | 23 ++-- apps/web/src/routes/_authed/servers/index.tsx | 72 ++++------- 3 files changed, 111 insertions(+), 105 deletions(-) diff --git a/apps/web/src/routes/_authed/servers/index.cells.test.tsx b/apps/web/src/routes/_authed/servers/index.cells.test.tsx index c9c19774..5335df57 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.test.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.test.tsx @@ -1,7 +1,8 @@ import { render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ServerMetrics } from '@/hooks/use-servers-ws' -import { MetricBarRow } from './index.cells' +import type { TrafficOverviewItem } from '@/hooks/use-traffic-overview' +import { CpuCell, DiskCell, MemoryCell, MetricBarRow, NameCell, NetworkCell, UptimeCell } from './index.cells' vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) @@ -15,7 +16,34 @@ vi.mock('@tanstack/react-router', () => ({ ) })) -export function makeServer(overrides: Partial = {}): ServerMetrics { +const REGEX_BG_EMERALD = /bg-emerald-500/ +const REGEX_BG_AMBER = /bg-amber-500/ +const REGEX_BG_RED = /bg-red-500/ +const REGEX_CPU_CORES_LOAD = /8 cores · load 1\.23/ +const REGEX_CORES = /cores/ +const REGEX_LOAD_1_23 = /load 1\.23/ +const REGEX_LOAD = /load/ +const REGEX_MEM_USED_TOTAL = /7\.2 GB \/ 16\.0 GB/ +const REGEX_SWAP = /swap/ +const REGEX_3_PCT = /3%/ +const REGEX_SWAP_0_PCT = /swap 0%/ +const REGEX_DISK_READ = /2\.0 MB\/s/ +const REGEX_DISK_WRITE = /500\.0 KB\/s/ +const REGEX_KB_PER_SEC = /KB\/s/ +const REGEX_MB_PER_SEC = /MB\/s/ +const REGEX_TRAFFIC_USED_LIMIT = /93\.2 GB \/ 1\.0 TB/ +const REGEX_TRAFFIC_DOWN = /1\.1 MB\/s/ +const REGEX_TRAFFIC_UP = /332\.0 KB\/s/ +const REGEX_TRAFFIC_FALLBACK = /3\.0 GB \/ 1\.0 TB/ +const REGEX_TRAFFIC_OFFLINE_PCT = /10%/ +const REGEX_TRAFFIC_OFFLINE_USAGE = /100\.0 GB \/ 1\.0 TB/ +const REGEX_LIMIT_DEFAULT = /1\.0 TB/ +const REGEX_UPTIME_23D = /23d/ +const REGEX_OS_UBUNTU = /Ubuntu 22\.04/ +const REGEX_OFFLINE = /offline/i +const REGEX_LAST_SEEN = /last_seen_ago/ + +function makeServer(overrides: Partial = {}): ServerMetrics { return { id: 'srv-1', name: 'test-server', @@ -57,19 +85,19 @@ describe('MetricBarRow', () => { it('renders green bar below 70%', () => { const { container } = render() const fill = container.querySelector('[data-slot="metric-bar-fill"]') - expect(fill?.className).toMatch(/bg-emerald-500/) + expect(fill?.className).toMatch(REGEX_BG_EMERALD) }) it('renders amber bar at 70% and below 90%', () => { const { container } = render() const fill = container.querySelector('[data-slot="metric-bar-fill"]') - expect(fill?.className).toMatch(/bg-amber-500/) + expect(fill?.className).toMatch(REGEX_BG_AMBER) }) it('renders red bar at 90%+', () => { const { container } = render() const fill = container.querySelector('[data-slot="metric-bar-fill"]') - expect(fill?.className).toMatch(/bg-red-500/) + expect(fill?.className).toMatch(REGEX_BG_RED) }) it('rounds the percentage to 0 decimals', () => { @@ -90,30 +118,26 @@ describe('MetricBarRow', () => { }) }) -import { CpuCell } from './index.cells' - describe('CpuCell', () => { it('renders cores + load when cpu_cores is present', () => { render() expect(screen.getByText('12%')).toBeDefined() - expect(screen.getByText(/8 cores · load 1\.23/)).toBeDefined() + expect(screen.getByText(REGEX_CPU_CORES_LOAD)).toBeDefined() }) it('falls back to load-only when cpu_cores is null (Phase A)', () => { render() - expect(screen.queryByText(/cores/)).toBeNull() - expect(screen.getByText(/load 1\.23/)).toBeDefined() + expect(screen.queryByText(REGEX_CORES)).toBeNull() + expect(screen.getByText(REGEX_LOAD_1_23)).toBeDefined() }) it('hides sub-line when offline', () => { render() - expect(screen.queryByText(/cores/)).toBeNull() - expect(screen.queryByText(/load/)).toBeNull() + expect(screen.queryByText(REGEX_CORES)).toBeNull() + expect(screen.queryByText(REGEX_LOAD)).toBeNull() }) }) -import { MemoryCell } from './index.cells' - describe('MemoryCell', () => { it('renders used/total + swap pct', () => { render( @@ -126,24 +150,22 @@ describe('MemoryCell', () => { })} /> ) - expect(screen.getByText(/7\.2 GB \/ 16\.0 GB/)).toBeDefined() - expect(screen.getByText(/swap/)).toBeDefined() - expect(screen.getByText(/3%/)).toBeDefined() + expect(screen.getByText(REGEX_MEM_USED_TOTAL)).toBeDefined() + expect(screen.getByText(REGEX_SWAP)).toBeDefined() + expect(screen.getByText(REGEX_3_PCT)).toBeDefined() }) it('renders 0% swap when swap_total is 0', () => { render() - expect(screen.getByText(/swap 0%/)).toBeDefined() + expect(screen.getByText(REGEX_SWAP_0_PCT)).toBeDefined() }) it('hides sub-line when offline', () => { render() - expect(screen.queryByText(/swap/)).toBeNull() + expect(screen.queryByText(REGEX_SWAP)).toBeNull() }) }) -import { DiskCell } from './index.cells' - describe('DiskCell', () => { it('shows usage bar + r/w speeds when online', () => { render( @@ -158,17 +180,15 @@ describe('DiskCell', () => { /> ) expect(screen.getByText('60%')).toBeDefined() - expect(screen.getByText(/2\.0 MB\/s/)).toBeDefined() - expect(screen.getByText(/500\.0 KB\/s/)).toBeDefined() + expect(screen.getByText(REGEX_DISK_READ)).toBeDefined() + expect(screen.getByText(REGEX_DISK_WRITE)).toBeDefined() }) it('hides r/w sub when offline', () => { render( - + ) - expect(screen.queryByText(/KB\/s/)).toBeNull() + expect(screen.queryByText(REGEX_KB_PER_SEC)).toBeNull() }) it('renders 0% when disk_total is 0', () => { @@ -177,9 +197,6 @@ describe('DiskCell', () => { }) }) -import type { TrafficOverviewItem } from '@/hooks/use-traffic-overview' -import { NetworkCell } from './index.cells' - const GB = 1024 ** 3 const TB = 1024 ** 4 @@ -206,9 +223,9 @@ describe('NetworkCell', () => { /> ) expect(screen.getByText('9%')).toBeDefined() - expect(screen.getByText(/93\.2 GB \/ 1\.0 TB/)).toBeDefined() - expect(screen.getByText(/1\.1 MB\/s/)).toBeDefined() - expect(screen.getByText(/332\.0 KB\/s/)).toBeDefined() + expect(screen.getByText(REGEX_TRAFFIC_USED_LIMIT)).toBeDefined() + expect(screen.getByText(REGEX_TRAFFIC_DOWN)).toBeDefined() + expect(screen.getByText(REGEX_TRAFFIC_UP)).toBeDefined() }) it('falls back to net_in_transfer + 1 TiB default when entry is undefined', () => { @@ -220,7 +237,7 @@ describe('NetworkCell', () => { ) // 3 GB / 1 TiB ≈ 0.29% → rounds to 0% expect(screen.getByText('0%')).toBeDefined() - expect(screen.getByText(/3\.0 GB \/ 1\.0 TB/)).toBeDefined() + expect(screen.getByText(REGEX_TRAFFIC_FALLBACK)).toBeDefined() }) it('renders traffic-quota bar even when offline (server-level data)', () => { @@ -230,20 +247,18 @@ describe('NetworkCell', () => { server={makeServer({ online: false })} /> ) - expect(screen.getByText(/10%/)).toBeDefined() - expect(screen.getByText(/100\.0 GB \/ 1\.0 TB/)).toBeDefined() - expect(screen.queryByText(/MB\/s/)).toBeNull() - expect(screen.queryByText(/KB\/s/)).toBeNull() + expect(screen.getByText(REGEX_TRAFFIC_OFFLINE_PCT)).toBeDefined() + expect(screen.getByText(REGEX_TRAFFIC_OFFLINE_USAGE)).toBeDefined() + expect(screen.queryByText(REGEX_MB_PER_SEC)).toBeNull() + expect(screen.queryByText(REGEX_KB_PER_SEC)).toBeNull() }) it('treats traffic_limit <= 0 as fallback to default', () => { render() - expect(screen.getByText(/1\.0 TB/)).toBeDefined() + expect(screen.getByText(REGEX_LIMIT_DEFAULT)).toBeDefined() }) }) -import { NameCell, UptimeCell } from './index.cells' - describe('UptimeCell', () => { const NOW = 1_700_000_000 const _originalNow = Date.now @@ -256,22 +271,18 @@ describe('UptimeCell', () => { it('shows uptime + OS line when online', () => { render( - + ) - expect(screen.getByText(/23d/)).toBeDefined() - expect(screen.getByText(/Ubuntu 22\.04/)).toBeDefined() + expect(screen.getByText(REGEX_UPTIME_23D)).toBeDefined() + expect(screen.getByText(REGEX_OS_UBUNTU)).toBeDefined() }) it('shows offline + last-seen relative when offline', () => { render( - + ) - expect(screen.getByText(/offline/i)).toBeDefined() - expect(screen.getByText(/last_seen_ago/)).toBeDefined() + expect(screen.getByText(REGEX_OFFLINE)).toBeDefined() + expect(screen.getByText(REGEX_LAST_SEEN)).toBeDefined() }) }) @@ -288,3 +299,11 @@ describe('NameCell', () => { expect(screen.getByText('web')).toBeDefined() }) }) + +describe('NameCell rightSlot', () => { + it('renders the rightSlot next to the server name', () => { + render(} server={makeServer({ name: 'web-01' })} />) + expect(screen.getByTestId('slot')).toBeDefined() + expect(screen.getByText('web-01')).toBeDefined() + }) +}) diff --git a/apps/web/src/routes/_authed/servers/index.cells.tsx b/apps/web/src/routes/_authed/servers/index.cells.tsx index 3e9022c0..72c2647e 100644 --- a/apps/web/src/routes/_authed/servers/index.cells.tsx +++ b/apps/web/src/routes/_authed/servers/index.cells.tsx @@ -219,19 +219,22 @@ export function UptimeCell({ server }: { server: ServerMetrics }) { ) } -export function NameCell({ server }: { server: ServerMetrics }) { +export function NameCell({ server, rightSlot }: { rightSlot?: ReactNode; server: ServerMetrics }) { const flag = countryCodeToFlag(server.country_code) return (
- - {flag && {flag}} - {server.name} - +
+ + {flag && {flag}} + {server.name} + + {rightSlot} +
) diff --git a/apps/web/src/routes/_authed/servers/index.tsx b/apps/web/src/routes/_authed/servers/index.tsx index f746e8d6..5e04f0ba 100644 --- a/apps/web/src/routes/_authed/servers/index.tsx +++ b/apps/web/src/routes/_authed/servers/index.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { createFileRoute, Link } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' import type { ColumnDef } from '@tanstack/react-table' import { CircleDot, ExternalLink, LayoutGrid, Search, Table2, Tag, Trash2 } from 'lucide-react' import { useMemo, useState } from 'react' @@ -10,7 +10,7 @@ import { DataTableColumnHeader } from '@/components/data-table/data-table-column import { DataTableToolbar } from '@/components/data-table/data-table-toolbar' import { ServerCard } from '@/components/server/server-card' import { ServerEditDialog } from '@/components/server/server-edit-dialog' -import { StatusBadge } from '@/components/server/status-badge' +import { StatusDot } from '@/components/server/status-dot' import { UpgradeJobBadge } from '@/components/server/upgrade-job-badge' import { AlertDialog, @@ -31,12 +31,12 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { useServer } from '@/hooks/use-api' import { useDataTable } from '@/hooks/use-data-table' import type { ServerMetrics } from '@/hooks/use-servers-ws' +import { useTrafficOverview } from '@/hooks/use-traffic-overview' import { api } from '@/lib/api-client' import type { ServerGroup } from '@/lib/api-schema' import { countCleanupCandidates } from '@/lib/orphan-server-utils' -import { countryCodeToFlag, formatUptime } from '@/lib/utils' import { useUpgradeJobsStore } from '@/stores/upgrade-jobs-store' -import { CpuCell, DiskCell, MemoryCell, NetworkCell } from './index.cells' +import { CpuCell, DiskCell, MemoryCell, NameCell, NetworkCell, UptimeCell } from './index.cells' function UpgradeBadgeCell({ serverId }: { serverId: string }) { const job = useUpgradeJobsStore((state) => state.jobs.get(serverId)) @@ -92,6 +92,8 @@ function ServersListPage() { staleTime: 60_000 }) + const { data: trafficOverview = [] } = useTrafficOverview() + const setSearch = (value: string) => navigate({ search: (prev) => ({ ...prev, q: value }) }) const [editingId, setEditingId] = useState(null) @@ -152,46 +154,32 @@ function ServersListPage() { meta: { className: 'w-9' } }, { - accessorKey: 'name', - id: 'name', - header: ({ column }) => , - cell: ({ row }) => { - const s = row.original - const flag = countryCodeToFlag(s.country_code) - return ( -
- - {flag && {flag}} - {s.name} - - -
- ) - }, - size: 260, - meta: { className: 'min-w-[200px]' } - }, - { - id: 'status', + id: 'status-dot', accessorFn: (row) => (row.online ? 'online' : 'offline'), - header: ({ column }) => , - cell: ({ row }) => , + enableSorting: false, + header: () => null, + cell: ({ row }) => , filterFn: arrayIncludesFilter, enableColumnFilter: true, - size: 84, + size: 36, meta: { - className: 'w-[84px]', + className: 'w-9', label: t('col_status'), variant: 'select', options: statusOptions, icon: CircleDot } }, + { + accessorKey: 'name', + id: 'name', + header: ({ column }) => , + cell: ({ row }) => ( + } server={row.original} /> + ), + size: 260, + meta: { className: 'min-w-[200px]' } + }, { accessorKey: 'cpu', id: 'cpu', @@ -220,7 +208,10 @@ function ServersListPage() { id: 'network', enableSorting: false, header: () => {t('col_network')}, - cell: ({ row }) => , + cell: ({ row }) => { + const entry = trafficOverview.find((e) => e.server_id === row.original.id) + return + }, size: 160, meta: { className: 'hidden lg:table-cell lg:w-[160px]' } }, @@ -228,14 +219,7 @@ function ServersListPage() { accessorKey: 'uptime', id: 'uptime', header: ({ column }) => , - cell: ({ row }) => { - const s = row.original - return ( - - {s.online ? formatUptime(s.uptime) : '-'} - - ) - }, + cell: ({ row }) => , size: 100, meta: { className: 'hidden xl:table-cell xl:w-[100px]' } }, @@ -279,7 +263,7 @@ function ServersListPage() { meta: { className: 'w-10' } } ], - [t, groupMap, groupOptions, statusOptions] + [t, groupMap, groupOptions, statusOptions, trafficOverview] ) const { table } = useDataTable({ From 452829ec9d65e6346dab0a633082b59b11baa691 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 09:35:04 +0800 Subject: [PATCH 41/43] style(web): satisfy ultracite lint on new servers-table primitives --- apps/web/src/components/server/status-dot.test.tsx | 12 ++++++++---- apps/web/src/components/server/status-dot.tsx | 4 +--- apps/web/src/components/server/tag-chip.tsx | 5 +++-- apps/web/src/hooks/use-server-tags.test.tsx | 12 +++++++----- apps/web/src/hooks/use-servers-ws.ts | 3 +-- apps/web/src/lib/traffic.ts | 2 +- 6 files changed, 21 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/server/status-dot.test.tsx b/apps/web/src/components/server/status-dot.test.tsx index 451d8254..818686be 100644 --- a/apps/web/src/components/server/status-dot.test.tsx +++ b/apps/web/src/components/server/status-dot.test.tsx @@ -2,18 +2,22 @@ import { render } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { StatusDot } from './status-dot' +const ANIMATE_PULSE_RE = /animate-pulse/ +const BG_EMERALD_RE = /bg-emerald-500/ +const BG_MUTED_RE = /bg-muted-foreground/ + describe('StatusDot', () => { it('renders pulsing emerald dot when online', () => { const { container } = render() const el = container.querySelector('[data-slot="status-dot"]') - expect(el?.className).toMatch(/animate-pulse/) - expect(el?.className).toMatch(/bg-emerald-500/) + expect(el?.className).toMatch(ANIMATE_PULSE_RE) + expect(el?.className).toMatch(BG_EMERALD_RE) }) it('renders muted dot without pulse when offline', () => { const { container } = render() const el = container.querySelector('[data-slot="status-dot"]') - expect(el?.className).not.toMatch(/animate-pulse/) - expect(el?.className).toMatch(/bg-muted-foreground/) + expect(el?.className).not.toMatch(ANIMATE_PULSE_RE) + expect(el?.className).toMatch(BG_MUTED_RE) }) }) diff --git a/apps/web/src/components/server/status-dot.tsx b/apps/web/src/components/server/status-dot.tsx index 23c5b03a..deaf695b 100644 --- a/apps/web/src/components/server/status-dot.tsx +++ b/apps/web/src/components/server/status-dot.tsx @@ -11,9 +11,7 @@ export function StatusDot({ online, className }: StatusDotProps) { aria-label={online ? 'online' : 'offline'} className={cn( 'inline-block size-2 rounded-full', - online - ? 'bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.18)] animate-pulse' - : 'bg-muted-foreground/60', + online ? 'animate-pulse bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.18)]' : 'bg-muted-foreground/60', className )} data-slot="status-dot" diff --git a/apps/web/src/components/server/tag-chip.tsx b/apps/web/src/components/server/tag-chip.tsx index 262766b8..101b54e8 100644 --- a/apps/web/src/components/server/tag-chip.tsx +++ b/apps/web/src/components/server/tag-chip.tsx @@ -12,7 +12,8 @@ const PALETTE = [ function hashTag(tag: string): number { let h = 0 for (let i = 0; i < tag.length; i++) { - h = (h * 31 + tag.charCodeAt(i)) | 0 + h = Math.imul(h, 31) + tag.charCodeAt(i) + h = Math.trunc(h) } return Math.abs(h) % PALETTE.length } @@ -31,7 +32,7 @@ export function TagChipRow({ tags, className }: TagChipRowProps) { {tags.map((tag) => ( { describe('useUpdateServerTags', () => { it('PUTs tags and patches both caches on success', async () => { - vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (_input, init) => { + vi.spyOn(globalThis, 'fetch').mockImplementationOnce((_input, init) => { const body = JSON.parse((init as RequestInit).body as string) as { tags: string[] } - return new Response(JSON.stringify({ data: [...body.tags].sort() }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }) + return Promise.resolve( + new Response(JSON.stringify({ data: [...body.tags].sort() }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) }) const { qc, Wrapper } = harness() qc.setQueryData(['server-tags', 'srv-1'], ['old']) diff --git a/apps/web/src/hooks/use-servers-ws.ts b/apps/web/src/hooks/use-servers-ws.ts index 040ff6a6..a64e88e2 100644 --- a/apps/web/src/hooks/use-servers-ws.ts +++ b/apps/web/src/hooks/use-servers-ws.ts @@ -108,8 +108,7 @@ export function mergeServerUpdate(prev: ServerMetrics[], incoming: ServerMetrics const merged = { ...updated[idx] } for (const [key, value] of Object.entries(server)) { const isStaticDefault = - STATIC_FIELDS.has(key) && - (value === null || value === 0 || (Array.isArray(value) && value.length === 0)) + STATIC_FIELDS.has(key) && (value === null || value === 0 || (Array.isArray(value) && value.length === 0)) if (!isStaticDefault) { ;(merged as Record)[key] = value } diff --git a/apps/web/src/lib/traffic.ts b/apps/web/src/lib/traffic.ts index 9f3c4203..d4696594 100644 --- a/apps/web/src/lib/traffic.ts +++ b/apps/web/src/lib/traffic.ts @@ -3,9 +3,9 @@ import type { TrafficOverviewItem } from '@/hooks/use-traffic-overview' export const DEFAULT_TRAFFIC_LIMIT_BYTES = 1024 ** 4 export interface TrafficQuota { - used: number limit: number pct: number + used: number } interface ComputeInput { From 9c84b796443e8f3fb3b64ec6819342cc98240b84 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 09:35:43 +0800 Subject: [PATCH 42/43] test(servers): manual QA checklist for table row redesign --- tests/servers/table-row-visual-redesign.md | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/servers/table-row-visual-redesign.md diff --git a/tests/servers/table-row-visual-redesign.md b/tests/servers/table-row-visual-redesign.md new file mode 100644 index 00000000..29e7cbc4 --- /dev/null +++ b/tests/servers/table-row-visual-redesign.md @@ -0,0 +1,25 @@ +# Manual QA — Servers Table Row Visual Redesign + +**Spec:** `docs/superpowers/specs/2026-04-17-servers-table-row-visual-redesign-design.md` + +## Prereqs +- Admin login. +- At least 3 servers with mixed state: online + traffic configured, online + no traffic limit, offline. + +## Checks + +- [ ] `/servers?view=table` — first column renders a pulsing green dot for online, a muted grey dot for offline. +- [ ] The text-badge `Status` column is gone; the filter pill in the toolbar still offers `Online/Offline`. +- [ ] CPU cell: bar + `%` on top (colored by threshold), `{N} cores · load X.XX` below. If `cpu_cores` is not yet exposed (legacy agent), falls back to `load X.XX`. +- [ ] Memory cell: `{used} / {total} · swap X%`. Swap color reflects threshold. +- [ ] Disk cell: bar + `%`, `↓ {read} ↑ {write}` below. +- [ ] Network cell: traffic quota bar (uses `/api/traffic/overview`), `{used} / {limit} · ↓in ↑out`. If no quota configured, uses 1 TiB fallback. +- [ ] Offline row: metric cells show `—`, Network quota bar still visible, Uptime shows `offline` + `last seen Xh ago`. Tag chips still visible. +- [ ] Name cell: flag + name + UpgradeBadge on line 1, tag chips on line 2 when tags are set. +- [ ] Edit dialog: type `prod, db, web` → save → chips appear in the row. +- [ ] Edit dialog validations: 9 tags / 17-char tag / `has space` → error toast, no PUT fires. +- [ ] Edit dialog client-validation blocks submit: set a name + client-invalid tags (e.g. `bad space` or 17-char tag) → a validation toast fires, **no PATCH and no PUT are issued** (verify in browser devtools Network tab), the dialog stays open. +- [ ] Edit dialog partial failure (PATCH ok, PUT fails): set a name + valid tags, force `PUT /api/servers/:id/tags` to return 500 (e.g. via browser devtools "block request URL" or a mock worker) → PATCH persists (server list shows the new name after dialog closes), tag input reverts to the last-known tags, `tags_save_failed` toast fires, dialog stays open. +- [ ] Breakpoints: network column hides below `lg:`, group/uptime hide below `xl:`. +- [ ] Viewport 1920×963 screenshot matches spec mockup proportions. +- [ ] `bun run test` green; `cargo test --workspace` green; `cargo clippy --workspace -- -D warnings` clean; `bun x ultracite check` clean; `bun run typecheck` clean. From effa53044e3547bcc9bb09694abb31d41add57bc Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Fri, 17 Apr 2026 19:15:26 +0800 Subject: [PATCH 43/43] fix(web): add disk I/O fields to test factories for strict tsc build --- apps/web/src/components/dashboard/widgets/stat-number.test.tsx | 2 ++ apps/web/src/components/server/server-card.test.tsx | 2 ++ apps/web/src/hooks/use-realtime-metrics.test.tsx | 2 ++ apps/web/src/hooks/use-servers-ws.test.ts | 2 ++ 4 files changed, 8 insertions(+) diff --git a/apps/web/src/components/dashboard/widgets/stat-number.test.tsx b/apps/web/src/components/dashboard/widgets/stat-number.test.tsx index 8290b2ef..b9fc64fe 100644 --- a/apps/web/src/components/dashboard/widgets/stat-number.test.tsx +++ b/apps/web/src/components/dashboard/widgets/stat-number.test.tsx @@ -42,6 +42,8 @@ function makeServer(id: string, overrides: Partial = {}): ServerM 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, diff --git a/apps/web/src/components/server/server-card.test.tsx b/apps/web/src/components/server/server-card.test.tsx index ab179d06..0e5471a2 100644 --- a/apps/web/src/components/server/server-card.test.tsx +++ b/apps/web/src/components/server/server-card.test.tsx @@ -63,6 +63,8 @@ function makeServer(overrides: Partial[0]['server' mem_total: 8_589_934_592, disk_used: 21_474_836_480, disk_total: 53_687_091_200, + disk_read_bytes_per_sec: 0, + disk_write_bytes_per_sec: 0, swap_used: 536_870_912, swap_total: 2_147_483_648, load1: 0.72, diff --git a/apps/web/src/hooks/use-realtime-metrics.test.tsx b/apps/web/src/hooks/use-realtime-metrics.test.tsx index 274884c6..de7b756c 100644 --- a/apps/web/src/hooks/use-realtime-metrics.test.tsx +++ b/apps/web/src/hooks/use-realtime-metrics.test.tsx @@ -10,8 +10,10 @@ function makeMetrics(overrides: Partial = {}): ServerMetrics { cpu: 50, cpu_name: 'Intel i7', country_code: 'US', + disk_read_bytes_per_sec: 0, disk_total: 500_000_000_000, disk_used: 100_000_000_000, + disk_write_bytes_per_sec: 0, group_id: 'g1', id: 's1', last_active: 1_710_500_000, diff --git a/apps/web/src/hooks/use-servers-ws.test.ts b/apps/web/src/hooks/use-servers-ws.test.ts index 4bd543d5..bfc65a5b 100644 --- a/apps/web/src/hooks/use-servers-ws.test.ts +++ b/apps/web/src/hooks/use-servers-ws.test.ts @@ -17,6 +17,8 @@ function makeServer(overrides: Partial = {}): ServerMetrics { swap_total: 4_000_000_000, disk_used: 100_000_000_000, disk_total: 500_000_000_000, + disk_read_bytes_per_sec: 0, + disk_write_bytes_per_sec: 0, net_in_speed: 1000, net_out_speed: 500, net_in_transfer: 10_000,