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)}
+```
+
+### 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
```
+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 (
+
+ )
+}
+```
+
+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 && (
-
+ )
+}
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 (
+
+ )
+}
+```
+
+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
+
+ {mutation.isPending || tagsMutation.isPending ? t('common:saving') : t('common:save')}
+
+```
+
+- [ ] **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 (
+