` 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 }) =>