Files
Void-Homelab/docs/superpowers/plans/2026-06-02-void-v2-plan6-sacred-valley.md
root 629b42f502 docs: Plan 6 implementation plan (Sacred Valley widgets)
23 TDD tasks across 4 phases: grid framework + data cards, reuse cards,
speedtest, Little Blue health band. Verified against repo patterns (validate,
api.put, requireOwner, pg-boss, migrations).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:11:56 +10:00

1923 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan 6 — Sacred Valley Widgets — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the `#/sacred-valley` stub with a working two-band homelab dashboard — draggable data cards + Little Blue's read-only Health band — on Void 2.0's real backend.
**Architecture:** New backend endpoints (`/api/dashboard/layout`, `/api/weather`, `/api/host`, speedtest, `/api/health/*`, `/api/icons`) follow the existing `Router`+`zod`+`asyncWrap`+repo pattern mounted under `agentOrOwner`. Health checks + hourly speedtest run as pg-boss jobs enqueued by `node-cron`. The frontend is the existing no-build vanilla-JS SPA: each card is a self-contained ESM module with a uniform `{id,title,size,mount,start,stop}` contract, rendered into a CSS-grid with hand-rolled drag-to-reorder, layout persisted server-side.
**Tech Stack:** Node 22 (ESM), Express 5, pg-boss, node-cron, Postgres 16, vitest+supertest, vanilla browser ESM. No new runtime deps (config is JSON, not YAML, to avoid adding `js-yaml`).
**Spec:** `docs/superpowers/specs/2026-06-02-void-v2-plan6-sacred-valley-design.md`
**Conventions to follow (verified in repo):**
- Migrations: `lib/db/migrations/NNN_name.sql`, plain SQL, applied in filename order by `lib/db/migrate.js`. Next free numbers: **012, 013, 014**.
- Routes: `export const router = Router()`, validate via `validate({query/body})` (zod), wrap handlers in `asyncWrap`, call a repo in `lib/db/repos/`. Mount in `lib/api/index.js` (already behind `agentOrOwner`). Owner-only: `router.use(requireOwner)` from `lib/api/cap.js`.
- Tests: `tests/api/*.test.js` use `setup()` from `tests/api/helpers.js` (resetDb + migrateUp + `OWNER_TOKEN='test-token'`, `ownerHeaders`). Repo tests in `tests/repos/`. Run a single file: `npx vitest run tests/api/NAME.test.js`.
- Frontend views export `render(main, ctx)`. Build DOM only with `el()/mount()/safeHref()` from `public/dom.js` — never `innerHTML` from data.
- `VERSION` const is `server.js:11`.
---
## Phase 1 — Grid framework, chrome, reorder, persistence (proven on clock/weather/host-perf)
### Task 1: `dashboard_layout` table + repo
**Files:**
- Create: `lib/db/migrations/012_dashboard_layout.sql`
- Create: `lib/db/repos/dashboard_layout.js`
- Test: `tests/repos/dashboard_layout.test.js`
- [ ] **Step 1: Write the migration**
```sql
-- 012_dashboard_layout.sql
-- Single global, owner-scoped dashboard layout. One logical row keyed by a
-- stable owner key (v2 is single-owner; 'owner' is the only key for now).
CREATE TABLE dashboard_layout (
owner_key text PRIMARY KEY DEFAULT 'owner',
card_order jsonb NOT NULL DEFAULT '[]'::jsonb, -- ["clock","weather",...]
hidden jsonb NOT NULL DEFAULT '[]'::jsonb, -- ["speedtest"]
sizes jsonb NOT NULL DEFAULT '{}'::jsonb, -- {"weather":"s"}
updated_at timestamptz NOT NULL DEFAULT now()
);
```
- [ ] **Step 2: Write the failing repo test**
```js
// tests/repos/dashboard_layout.test.js
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as repo from '../../lib/db/repos/dashboard_layout.js';
beforeAll(async () => { await resetDb(); await migrateUp(); });
describe('dashboard_layout repo', () => {
it('returns defaults when unset', async () => {
const l = await repo.get();
expect(l).toEqual({ card_order: [], hidden: [], sizes: {} });
});
it('upserts and reads back', async () => {
await repo.put({ card_order: ['clock', 'weather'], hidden: ['jobs'], sizes: { weather: 's' } });
const l = await repo.get();
expect(l.card_order).toEqual(['clock', 'weather']);
expect(l.hidden).toEqual(['jobs']);
expect(l.sizes).toEqual({ weather: 's' });
});
it('second put overwrites the same single row', async () => {
await repo.put({ card_order: ['host-perf'], hidden: [], sizes: {} });
const l = await repo.get();
expect(l.card_order).toEqual(['host-perf']);
});
});
```
- [ ] **Step 3: Run it — expect FAIL** (`Cannot find module dashboard_layout.js`)
Run: `npx vitest run tests/repos/dashboard_layout.test.js`
- [ ] **Step 4: Implement the repo**
```js
// lib/db/repos/dashboard_layout.js
import { pool } from '../pool.js';
const DEFAULTS = { card_order: [], hidden: [], sizes: {} };
export async function get() {
const { rows } = await pool.query(
`SELECT card_order, hidden, sizes FROM dashboard_layout WHERE owner_key = 'owner'`
);
return rows[0] || { ...DEFAULTS };
}
export async function put({ card_order = [], hidden = [], sizes = {} }) {
await pool.query(
`INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, updated_at)
VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, now())
ON CONFLICT (owner_key) DO UPDATE
SET card_order = EXCLUDED.card_order,
hidden = EXCLUDED.hidden,
sizes = EXCLUDED.sizes,
updated_at = now()`,
[JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes)]
);
return get();
}
```
- [ ] **Step 5: Run — expect PASS.** `npx vitest run tests/repos/dashboard_layout.test.js`
- [ ] **Step 6: Commit**
```bash
git add lib/db/migrations/012_dashboard_layout.sql lib/db/repos/dashboard_layout.js tests/repos/dashboard_layout.test.js
git commit -m "feat(dashboard): dashboard_layout table + repo"
```
---
### Task 2: `/api/dashboard/layout` route (owner-only GET/PUT)
**Files:**
- Create: `lib/api/routes/dashboard.js`
- Modify: `lib/api/index.js` (import + mount)
- Test: `tests/api/dashboard.test.js`
- [ ] **Step 1: Write the failing test**
```js
// tests/api/dashboard.test.js
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { setup } from './helpers.js';
let app, ownerHeaders;
beforeAll(async () => { ({ app, ownerHeaders } = await setup()); });
describe('dashboard layout api', () => {
it('401 without auth', async () => {
const res = await request(app).get('/api/dashboard/layout');
expect(res.status).toBe(401);
});
it('GET returns defaults', async () => {
const res = await request(app).get('/api/dashboard/layout').set(ownerHeaders);
expect(res.status).toBe(200);
expect(res.body).toEqual({ card_order: [], hidden: [], sizes: {} });
});
it('PUT persists and GET reflects it', async () => {
const body = { card_order: ['clock', 'weather'], hidden: [], sizes: { weather: 's' } };
const put = await request(app).put('/api/dashboard/layout').set(ownerHeaders).send(body);
expect(put.status).toBe(200);
const get = await request(app).get('/api/dashboard/layout').set(ownerHeaders);
expect(get.body.card_order).toEqual(['clock', 'weather']);
expect(get.body.sizes).toEqual({ weather: 's' });
});
it('PUT rejects a bad size value', async () => {
const res = await request(app).put('/api/dashboard/layout').set(ownerHeaders)
.send({ card_order: [], hidden: [], sizes: { weather: 'huge' } });
expect(res.status).toBe(400);
});
});
```
- [ ] **Step 2: Run — expect FAIL** (404 route not found). `npx vitest run tests/api/dashboard.test.js`
- [ ] **Step 3: Implement the route**
```js
// lib/api/routes/dashboard.js
import { Router } from 'express';
import { z } from 'zod';
import { validate } from '../validate.js';
import { asyncWrap } from '../errors.js';
import { requireOwner } from '../cap.js';
import * as repo from '../../db/repos/dashboard_layout.js';
export const router = Router();
router.use(requireOwner);
const layoutSchema = z.object({
card_order: z.array(z.string()).default([]),
hidden: z.array(z.string()).default([]),
sizes: z.record(z.enum(['s', 'm', 'l'])).default({})
});
router.get('/layout', asyncWrap(async (_req, res) => {
res.json(await repo.get());
}));
router.put('/layout',
validate({ body: layoutSchema }),
asyncWrap(async (req, res) => {
res.json(await repo.put(req.body));
})
);
```
- [ ] **Step 4: Mount it** — in `lib/api/index.js`, add the import next to the other route imports and the mount line next to the others:
```js
import { router as dashboardRouter } from './routes/dashboard.js';
// ...
api.use('/dashboard', dashboardRouter);
```
- [ ] **Step 5: Run — expect PASS.** `npx vitest run tests/api/dashboard.test.js`
> Confirmed in repo: `lib/api/validate.js` assigns the parsed body back to `req.body`, so the handler reading `req.body` is correct; a zod failure becomes a `ValidationError` → 400.
- [ ] **Step 6: Commit**
```bash
git add lib/api/routes/dashboard.js lib/api/index.js tests/api/dashboard.test.js
git commit -m "feat(dashboard): owner-only GET/PUT /api/dashboard/layout"
```
---
### Task 3: Refined-B card chrome + theme tokens (CSS)
**Files:**
- Modify: `public/style.css` (append a Sacred Valley section)
No unit test (pure CSS); verified visually in Task 10.
- [ ] **Step 1: Append tokens + chrome to `public/style.css`**
```css
/* ===== Sacred Valley (Plan 6) ===== */
:root {
--lb: #7dd3d8; /* Little Blue cyan */
/* reserved for a future agent-output phase — unused now:
--hue-dross: #ff4f2e; --hue-yerin: #c45a4a; --hue-orthos: #6fa86a; */
}
#sv-cards { display: grid; grid-template-columns: repeat(6, 1fr); gap: 16px; align-items: start; }
.sv-card { grid-column: span 2; } /* m default */
.sv-card[data-size="s"] { grid-column: span 2; }
.sv-card[data-size="m"] { grid-column: span 3; }
.sv-card[data-size="l"] { grid-column: span 6; }
@media (max-width: 900px) { #sv-cards { grid-template-columns: 1fr; } .sv-card { grid-column: 1 / -1 !important; } }
.sv-card {
position: relative; border: 1px solid #2c242a; border-radius: 10px; padding: 16px 18px;
background: radial-gradient(120% 90% at 100% 0%, rgba(255,79,46,.05), transparent 55%),
linear-gradient(160deg, #16131a, #0f0d12);
box-shadow: inset 0 0 30px rgba(255,79,46,.015);
transition: box-shadow .35s ease, border-color .35s ease, transform .35s ease;
}
.sv-card::after {
content: ""; position: absolute; inset: 0; border-radius: 10px; pointer-events: none;
background-image: repeating-linear-gradient(115deg, rgba(255,255,255,.015) 0 1px, transparent 1px 9px);
mix-blend-mode: overlay;
}
.sv-card:hover {
border-color: #4a2c28; transform: translateY(-2px);
box-shadow: 0 8px 28px rgba(0,0,0,.45), inset 0 0 46px rgba(255,79,46,.06), 0 0 0 1px rgba(255,79,46,.10);
}
.sv-card.dragging { opacity: .5; }
.sv-card.drag-over { border-color: var(--accent); }
.sv-card-title {
font-family: var(--font-display); font-size: 12px; letter-spacing: .16em; text-transform: uppercase;
color: var(--text); padding-bottom: 7px; margin-bottom: 12px;
border-bottom: 1px solid transparent; border-image: linear-gradient(90deg, var(--accent), transparent 60%) 1;
cursor: grab;
}
.sv-row { display: flex; justify-content: space-between; align-items: center; font-family: var(--font-mono); font-size: 11px; margin: 7px 0; }
.sv-row .k { color: var(--muted); letter-spacing: .04em; }
.sv-bar { height: 5px; border-radius: 3px; background: #221820; overflow: hidden; margin-top: 3px; }
.sv-bar > i { display: block; height: 100%; border-radius: 3px; background: linear-gradient(90deg, var(--accent-dim), var(--accent)); transition: box-shadow .35s; }
.sv-card:hover .sv-bar > i { box-shadow: 0 0 9px rgba(255,79,46,.55); }
```
- [ ] **Step 2: Commit**
```bash
git add public/style.css
git commit -m "feat(sacred-valley): refined-B card chrome + theme tokens"
```
---
### Task 4: Card factory + view skeleton + scheduler
**Files:**
- Create: `public/components/sv_card.js`
- Create: `public/views/cards/registry.js`
- Rewrite: `public/views/sacred_valley.js`
- Test: `tests/frontend/card_registry.test.js`
- [ ] **Step 1: Write the failing logic test** (registry ordering is pure logic — no DOM)
```js
// tests/frontend/card_registry.test.js
import { describe, it, expect } from 'vitest';
import { orderCards } from '../../public/views/cards/registry.js';
const defs = [{ id: 'clock' }, { id: 'weather' }, { id: 'host-perf' }];
describe('orderCards', () => {
it('uses saved order first, then appends new cards in default order', () => {
const out = orderCards(defs, { card_order: ['weather'], hidden: [] });
expect(out.map(c => c.id)).toEqual(['weather', 'clock', 'host-perf']);
});
it('drops hidden cards', () => {
const out = orderCards(defs, { card_order: [], hidden: ['clock'] });
expect(out.map(c => c.id)).toEqual(['weather', 'host-perf']);
});
it('ignores stale ids in saved order', () => {
const out = orderCards(defs, { card_order: ['gone', 'clock'], hidden: [] });
expect(out.map(c => c.id)).toEqual(['clock', 'weather', 'host-perf']);
});
});
```
- [ ] **Step 2: Run — expect FAIL.** `npx vitest run tests/frontend/card_registry.test.js`
- [ ] **Step 3: Implement the registry**
```js
// public/views/cards/registry.js
// Pure ordering logic (kept DOM-free so it is unit-testable). The card MODULES
// themselves are imported by sacred_valley.js, which passes their defs here.
export function orderCards(defs, layout = { card_order: [], hidden: [] }) {
const byId = new Map(defs.map(d => [d.id, d]));
const hidden = new Set(layout.hidden || []);
const out = [];
for (const id of layout.card_order || []) {
if (byId.has(id) && !hidden.has(id)) { out.push(byId.get(id)); byId.delete(id); }
}
for (const d of defs) {
if (byId.has(d.id) && !hidden.has(d.id)) out.push(d);
}
return out;
}
```
- [ ] **Step 4: Run — expect PASS.** `npx vitest run tests/frontend/card_registry.test.js`
- [ ] **Step 5: Implement the card factory**
```js
// public/components/sv_card.js
import { el } from '../dom.js';
// Builds the refined-B chrome shell and returns { root, body }. The card module
// fills `body` in its mount(); start()/stop() own its refresh timer.
export function svCard(def) {
const body = el('div', { class: 'sv-card-body' });
const root = el('div', { class: 'sv-card', dataset: { size: def.size || 'm', cardId: def.id } },
el('div', { class: 'sv-card-title' }, def.title),
body
);
return { root, body };
}
```
- [ ] **Step 6: Rewrite the view skeleton** (cards mount; health band stub for now)
```js
// public/views/sacred_valley.js
import { el, mount } from '../dom.js';
import { api } from '../api.js';
import { svCard } from '../components/sv_card.js';
import { orderCards } from './cards/registry.js';
import clock from './cards/clock.js';
import weather from './cards/weather.js';
import hostPerf from './cards/host_perf.js';
const CARD_MODULES = [clock, weather, hostPerf]; // grows in later tasks
let active = []; // mounted cards needing stop()
export async function render(main) {
active.forEach(c => c.stop && c.stop()); active = [];
mount(main,
el('h1', { class: 'view-h1' }, 'Sacred Valley'),
el('p', { class: 'view-sub' }, 'The homelab, at a glance.'),
el('div', { id: 'sv-cards' }),
el('div', { id: 'sv-health' })
);
let layout = { card_order: [], hidden: [], sizes: {} };
try { layout = await api.get('/api/dashboard/layout'); } catch { /* defaults */ }
const grid = document.getElementById('sv-cards');
const ordered = orderCards(CARD_MODULES, layout);
for (const def of ordered) {
const size = layout.sizes?.[def.id] || def.size;
const { root, body } = svCard({ ...def, size });
grid.appendChild(root);
try { def.mount(body); def.start && def.start(); active.push(def); }
catch (e) { body.appendChild(el('span', { class: 'muted' }, 'card failed')); console.error(def.id, e); }
}
// health band + drag wiring arrive in Tasks 22 and 10.
}
```
> The view imports `clock`, `weather`, `host_perf` — create empty stubs now so the import resolves, filled in Tasks 5/7/9:
> ```js
> // public/views/cards/clock.js (temporary stub)
> export default { id: 'clock', title: 'Clock', size: 's', mount() {}, start() {}, stop() {} };
> ```
> (and identical stubs for `weather.js` title 'Weather' size 's', `host_perf.js` title 'Host Perf' size 'm').
- [ ] **Step 7: Commit**
```bash
git add public/components/sv_card.js public/views/cards/ public/views/sacred_valley.js tests/frontend/card_registry.test.js
git commit -m "feat(sacred-valley): card factory, registry ordering, view skeleton"
```
---
### Task 5: Clock card
**Files:** Modify `public/views/cards/clock.js`
- [ ] **Step 1: Implement** (Melbourne primary; pure client; 1s tick)
```js
// public/views/cards/clock.js
import { el, mount } from '../../dom.js';
let body, timer;
function fmt(tz) {
return new Intl.DateTimeFormat('en-AU', {
timeZone: tz, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
}).format(new Date());
}
function tick() {
if (!body) return;
mount(body,
el('div', { class: 'sv-row', style: { fontSize: '22px' } },
el('span', { style: { fontFamily: 'var(--font-mono)' } }, fmt('Australia/Melbourne'))),
el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'Melbourne'), el('span', {}, 'AEST/AEDT'))
);
}
export default {
id: 'clock', title: 'Clock', size: 's',
mount(el_) { body = el_; tick(); },
start() { timer = setInterval(tick, 1000); },
stop() { clearInterval(timer); body = null; }
};
```
- [ ] **Step 2: Commit**
```bash
git add public/views/cards/clock.js
git commit -m "feat(card): clock (Melbourne)"
```
---
### Task 6: Weather backend — `/api/weather` (Open-Meteo proxy, 15-min cache)
**Files:**
- Create: `lib/weather.js`
- Create: `lib/api/routes/weather.js`
- Modify: `lib/api/index.js`
- Test: `tests/api/weather.test.js`
- [ ] **Step 1: Write the failing test** (inject a fake fetcher to assert caching)
```js
// tests/api/weather.test.js
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { setup } from './helpers.js';
import * as weather from '../../lib/weather.js';
let app, ownerHeaders;
beforeAll(async () => { ({ app, ownerHeaders } = await setup()); });
beforeEach(() => weather._resetCache());
const SAMPLE = { current: { temperature_2m: 14.2, apparent_temperature: 12.1, relative_humidity_2m: 71, wind_speed_10m: 9, weather_code: 3 } };
describe('weather api', () => {
it('401 without auth', async () => {
expect((await request(app).get('/api/weather')).status).toBe(401);
});
it('returns mapped weather and caches the upstream call', async () => {
const fetcher = vi.fn().mockResolvedValue(SAMPLE);
weather._setFetcher(fetcher);
const r1 = await request(app).get('/api/weather').set(ownerHeaders);
expect(r1.status).toBe(200);
expect(r1.body.temp).toBe(14.2);
expect(r1.body.humidity).toBe(71);
expect(typeof r1.body.label).toBe('string'); // weather_code → text
await request(app).get('/api/weather').set(ownerHeaders); // 2nd hit
expect(fetcher).toHaveBeenCalledTimes(1); // cached
});
});
```
- [ ] **Step 2: Run — expect FAIL.** `npx vitest run tests/api/weather.test.js`
- [ ] **Step 3: Implement `lib/weather.js`**
```js
// lib/weather.js — Melbourne, Open-Meteo, no API key, 15-min cache.
const LAT = -37.81, LON = 144.96, TTL_MS = 15 * 60 * 1000;
const CODES = { 0:'Clear',1:'Mainly clear',2:'Partly cloudy',3:'Overcast',45:'Fog',48:'Rime fog',
51:'Light drizzle',53:'Drizzle',55:'Heavy drizzle',61:'Light rain',63:'Rain',65:'Heavy rain',
71:'Light snow',73:'Snow',75:'Heavy snow',80:'Showers',81:'Showers',82:'Violent showers',
95:'Thunderstorm',96:'Thunderstorm',99:'Thunderstorm' };
let cache = null; // { at, data }
let fetcher = defaultFetcher;
async function defaultFetcher() {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${LAT}&longitude=${LON}` +
`&current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m`;
const res = await fetch(url, { signal: AbortSignal.timeout(6000) });
if (!res.ok) throw new Error(`open-meteo ${res.status}`);
return res.json();
}
export function _setFetcher(fn) { fetcher = fn; }
export function _resetCache() { cache = null; fetcher = defaultFetcher; }
export async function current() {
if (cache && Date.now() - cache.at < TTL_MS) return cache.data;
const raw = await fetcher();
const c = raw.current || {};
const data = {
temp: c.temperature_2m, feels_like: c.apparent_temperature,
humidity: c.relative_humidity_2m, wind: c.wind_speed_10m,
code: c.weather_code, label: CODES[c.weather_code] || 'Unknown'
};
cache = { at: Date.now(), data };
return data;
}
```
- [ ] **Step 4: Implement the route + mount it**
```js
// lib/api/routes/weather.js
import { Router } from 'express';
import { asyncWrap } from '../errors.js';
import * as weather from '../../weather.js';
export const router = Router();
router.get('/', asyncWrap(async (_req, res) => res.json(await weather.current())));
```
In `lib/api/index.js`: `import { router as weatherRouter } from './routes/weather.js';` and `api.use('/weather', weatherRouter);`
- [ ] **Step 5: Run — expect PASS.** `npx vitest run tests/api/weather.test.js`
- [ ] **Step 6: Commit**
```bash
git add lib/weather.js lib/api/routes/weather.js lib/api/index.js tests/api/weather.test.js
git commit -m "feat(weather): /api/weather Open-Meteo proxy with 15-min cache"
```
---
### Task 7: Weather card
**Files:** Modify `public/views/cards/weather.js`
- [ ] **Step 1: Implement** (fetch on mount; refresh every 15 min)
```js
// public/views/cards/weather.js
import { el, mount } from '../../dom.js';
import { api } from '../../api.js';
let body, timer;
async function load() {
if (!body) return;
try {
const w = await api.get('/api/weather');
mount(body,
el('div', { class: 'sv-row', style: { fontSize: '22px' } },
el('span', { style: { fontFamily: 'var(--font-mono)' } }, `${Math.round(w.temp)}°C`),
el('span', { class: 'k' }, w.label)),
el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'Feels like'), el('span', {}, `${Math.round(w.feels_like)}°C`)),
el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'Humidity'), el('span', {}, `${w.humidity}%`)),
el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'Wind'), el('span', {}, `${Math.round(w.wind)} km/h`))
);
} catch { mount(body, el('span', { class: 'muted' }, 'Weather unavailable')); }
}
export default {
id: 'weather', title: 'Weather · Melbourne', size: 's',
mount(el_) { body = el_; load(); },
start() { timer = setInterval(load, 15 * 60 * 1000); },
stop() { clearInterval(timer); body = null; }
};
```
- [ ] **Step 2: Commit**
```bash
git add public/views/cards/weather.js
git commit -m "feat(card): weather"
```
---
### Task 8: Host backend — `/api/host` (CT 311 /proc)
**Files:**
- Create: `lib/host/resources.js`
- Create: `lib/api/routes/host.js`
- Modify: `lib/api/index.js`
- Test: `tests/api/host.test.js`
- [ ] **Step 1: Write the failing test** (shape + ranges; runs on the test box's real /proc)
```js
// tests/api/host.test.js
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { setup } from './helpers.js';
let app, ownerHeaders;
beforeAll(async () => { ({ app, ownerHeaders } = await setup()); });
describe('host api', () => {
it('401 without auth', async () => {
expect((await request(app).get('/api/host')).status).toBe(401);
});
it('returns cpu/mem/disk/net shape', async () => {
const res = await request(app).get('/api/host').set(ownerHeaders);
expect(res.status).toBe(200);
expect(res.body.cpu_pct).toBeGreaterThanOrEqual(0);
expect(res.body.cpu_pct).toBeLessThanOrEqual(100);
expect(res.body.mem.total).toBeGreaterThan(0);
expect(res.body.mem.used).toBeGreaterThanOrEqual(0);
expect(res.body.disk).toHaveProperty('pct');
expect(res.body.net).toHaveProperty('rx_bytes');
});
});
```
- [ ] **Step 2: Run — expect FAIL.** `npx vitest run tests/api/host.test.js`
- [ ] **Step 3: Implement `lib/host/resources.js`**
```js
// lib/host/resources.js — reads CT 311's own /proc + statfs. Node 22 fs.statfs.
import { readFile } from 'node:fs/promises';
import { statfs } from 'node:fs/promises';
async function cpuSample() {
const line = (await readFile('/proc/stat', 'utf8')).split('\n')[0]; // "cpu u n s i ..."
const v = line.trim().split(/\s+/).slice(1).map(Number);
const idle = v[3] + (v[4] || 0);
const total = v.reduce((a, b) => a + b, 0);
return { idle, total };
}
export async function snapshot() {
// CPU%: two samples ~100ms apart.
const a = await cpuSample();
await new Promise(r => setTimeout(r, 100));
const b = await cpuSample();
const dTotal = b.total - a.total, dIdle = b.idle - a.idle;
const cpu_pct = dTotal > 0 ? Math.round((1 - dIdle / dTotal) * 100) : 0;
// Memory from /proc/meminfo (kB).
const mem = Object.fromEntries(
(await readFile('/proc/meminfo', 'utf8')).split('\n').filter(Boolean).map(l => {
const [k, val] = l.split(':'); return [k.trim(), parseInt(val) * 1024];
})
);
const total = mem.MemTotal, avail = mem.MemAvailable ?? mem.MemFree;
const memOut = { total, used: total - avail, pct: Math.round((1 - avail / total) * 100) };
// Disk for / via statfs.
const fs = await statfs('/');
const dTotalB = fs.blocks * fs.bsize, dFree = fs.bavail * fs.bsize;
const disk = { total: dTotalB, free: dFree, pct: Math.round((1 - dFree / dTotalB) * 100) };
// Net totals from /proc/net/dev (sum non-lo interfaces).
let rx = 0, tx = 0;
for (const l of (await readFile('/proc/net/dev', 'utf8')).split('\n')) {
const m = l.match(/^\s*([^:]+):\s+(\d+)(?:\s+\d+){7}\s+(\d+)/);
if (m && m[1].trim() !== 'lo') { rx += Number(m[2]); tx += Number(m[3]); }
}
return { cpu_pct, mem: memOut, disk, net: { rx_bytes: rx, tx_bytes: tx }, at: Date.now() };
}
```
- [ ] **Step 4: Implement the route + mount**
```js
// lib/api/routes/host.js
import { Router } from 'express';
import { asyncWrap } from '../errors.js';
import { snapshot } from '../../host/resources.js';
export const router = Router();
router.get('/', asyncWrap(async (_req, res) => res.json(await snapshot())));
```
`lib/api/index.js`: `import { router as hostRouter } from './routes/host.js';` + `api.use('/host', hostRouter);`
- [ ] **Step 5: Run — expect PASS.** `npx vitest run tests/api/host.test.js`
- [ ] **Step 6: Commit**
```bash
git add lib/host/resources.js lib/api/routes/host.js lib/api/index.js tests/api/host.test.js
git commit -m "feat(host): /api/host CPU/mem/disk/net from /proc"
```
---
### Task 9: Host-perf card
**Files:** Modify `public/views/cards/host_perf.js`
- [ ] **Step 1: Implement** (30s refresh; net rate from successive samples)
```js
// public/views/cards/host_perf.js
import { el, mount } from '../../dom.js';
import { api } from '../../api.js';
let body, timer, prev;
const GB = 1024 ** 3;
function bar(pct) { return el('div', { class: 'sv-bar' }, el('i', { style: { width: Math.min(100, pct) + '%' } })); }
async function load() {
if (!body) return;
try {
const h = await api.get('/api/host');
let rate = '';
if (prev) {
const dt = (h.at - prev.at) / 1000 || 1;
const dn = (b) => ((b) / dt / 1e6).toFixed(1);
rate = `${dn(h.net.rx_bytes - prev.net.rx_bytes)}${dn(h.net.tx_bytes - prev.net.tx_bytes)} MB/s`;
}
prev = h;
mount(body,
el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'CPU'), el('span', {}, h.cpu_pct + '%')), bar(h.cpu_pct),
el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'RAM'),
el('span', {}, `${(h.mem.used / GB).toFixed(1)} / ${(h.mem.total / GB).toFixed(0)} GB`)), bar(h.mem.pct),
el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'DISK'), el('span', {}, h.disk.pct + '%')), bar(h.disk.pct),
el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'NET'), el('span', {}, rate || '—'))
);
} catch { mount(body, el('span', { class: 'muted' }, 'Host unavailable')); }
}
export default {
id: 'host-perf', title: 'Host Perf · CT 311', size: 'm',
mount(el_) { body = el_; prev = null; load(); },
start() { timer = setInterval(load, 30000); },
stop() { clearInterval(timer); body = null; }
};
```
- [ ] **Step 2: Commit**
```bash
git add public/views/cards/host_perf.js
git commit -m "feat(card): host-perf"
```
---
### Task 10: Drag-to-reorder + persistence
**Files:**
- Create: `public/components/sv_reorder.js`
- Modify: `public/views/sacred_valley.js` (wire reorder after mounting cards)
- Test: `tests/frontend/reorder.test.js`
- [ ] **Step 1: Write the failing logic test** (the order-computing helper is pure)
```js
// tests/frontend/reorder.test.js
import { describe, it, expect } from 'vitest';
import { moveId } from '../../public/components/sv_reorder.js';
describe('moveId', () => {
it('moves an id before a target', () => {
expect(moveId(['a', 'b', 'c'], 'c', 'a')).toEqual(['c', 'a', 'b']);
});
it('moving onto itself is a no-op', () => {
expect(moveId(['a', 'b', 'c'], 'b', 'b')).toEqual(['a', 'b', 'c']);
});
it('moving to end when target is null appends', () => {
expect(moveId(['a', 'b', 'c'], 'a', null)).toEqual(['b', 'c', 'a']);
});
});
```
- [ ] **Step 2: Run — expect FAIL.** `npx vitest run tests/frontend/reorder.test.js`
- [ ] **Step 3: Implement `sv_reorder.js`** (pure `moveId` + a DOM wiring function)
```js
// public/components/sv_reorder.js
export function moveId(order, dragId, beforeId) {
const out = order.filter(id => id !== dragId);
if (beforeId == null) { out.push(dragId); return out; }
const i = out.indexOf(beforeId);
if (i < 0) { out.push(dragId); return out; }
out.splice(i, 0, dragId);
return out;
}
// Wires HTML5 drag on .sv-card elements in `grid`. onReorder(newOrderIds) fires
// after a drop. Drag handle = the whole card title (cursor:grab via CSS).
export function attachReorder(grid, onReorder) {
let dragId = null;
grid.querySelectorAll('.sv-card').forEach(card => {
card.draggable = true;
card.addEventListener('dragstart', () => { dragId = card.dataset.cardId; card.classList.add('dragging'); });
card.addEventListener('dragend', () => { card.classList.remove('dragging'); dragId = null; });
card.addEventListener('dragover', e => { e.preventDefault(); card.classList.add('drag-over'); });
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
card.addEventListener('drop', e => {
e.preventDefault(); card.classList.remove('drag-over');
if (!dragId || dragId === card.dataset.cardId) return;
const ids = [...grid.querySelectorAll('.sv-card')].map(c => c.dataset.cardId);
onReorder(moveId(ids, dragId, card.dataset.cardId));
});
});
}
```
- [ ] **Step 4: Run — expect PASS.** `npx vitest run tests/frontend/reorder.test.js`
- [ ] **Step 5: Wire it into the view** — at the end of `render()` in `sacred_valley.js`, after the card loop:
```js
import { attachReorder } from '../components/sv_reorder.js';
// ... after the for-loop that appends cards:
attachReorder(grid, async (newOrder) => {
// reflect immediately
const frag = document.createDocumentFragment();
newOrder.forEach(id => { const n = grid.querySelector(`.sv-card[data-card-id="${id}"]`); if (n) frag.appendChild(n); });
grid.appendChild(frag);
try { await api.put('/api/dashboard/layout', { ...layout, card_order: newOrder }); layout.card_order = newOrder; }
catch (e) { console.error('save layout', e); }
});
```
> Confirmed in repo: `public/api.js` exports `get/post/patch/del` but **no `put`**. Add one line to the exported `api` object: `put: (p, body) => call('PUT', p, body ?? {}),` (mirrors `post`). The internal `call()` already handles any method + JSON body.
- [ ] **Step 6: Manual browser verification.** Run the app (or deploy to a dev box), open `#/sacred-valley`, drag a card by its title onto another, reload — order persists. Confirm clock ticks, weather + host-perf populate.
- [ ] **Step 7: Commit**
```bash
git add public/components/sv_reorder.js public/views/sacred_valley.js public/api.js tests/frontend/reorder.test.js
git commit -m "feat(sacred-valley): drag-to-reorder with server-persisted layout"
```
---
## Phase 2 — Reuse cards (jobs / inbox / search)
> Before writing these, read the existing response shapes: `lib/api/routes/jobs.js` + `lib/db/repos` for jobs, `lib/api/routes/pending_changes.js`, and `public/views/jobs.js` / `public/views/inbox.js` / `public/views/search.js` for how the full views already call them. Match those shapes exactly.
### Task 11: Jobs card
**Files:** Create `public/views/cards/jobs.js`; Modify `public/views/sacred_valley.js` (import + add to `CARD_MODULES`).
- [ ] **Step 1: Implement** (counts by state; 10s refresh; links to `#/jobs`). Use the same endpoint `public/views/jobs.js` uses — confirm its path/shape first and adapt the fields below to match.
```js
// public/views/cards/jobs.js
import { el, mount } from '../../dom.js';
import { api } from '../../api.js';
let body, timer;
async function load() {
if (!body) return;
try {
const jobs = await api.get('/api/jobs'); // adapt to real path/shape
const counts = {};
for (const j of (jobs.items || jobs)) counts[j.state] = (counts[j.state] || 0) + 1;
const rows = ['active', 'created', 'completed', 'failed', 'retry']
.filter(s => counts[s])
.map(s => el('div', { class: 'sv-row' }, el('span', { class: 'k' }, s), el('span', {}, String(counts[s]))));
mount(body,
rows.length ? rows : el('span', { class: 'muted' }, 'No jobs'),
el('a', { href: '#/jobs', class: 'k', style: { display: 'block', marginTop: '8px' } }, 'Open Jobs →')
);
} catch { mount(body, el('span', { class: 'muted' }, 'Jobs unavailable')); }
}
export default {
id: 'jobs', title: 'Capture Queue', size: 'm',
mount(el_) { body = el_; load(); }, start() { timer = setInterval(load, 10000); }, stop() { clearInterval(timer); body = null; }
};
```
- [ ] **Step 2:** Add to `sacred_valley.js`: `import jobs from './cards/jobs.js';` and append `jobs` to `CARD_MODULES`.
- [ ] **Step 3: Manual check** the card renders counts. **Commit:**
```bash
git add public/views/cards/jobs.js public/views/sacred_valley.js
git commit -m "feat(card): jobs / capture queue"
```
---
### Task 12: Inbox card
**Files:** Create `public/views/cards/inbox.js`; Modify `sacred_valley.js`.
- [ ] **Step 1: Implement** (pending-changes count + recent; links `#/inbox`). The app already polls `/api/pending-changes` (see `public/app.js::pollPending`) — reuse it.
```js
// public/views/cards/inbox.js
import { el, mount } from '../../dom.js';
import { api } from '../../api.js';
let body, timer;
async function load() {
if (!body) return;
try {
const rows = await api.get('/api/pending-changes');
mount(body,
el('div', { class: 'sv-row', style: { fontSize: '22px' } },
el('span', { style: { fontFamily: 'var(--font-mono)' } }, String(rows.length)),
el('span', { class: 'k' }, 'awaiting review')),
el('a', { href: '#/inbox', class: 'k', style: { display: 'block', marginTop: '8px' } }, 'Open Inbox →')
);
} catch (e) {
mount(body, el('span', { class: 'muted' }, e.status === 403 ? 'Owner only' : 'Inbox unavailable'));
}
}
export default {
id: 'inbox', title: 'Inbox', size: 's',
mount(el_) { body = el_; load(); }, start() { timer = setInterval(load, 10000); }, stop() { clearInterval(timer); body = null; }
};
```
- [ ] **Step 2:** Wire into `sacred_valley.js` (`import inbox` + append). **Commit:**
```bash
git add public/views/cards/inbox.js public/views/sacred_valley.js
git commit -m "feat(card): inbox"
```
---
### Task 13: Search spotlight card
**Files:** Create `public/views/cards/search.js`; Modify `sacred_valley.js`.
- [ ] **Step 1: Implement** (debounced query → `/api/search`; click result → navigate). Match the result shape used by `public/views/search.js` (each hit has `kind` + `id` + `title`).
```js
// public/views/cards/search.js
import { el, mount } from '../../dom.js';
import { api } from '../../api.js';
import { navigate } from '../../router.js';
const ROUTE = { page: id => '#/page/' + id, ref: id => '#/ref/' + id, source_doc: () => '#/', message: () => '#/' };
let body, input, results, deb;
async function run(q) {
if (!q) { mount(results); return; }
try {
const hits = await api.get('/api/search?q=' + encodeURIComponent(q));
mount(results, (hits || []).slice(0, 6).map(h =>
el('div', { class: 'sv-row', style: { cursor: 'pointer' },
onclick: () => { const r = ROUTE[h.kind]; if (r) navigate(r(h.id)); } },
el('span', {}, h.title || '(untitled)'), el('span', { class: 'k' }, h.kind))
));
} catch { mount(results, el('span', { class: 'muted' }, 'Search failed')); }
}
export default {
id: 'search', title: 'Spotlight', size: 'l',
mount(el_) {
body = el_;
input = el('input', { class: 'sv-search-input', placeholder: 'Search the Void…',
oninput: e => { clearTimeout(deb); const q = e.target.value.trim(); deb = setTimeout(() => run(q), 250); } });
results = el('div', { style: { marginTop: '10px' } });
mount(body, input, results);
},
start() {}, stop() { body = null; }
};
```
Add to `style.css`: `.sv-search-input{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:6px;padding:8px 10px;color:var(--text);font-family:var(--font-mono);font-size:12px}`
- [ ] **Step 2:** Wire into `sacred_valley.js`. **Manual check.** **Commit:**
```bash
git add public/views/cards/search.js public/views/sacred_valley.js public/style.css
git commit -m "feat(card): search spotlight"
```
---
## Phase 3 — Speedtest
### Task 14: `speedtest_results` table + repo
**Files:**
- Create: `lib/db/migrations/013_speedtest.sql`
- Create: `lib/db/repos/speedtest.js`
- Test: `tests/repos/speedtest.test.js`
- [ ] **Step 1: Migration**
```sql
-- 013_speedtest.sql
CREATE TABLE speedtest_results (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
down_mbps numeric NOT NULL,
up_mbps numeric NOT NULL,
ping_ms numeric,
ran_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_speedtest_ran_at ON speedtest_results (ran_at DESC);
```
- [ ] **Step 2: Failing test**
```js
// tests/repos/speedtest.test.js
import { describe, it, expect, beforeAll } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as repo from '../../lib/db/repos/speedtest.js';
beforeAll(async () => { await resetDb(); await migrateUp(); });
describe('speedtest repo', () => {
it('records and lists newest-first', async () => {
await repo.record({ down_mbps: 100, up_mbps: 20, ping_ms: 8 });
await repo.record({ down_mbps: 110, up_mbps: 22, ping_ms: 7 });
const hist = await repo.history(30);
expect(hist.length).toBe(2);
expect(Number(hist[0].down_mbps)).toBe(110); // newest first
});
});
```
- [ ] **Step 3: Run — expect FAIL.** `npx vitest run tests/repos/speedtest.test.js`
- [ ] **Step 4: Implement repo**
```js
// lib/db/repos/speedtest.js
import { pool } from '../pool.js';
export async function record({ down_mbps, up_mbps, ping_ms = null }) {
const { rows } = await pool.query(
`INSERT INTO speedtest_results (down_mbps, up_mbps, ping_ms) VALUES ($1,$2,$3) RETURNING *`,
[down_mbps, up_mbps, ping_ms]);
return rows[0];
}
export async function history(limit = 30) {
const { rows } = await pool.query(
`SELECT * FROM speedtest_results ORDER BY ran_at DESC LIMIT $1`, [limit]);
return rows;
}
```
- [ ] **Step 5: Run — expect PASS.** **Commit:**
```bash
git add lib/db/migrations/013_speedtest.sql lib/db/repos/speedtest.js tests/repos/speedtest.test.js
git commit -m "feat(speedtest): results table + repo"
```
---
### Task 15: Speedtest worker, cron schedule, routes
**Files:**
- Create: `lib/jobs/workers/speedtest.js`
- Modify: `lib/jobs/index.js` (add to WORKERS)
- Modify: `lib/cron/index.js` (hourly enqueue)
- Create: `lib/api/routes/speedtest.js`
- Modify: `lib/api/index.js`
- Test: `tests/api/speedtest.test.js`, `tests/jobs/speedtest_worker.test.js`
- [ ] **Step 1: Worker test** (inject a fake runner so no real network)
```js
// tests/jobs/speedtest_worker.test.js
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as worker from '../../lib/jobs/workers/speedtest.js';
import * as repo from '../../lib/db/repos/speedtest.js';
beforeAll(async () => { await resetDb(); await migrateUp(); });
describe('speedtest worker', () => {
it('runs the CLI runner and records the result', async () => {
worker._setRunner(vi.fn().mockResolvedValue({ down_mbps: 95.5, up_mbps: 18.3, ping_ms: 9 }));
await worker.handler({ id: 'j1', data: {} });
const hist = await repo.history(1);
expect(Number(hist[0].down_mbps)).toBe(95.5);
});
});
```
- [ ] **Step 2: Run — expect FAIL.** `npx vitest run tests/jobs/speedtest_worker.test.js`
- [ ] **Step 3: Implement the worker** (Ookla/`speedtest-cli` JSON; injectable runner)
```js
// lib/jobs/workers/speedtest.js
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import * as repo from '../../db/repos/speedtest.js';
import { log } from '../../log.js';
const pexec = promisify(execFile);
export const NAME = 'speedtest';
// Default runner uses speedtest-cli --json (bits/s → Mbps). Swap binary/flags
// here if the box has the Ookla `speedtest -f json` CLI instead.
async function defaultRunner() {
const { stdout } = await pexec('speedtest-cli', ['--json'], { timeout: 120000 });
const j = JSON.parse(stdout);
return { down_mbps: j.download / 1e6, up_mbps: j.upload / 1e6, ping_ms: j.ping };
}
let runner = defaultRunner;
export function _setRunner(fn) { runner = fn; }
export async function handler(_job) {
const r = await runner();
await repo.record(r);
log.info(r, 'speedtest recorded');
}
```
- [ ] **Step 4: Register the worker** — in `lib/jobs/index.js`: `import * as speedtest from './workers/speedtest.js';` and add `speedtest` to the `WORKERS` array.
- [ ] **Step 5: Schedule hourly** — in `lib/cron/index.js`, inside `startCron()`, add:
```js
import { enqueue } from '../jobs/queue.js';
// ... inside startCron():
cron.schedule('0 * * * *', async () => {
try { await enqueue('speedtest', {}); log.info('cron speedtest enqueued'); }
catch (e) { log.error({ err: e }, 'cron speedtest failed'); }
});
```
- [ ] **Step 6: API test**
```js
// tests/api/speedtest.test.js
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { setup } from './helpers.js';
import * as repo from '../../lib/db/repos/speedtest.js';
let app, ownerHeaders;
beforeAll(async () => { ({ app, ownerHeaders } = await setup()); await repo.record({ down_mbps: 50, up_mbps: 10, ping_ms: 12 }); });
describe('speedtest api', () => {
it('401 without auth', async () => expect((await request(app).get('/api/speedtest/history')).status).toBe(401));
it('history returns rows', async () => {
const res = await request(app).get('/api/speedtest/history').set(ownerHeaders);
expect(res.status).toBe(200);
expect(res.body.length).toBeGreaterThanOrEqual(1);
});
});
```
- [ ] **Step 7: Implement routes + mount** (`run` is owner-only, enqueues a job)
```js
// lib/api/routes/speedtest.js
import { Router } from 'express';
import { asyncWrap } from '../errors.js';
import { requireOwner } from '../cap.js';
import * as repo from '../../db/repos/speedtest.js';
import { enqueue } from '../../jobs/queue.js';
export const router = Router();
router.get('/history', asyncWrap(async (_req, res) => res.json(await repo.history(30))));
router.post('/run', requireOwner, asyncWrap(async (_req, res) => {
const id = await enqueue('speedtest', {});
res.status(202).json({ enqueued: id });
}));
```
`lib/api/index.js`: `import { router as speedtestRouter } from './routes/speedtest.js';` + `api.use('/speedtest', speedtestRouter);`
- [ ] **Step 8: Run both test files — expect PASS.** `npx vitest run tests/jobs/speedtest_worker.test.js tests/api/speedtest.test.js`
- [ ] **Step 9: Commit**
```bash
git add lib/jobs/workers/speedtest.js lib/jobs/index.js lib/cron/index.js lib/api/routes/speedtest.js lib/api/index.js tests/jobs/speedtest_worker.test.js tests/api/speedtest.test.js
git commit -m "feat(speedtest): worker + hourly cron + history/run routes"
```
> **Deploy note (record in deploy/README.md in Task 23):** the worker box (CT 311) needs `speedtest-cli` installed (`pip install --break-system-packages speedtest-cli`) or the Ookla CLI; otherwise `speedtest` jobs fail (card still renders history).
---
### Task 16: Speedtest card
**Files:** Create `public/views/cards/speedtest.js`; Modify `sacred_valley.js`.
- [ ] **Step 1: Implement** (latest figure + sparkline bars from history; "Run" button)
```js
// public/views/cards/speedtest.js
import { el, mount } from '../../dom.js';
import { api } from '../../api.js';
let body;
async function load() {
if (!body) return;
try {
const hist = await api.get('/api/speedtest/history');
const latest = hist[0];
const max = Math.max(1, ...hist.map(h => Number(h.down_mbps)));
const bars = el('div', { style: { display: 'flex', gap: '2px', alignItems: 'flex-end', height: '40px', marginTop: '8px' } },
hist.slice(0, 30).reverse().map(h =>
el('div', { style: { flex: '1', background: 'var(--accent-dim)',
height: (Number(h.down_mbps) / max * 100) + '%' } })));
mount(body,
el('div', { class: 'sv-row', style: { fontSize: '20px' } },
el('span', { style: { fontFamily: 'var(--font-mono)' } }, latest ? `${Number(latest.down_mbps).toFixed(0)}${Number(latest.up_mbps).toFixed(0)}` : '—'),
el('button', { class: 'sv-run', onclick: runNow }, 'Run')),
bars);
} catch { mount(body, el('span', { class: 'muted' }, 'No speedtest data')); }
}
async function runNow() { try { await api.post('/api/speedtest/run', {}); } catch {} setTimeout(load, 3000); }
export default {
id: 'speedtest', title: 'Speedtest', size: 'm',
mount(el_) { body = el_; load(); }, start() {}, stop() { body = null; }
};
```
Add to `style.css`: `.sv-run{background:var(--accent-soft);border:1px solid var(--accent-dim);color:var(--accent);border-radius:5px;padding:3px 10px;font-family:var(--font-ui);font-size:11px;cursor:pointer}`
- [ ] **Step 2:** Wire into `sacred_valley.js`. **Manual check.** **Commit:**
```bash
git add public/views/cards/speedtest.js public/views/sacred_valley.js public/style.css
git commit -m "feat(card): speedtest"
```
---
## Phase 4 — Little Blue Health band
### Task 17: Service registry loader + seed config
**Files:**
- Create: `config/services.json`
- Create: `lib/health/registry.js`
- Test: `tests/health/registry.test.js`
- [ ] **Step 1: Seed `config/services.json`** (correct titles — NOT inherited from v1; user edits later). IPs/CTs from the homelab wiki; group = agents | infrastructure | media.
```json
[
{ "id": "void-server", "name": "Void 2.0", "category": "agents", "host": "ct311", "url": "http://192.168.1.216:3000", "icon": "void", "check": { "type": "http", "path": "/health" } },
{ "id": "ollama", "name": "Ollama", "category": "agents", "host": "ct102", "url": "http://192.168.1.185:11434", "icon": "ollama" },
{ "id": "gitea", "name": "Gitea", "category": "infrastructure", "host": "ct105", "url": "http://192.168.1.223:3000", "icon": "gitea" },
{ "id": "pihole", "name": "Pi-hole", "category": "infrastructure", "host": "ct106", "url": "http://192.168.1.140/admin", "icon": "pi-hole" },
{ "id": "bookstack", "name": "BookStack", "category": "infrastructure", "host": "ct104", "url": "http://192.168.1.213", "icon": "bookstack" },
{ "id": "plex", "name": "Plex", "category": "media", "host": "ct100", "url": "http://192.168.1.230:32400/web", "icon": "plex" },
{ "id": "sonarr", "name": "Sonarr", "category": "media", "host": "ct100", "url": "http://192.168.1.230:8989", "icon": "sonarr" },
{ "id": "radarr", "name": "Radarr", "category": "media", "host": "ct100", "url": "http://192.168.1.230:7878", "icon": "radarr" },
{ "id": "qbittorrent", "name": "qBittorrent", "category": "media", "host": "ct100", "url": "http://192.168.1.230:8080", "icon": "qbittorrent" }
]
```
- [ ] **Step 2: Failing test**
```js
// tests/health/registry.test.js
import { describe, it, expect } from 'vitest';
import { load, grouped, iconSlug, CATEGORY_ORDER } from '../../lib/health/registry.js';
describe('registry', () => {
it('loads the seed config', () => { expect(load().length).toBeGreaterThan(0); });
it('derives an icon slug from icon or name', () => {
expect(iconSlug({ name: 'Open WebUI' })).toBe('open-webui');
expect(iconSlug({ name: 'Plex', icon: 'plex' })).toBe('plex');
});
it('groups in agents→infrastructure→media order', () => {
const g = grouped(load());
const cats = g.map(x => x.category);
const ai = cats.indexOf('agents'), mi = cats.indexOf('media');
expect(ai).toBeLessThan(mi);
expect(CATEGORY_ORDER[0]).toBe('agents');
});
});
```
- [ ] **Step 3: Run — expect FAIL.** `npx vitest run tests/health/registry.test.js`
- [ ] **Step 4: Implement `lib/health/registry.js`**
```js
// lib/health/registry.js
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG = path.join(__dirname, '../../config/services.json');
export const CATEGORY_ORDER = ['agents', 'infrastructure', 'media', 'other'];
let cache = null;
export function load() {
if (!cache) cache = JSON.parse(readFileSync(CONFIG, 'utf8'));
return cache;
}
export function _reset() { cache = null; } // tests
export function iconSlug(svc) {
return (svc.icon || svc.name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
export function grouped(services) {
const map = new Map();
for (const s of services) {
const cat = CATEGORY_ORDER.includes(s.category) ? s.category : 'other';
if (!map.has(cat)) map.set(cat, []);
map.get(cat).push(s);
}
return [...CATEGORY_ORDER, ...[...map.keys()].filter(c => !CATEGORY_ORDER.includes(c))]
.filter(c => map.has(c))
.map(category => ({ category, services: map.get(category) }));
}
```
- [ ] **Step 5: Run — expect PASS.** **Commit:**
```bash
git add config/services.json lib/health/registry.js tests/health/registry.test.js
git commit -m "feat(health): service registry loader + seed config (fresh titles)"
```
---
### Task 18: `service_status` cache table + repo
**Files:**
- Create: `lib/db/migrations/014_service_status.sql`
- Create: `lib/db/repos/service_status.js`
- Test: `tests/repos/service_status.test.js`
- [ ] **Step 1: Migration**
```sql
-- 014_service_status.sql
CREATE TABLE service_status (
service_id text PRIMARY KEY,
status text NOT NULL CHECK (status IN ('ok','warn','down','unknown')),
latency_ms integer,
detail text,
checked_at timestamptz NOT NULL DEFAULT now()
);
```
- [ ] **Step 2: Failing test**
```js
// tests/repos/service_status.test.js
import { describe, it, expect, beforeAll } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as repo from '../../lib/db/repos/service_status.js';
beforeAll(async () => { await resetDb(); await migrateUp(); });
describe('service_status repo', () => {
it('upserts and reads all', async () => {
await repo.upsert({ service_id: 'gitea', status: 'ok', latency_ms: 12, detail: '200' });
await repo.upsert({ service_id: 'gitea', status: 'down', latency_ms: null, detail: 'ECONN' });
const all = await repo.all();
expect(all.find(r => r.service_id === 'gitea').status).toBe('down');
});
});
```
- [ ] **Step 3: Run — expect FAIL.** `npx vitest run tests/repos/service_status.test.js`
- [ ] **Step 4: Implement repo**
```js
// lib/db/repos/service_status.js
import { pool } from '../pool.js';
export async function upsert({ service_id, status, latency_ms = null, detail = null }) {
await pool.query(
`INSERT INTO service_status (service_id, status, latency_ms, detail, checked_at)
VALUES ($1,$2,$3,$4, now())
ON CONFLICT (service_id) DO UPDATE
SET status=EXCLUDED.status, latency_ms=EXCLUDED.latency_ms,
detail=EXCLUDED.detail, checked_at=now()`,
[service_id, status, latency_ms, detail]);
}
export async function all() {
const { rows } = await pool.query(`SELECT * FROM service_status`);
return rows;
}
```
- [ ] **Step 5: Run — expect PASS.** **Commit:**
```bash
git add lib/db/migrations/014_service_status.sql lib/db/repos/service_status.js tests/repos/service_status.test.js
git commit -m "feat(health): service_status cache table + repo"
```
---
### Task 19: Health-check engine (probe + cron)
**Files:**
- Create: `lib/health/checker.js`
- Modify: `lib/cron/index.js` (60s schedule)
- Test: `tests/health/checker.test.js`
- [ ] **Step 1: Failing test** (status logic from an injectable probe)
```js
// tests/health/checker.test.js
import { describe, it, expect, vi } from 'vitest';
import { classify, checkAll } from '../../lib/health/checker.js';
describe('health classify', () => {
it('ok when reachable and fast', () => expect(classify({ ok: true, latency: 120 }).status).toBe('ok'));
it('warn when reachable but slow', () => expect(classify({ ok: true, latency: 4000 }).status).toBe('warn'));
it('warn on non-2xx/3xx reachable', () => expect(classify({ ok: false, reachable: true, latency: 50 }).status).toBe('warn'));
it('down when unreachable', () => expect(classify({ ok: false, reachable: false, error: 'ECONN' }).status).toBe('down'));
});
describe('checkAll', () => {
it('probes each service and returns a status per id', async () => {
const probe = vi.fn().mockResolvedValue({ ok: true, latency: 30 });
const svcs = [{ id: 'a', url: 'http://x' }, { id: 'b', url: 'http://y' }];
const out = await checkAll(svcs, probe);
expect(out.map(o => o.service_id).sort()).toEqual(['a', 'b']);
expect(out.every(o => o.status === 'ok')).toBe(true);
});
});
```
- [ ] **Step 2: Run — expect FAIL.** `npx vitest run tests/health/checker.test.js`
- [ ] **Step 3: Implement `lib/health/checker.js`**
```js
// lib/health/checker.js
import net from 'node:net';
const SLOW_MS = 3000;
export function classify({ ok, reachable, latency, error }) {
if (ok) return { status: latency > SLOW_MS ? 'warn' : 'ok', latency_ms: latency, detail: `${latency}ms` };
if (reachable) return { status: 'warn', latency_ms: latency ?? null, detail: 'degraded' };
return { status: 'down', latency_ms: null, detail: error || 'unreachable' };
}
// Default probe: HTTP (status 2xx/3xx) or TCP connect. Only called with
// operator-configured URLs from the registry — never user input.
export async function probe(svc) {
const started = Date.now();
const type = svc.check?.type || 'http';
try {
if (type === 'tcp') {
const u = new URL(svc.url);
await new Promise((resolve, reject) => {
const sock = net.connect({ host: u.hostname, port: Number(u.port) }, () => { sock.end(); resolve(); });
sock.setTimeout(5000); sock.on('timeout', () => { sock.destroy(); reject(new Error('timeout')); });
sock.on('error', reject);
});
return { ok: true, latency: Date.now() - started };
}
const base = svc.url.replace(/\/$/, '');
const url = base + (svc.check?.path || '');
const res = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(6000) });
const reachable = true;
const ok = res.status >= 200 && res.status < 400;
return { ok, reachable, latency: Date.now() - started };
} catch (e) {
return { ok: false, reachable: false, latency: Date.now() - started, error: e.code || e.message };
}
}
export async function checkAll(services, probeFn = probe) {
return Promise.all(services.map(async svc => {
const c = classify(await probeFn(svc));
return { service_id: svc.id, ...c };
}));
}
```
- [ ] **Step 4: Run — expect PASS.** `npx vitest run tests/health/checker.test.js`
- [ ] **Step 5: Schedule the 60s check** — in `lib/cron/index.js`, add:
```js
import { load } from '../health/registry.js';
import { checkAll } from '../health/checker.js';
import * as statusRepo from '../db/repos/service_status.js';
// ... inside startCron():
cron.schedule('*/1 * * * *', async () => {
try {
const results = await checkAll(load());
for (const r of results) await statusRepo.upsert(r);
log.info({ n: results.length }, 'health check complete');
} catch (e) { log.error({ err: e }, 'health check failed'); }
});
```
- [ ] **Step 6: Commit**
```bash
git add lib/health/checker.js lib/cron/index.js tests/health/checker.test.js
git commit -m "feat(health): probe + classify engine on a 60s cron"
```
---
### Task 20: `/api/health/services` + `/api/health/check`
**Files:**
- Create: `lib/api/routes/health.js`
- Modify: `lib/api/index.js`
- Test: `tests/api/health.test.js`
- [ ] **Step 1: Failing test**
```js
// tests/api/health.test.js
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { setup } from './helpers.js';
import * as statusRepo from '../../lib/db/repos/service_status.js';
let app, ownerHeaders;
beforeAll(async () => {
({ app, ownerHeaders } = await setup());
await statusRepo.upsert({ service_id: 'gitea', status: 'ok', latency_ms: 10, detail: '200' });
});
describe('health api', () => {
it('401 without auth', async () => expect((await request(app).get('/api/health/services')).status).toBe(401));
it('returns groups with counts + merged cached status', async () => {
const res = await request(app).get('/api/health/services').set(ownerHeaders);
expect(res.status).toBe(200);
const infra = res.body.find(g => g.category === 'infrastructure');
expect(infra).toBeTruthy();
expect(infra.healthy).toBeGreaterThanOrEqual(1); // gitea ok
const gitea = infra.services.find(s => s.id === 'gitea');
expect(gitea.status).toBe('ok');
});
});
```
- [ ] **Step 2: Run — expect FAIL.** `npx vitest run tests/api/health.test.js`
- [ ] **Step 3: Implement route + mount**
```js
// lib/api/routes/health.js
import { Router } from 'express';
import { asyncWrap } from '../errors.js';
import { requireOwner } from '../cap.js';
import { load, grouped, iconSlug } from '../../health/registry.js';
import * as statusRepo from '../../db/repos/service_status.js';
import { enqueue } from '../../jobs/queue.js';
export const router = Router();
router.get('/services', asyncWrap(async (_req, res) => {
const statuses = Object.fromEntries((await statusRepo.all()).map(s => [s.service_id, s]));
const groups = grouped(load()).map(g => {
const services = g.services.map(s => {
const st = statuses[s.id];
return {
id: s.id, name: s.name, host: s.host, url: s.url, icon: iconSlug(s),
status: st?.status || 'unknown', latency_ms: st?.latency_ms ?? null,
detail: st?.detail || null, checked_at: st?.checked_at || null
};
});
return { category: g.category, healthy: services.filter(s => s.status === 'ok').length,
total: services.length, services };
});
res.json(groups);
}));
router.post('/check', requireOwner, asyncWrap(async (_req, res) => {
const id = await enqueue('health.check', {});
res.status(202).json({ enqueued: id });
}));
```
`lib/api/index.js`: `import { router as healthRouter } from './routes/health.js';` + `api.use('/health', healthRouter);`
- [ ] **Step 4: Add the `health.check` worker** so `POST /check` resolves (re-uses checker). Create `lib/jobs/workers/health_check.js`:
```js
// lib/jobs/workers/health_check.js
import { load } from '../../health/registry.js';
import { checkAll } from '../../health/checker.js';
import * as statusRepo from '../../db/repos/service_status.js';
export const NAME = 'health.check';
export async function handler(_job) {
const results = await checkAll(load());
for (const r of results) await statusRepo.upsert(r);
}
```
Register it in `lib/jobs/index.js` (`import * as healthCheck from './workers/health_check.js';` + add `healthCheck` to `WORKERS`).
- [ ] **Step 5: Run — expect PASS.** `npx vitest run tests/api/health.test.js`
- [ ] **Step 6: Commit**
```bash
git add lib/api/routes/health.js lib/jobs/workers/health_check.js lib/jobs/index.js lib/api/index.js tests/api/health.test.js
git commit -m "feat(health): /api/health/services (grouped+counts) + owner /check"
```
---
### Task 21: Local icon cache — `/api/icons/:slug.png`
**Files:**
- Create: `lib/health/icons.js`
- Create: `lib/api/routes/icons.js`
- Modify: `lib/api/index.js` (mount **before** `agentOrOwner`? No — icons are public assets for `<img>`; mount on `app` directly in `server.js` so no auth header is needed)
- Modify: `server.js` (mount icons router on `app`, like `ingestRouter`)
- Test: `tests/api/icons.test.js`
> Rationale: `<img>` tags can't send the bearer header, so the icon route must be reachable without `agentOrOwner`. Mount it on `app` (like the ingest webhook), not under `/api` behind auth. Path stays `/api/icons/:slug.png` for tidiness — mount the router at `/api/icons` on `app` directly.
- [ ] **Step 1: Failing test** (inject a fake fetcher; assert fetch-once + sanitize)
```js
// tests/api/icons.test.js
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { createApp } from '../../server.js';
import * as icons from '../../lib/health/icons.js';
import { tmpdir } from 'node:os';
import path from 'node:path';
let app;
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
beforeAll(() => { app = createApp(); });
beforeEach(() => { icons._setCacheDir(path.join(tmpdir(), 'void-icons-' + Date.now())); icons._setFetcher(null); });
describe('icon cache', () => {
it('rejects an invalid slug', async () => {
const res = await request(app).get('/api/icons/..%2f..%2fetc%2fpasswd.png');
expect(res.status).toBe(400);
});
it('fetches once on miss then serves from cache', async () => {
const fetcher = vi.fn().mockResolvedValue(PNG);
icons._setFetcher(fetcher);
const r1 = await request(app).get('/api/icons/gitea.png');
expect(r1.status).toBe(200);
expect(r1.headers['content-type']).toContain('image/png');
await request(app).get('/api/icons/gitea.png');
expect(fetcher).toHaveBeenCalledTimes(1);
});
it('404s when upstream has no icon', async () => {
icons._setFetcher(vi.fn().mockResolvedValue(null));
expect((await request(app).get('/api/icons/nope.png')).status).toBe(404);
});
});
```
- [ ] **Step 2: Run — expect FAIL.** `npx vitest run tests/api/icons.test.js`
- [ ] **Step 3: Implement `lib/health/icons.js`**
```js
// lib/health/icons.js
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
let cacheDir = process.env.ICON_CACHE || '/var/lib/void/icons';
let fetcher = defaultFetcher;
export function _setCacheDir(d) { cacheDir = d; }
export function _setFetcher(fn) { fetcher = fn || defaultFetcher; }
const VALID = /^[a-z0-9-]+$/;
export function validSlug(slug) { return VALID.test(slug); }
async function defaultFetcher(slug) {
const url = `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${slug}.png`;
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
if (!res.ok) return null;
return Buffer.from(await res.arrayBuffer());
}
function isPng(buf) { return buf && buf.length > 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47; }
// Returns a Buffer (cached or freshly fetched) or null if upstream has no icon.
export async function getIcon(slug) {
if (!validSlug(slug)) throw new Error('invalid slug');
const file = path.join(cacheDir, `${slug}.png`);
try { return await readFile(file); } catch { /* miss → fetch */ }
const buf = await fetcher(slug);
if (!isPng(buf)) return null;
await mkdir(cacheDir, { recursive: true });
await writeFile(file, buf);
return buf;
}
```
- [ ] **Step 4: Implement the route**
```js
// lib/api/routes/icons.js
import { Router } from 'express';
import { getIcon, validSlug } from '../../health/icons.js';
export const router = Router();
router.get('/:file', async (req, res) => {
const slug = req.params.file.replace(/\.png$/, '');
if (!validSlug(slug)) return res.status(400).json({ error: { code: 'bad_slug' } });
try {
const buf = await getIcon(slug);
if (!buf) return res.status(404).end();
res.set('Content-Type', 'image/png').set('Cache-Control', 'public, max-age=86400').send(buf);
} catch (e) {
res.status(e.message === 'invalid slug' ? 400 : 502).end();
}
});
```
- [ ] **Step 5: Mount on `app` in `server.js`** (next to the ingest mount, before `mountApi`):
```js
import { router as iconsRouter } from './lib/api/routes/icons.js';
// ... after app.use('/api/ingest', ingestRouter);
app.use('/api/icons', iconsRouter);
```
- [ ] **Step 6: Run — expect PASS.** `npx vitest run tests/api/icons.test.js`
- [ ] **Step 7: Commit**
```bash
git add lib/health/icons.js lib/api/routes/icons.js server.js tests/api/icons.test.js
git commit -m "feat(health): local icon cache /api/icons/:slug.png (no CDN leak)"
```
---
### Task 22: Health band UI (avatar + tiles) + integrate
**Files:**
- Create: `public/components/littleblue_avatar.js`
- Create: `public/components/service_tile.js`
- Create: `public/views/health_band.js`
- Modify: `public/views/sacred_valley.js` (render the band into `#sv-health`)
- Modify: `public/style.css` (band + tile styles)
No unit test (DOM); verified in browser (Step 5).
- [ ] **Step 1: Avatar placeholder** (blue humanoid water-creature; inline SVG; final art later)
```js
// public/components/littleblue_avatar.js
import { el } from '../dom.js';
// Placeholder: a simple blue humanoid water-wisp. Swap for final art later.
export function littleblueAvatar() {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 40 48'); svg.setAttribute('width', '40'); svg.setAttribute('height', '48');
svg.innerHTML = `
<defs><radialGradient id="lbg" cx="40%" cy="30%" r="70%">
<stop offset="0%" stop-color="#bff0f3"/><stop offset="55%" stop-color="#4fb6c4"/><stop offset="100%" stop-color="#1d5f70"/>
</radialGradient></defs>
<path d="M20 2 C12 14 6 20 6 30 a14 14 0 0 0 28 0 C34 20 28 14 20 2 Z" fill="url(#lbg)"/>
<circle cx="15" cy="28" r="2.4" fill="#06222a"/><circle cx="25" cy="28" r="2.4" fill="#06222a"/>
<path d="M15 35 q5 4 10 0" stroke="#06222a" stroke-width="1.6" fill="none" stroke-linecap="round"/>`;
return el('div', { class: 'lb-av' }, svg);
}
```
- [ ] **Step 2: Service tile** (auto-icon from local cache + first-letter fallback)
```js
// public/components/service_tile.js
import { el, safeHref } from '../dom.js';
export function serviceTile(s) {
const img = el('img', { class: 'tile-icon', loading: 'lazy', src: `/api/icons/${s.icon}.png`, alt: s.name });
img.onerror = () => img.replaceWith(el('div', { class: 'tile-icon-fb' }, (s.name[0] || '?').toUpperCase()));
return el('a', { class: `tile status-${s.status}`, href: safeHref(s.url), target: '_blank', rel: 'noreferrer' },
img,
el('div', { class: 'tile-main' },
el('div', { class: 'tile-nm' }, el('span', { class: 'dot' }), s.name),
el('div', { class: 'tile-host' }, s.host || '')),
el('span', { class: 'tile-go' }, 'open ↗'));
}
```
- [ ] **Step 3: Band view**
```js
// public/views/health_band.js
import { el, mount } from '../dom.js';
import { api } from '../api.js';
import { littleblueAvatar } from '../components/littleblue_avatar.js';
import { serviceTile } from '../components/service_tile.js';
const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' };
let host, timer;
async function load() {
if (!host) return;
try {
const groups = await api.get('/api/health/services');
const sections = groups.map(g =>
el('div', { class: 'lb-section' },
el('div', { class: 'lb-group' },
el('span', { class: 'gname' }, TITLE[g.category] || g.category),
el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`),
el('span', { class: 'line' })),
el('div', { class: 'tiles' }, g.services.map(serviceTile))));
mount(host,
el('div', { class: 'lbwrap' }, littleblueAvatar(),
el('div', {}, el('div', { class: 'lb-name' }, 'Little Blue'),
el('div', { class: 'lb-sub' }, 'Health & Uptime of the lab'))),
sections);
} catch { mount(host, el('span', { class: 'muted' }, 'Health band unavailable')); }
}
export function renderHealthBand(el_) { host = el_; load(); timer = setInterval(load, 60000); }
export function stopHealthBand() { clearInterval(timer); host = null; }
```
- [ ] **Step 4: Integrate** — in `sacred_valley.js`, import and call after cards; track for cleanup:
```js
import { renderHealthBand, stopHealthBand } from './health_band.js';
// at top of render(), in the cleanup line, also: stopHealthBand();
// at the end of render():
renderHealthBand(document.getElementById('sv-health'));
```
- [ ] **Step 5: Styles** — append to `public/style.css`:
```css
#sv-health { margin-top: 28px; }
.lbwrap { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
.lb-av { width: 46px; height: 46px; filter: drop-shadow(0 0 10px rgba(125,211,216,.4)); animation: lb-bob 4s ease-in-out infinite; }
@keyframes lb-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
.lb-name { font-family: var(--font-display); letter-spacing: .08em; font-size: 15px; color: var(--text); }
.lb-sub { font-family: var(--font-body); font-style: italic; color: var(--lb); font-size: 14px; }
.lb-group { margin: 14px 0 8px; display: flex; align-items: center; gap: 10px; }
.lb-group .gname { font-family: var(--font-display); text-transform: uppercase; letter-spacing: .14em; font-size: 11px; color: var(--muted); }
.lb-group .gcount { font-family: var(--font-mono); font-size: 10px; color: var(--lb); }
.lb-group .line { flex: 1; height: 1px; background: linear-gradient(90deg, rgba(125,211,216,.25), transparent); }
.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
.tile { display: flex; align-items: center; gap: 10px; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px;
background: linear-gradient(160deg, #14141c, #101017); text-decoration: none; color: var(--text); transition: border-color .25s, transform .25s; }
.tile:hover { transform: translateY(-2px); border-color: #37404a; }
.tile-icon, .tile-icon-fb { width: 26px; height: 26px; border-radius: 6px; flex: none; }
.tile-icon-fb { display: grid; place-items: center; background: #20202a; color: var(--muted); font-family: var(--font-mono); font-size: 13px; }
.tile-main { flex: 1; min-width: 0; }
.tile-nm { font-family: var(--font-mono); font-size: 12px; display: flex; align-items: center; gap: 7px; }
.tile-host { font-family: var(--font-mono); font-size: 10px; color: var(--muted); margin-top: 4px; }
.tile .dot { width: 8px; height: 8px; border-radius: 50%; }
.tile.status-ok .dot { background: var(--ok); box-shadow: 0 0 7px var(--ok); }
.tile.status-warn .dot { background: var(--warn); box-shadow: 0 0 7px var(--warn); }
.tile.status-down .dot { background: var(--bad); box-shadow: 0 0 7px var(--bad); }
.tile.status-unknown .dot { background: var(--muted); }
.tile-go { color: var(--lb); font-size: 11px; opacity: 0; transition: opacity .25s; }
.tile:hover .tile-go { opacity: 1; }
```
- [ ] **Step 6: Manual browser verification**`#/sacred-valley` shows the two bands; tiles pull icons from `/api/icons/*` (check Network tab hits localhost, not jsDelivr, on the 2nd load); status dots reflect cached health; "open ↗" links work.
- [ ] **Step 7: Commit**
```bash
git add public/components/littleblue_avatar.js public/components/service_tile.js public/views/health_band.js public/views/sacred_valley.js public/style.css
git commit -m "feat(health): Little Blue health band — avatar, grouped service tiles, local icons"
```
---
### Task 23: Release — version bump, CHANGELOG, deploy notes
**Files:** Modify `server.js:11`, `package.json`, `CHANGELOG.md`, `deploy/README.md`
- [ ] **Step 1:** Bump `VERSION` in `server.js:11` to `'2.0.0-alpha.8'` and `package.json` `version` to `2.0.0-alpha.8`.
- [ ] **Step 2:** Prepend a CHANGELOG entry:
```markdown
## 2.0.0-alpha.8 — Sacred Valley (Plan 6)
- Two-band #/sacred-valley dashboard: draggable data cards (clock, weather, host-perf, speedtest, jobs, inbox, search) with server-persisted layout.
- Little Blue Health band: config service registry, 60s pg-boss health checks, grouped status tiles, locally-cached service icons (no CDN leak).
- New endpoints: /api/dashboard/layout, /api/weather, /api/host, /api/speedtest/*, /api/health/*, /api/icons/:slug.png.
- Migrations 012 (dashboard_layout), 013 (speedtest_results), 014 (service_status).
```
- [ ] **Step 3:** In `deploy/README.md` add a Plan 6 note: install `speedtest-cli` on CT 311 (`pip install --break-system-packages speedtest-cli`); ensure `ICON_CACHE=/var/lib/void/icons` dir exists and is owned by `void` (server auto-creates, but pre-create for clarity); migrations 012014 run via `npm run migrate`.
- [ ] **Step 4:** Run the **full suite** — expect all green.
Run: `npx vitest run`
Expected: PASS (existing suite + the new dashboard/weather/host/speedtest/health/icons/registry/reorder/registry tests).
- [ ] **Step 5: Commit**
```bash
git add server.js package.json CHANGELOG.md deploy/README.md
git commit -m "chore: version 2.0.0-alpha.8 — Sacred Valley (Plan 6)"
```
---
## Deploy (after the plan, standard recipe)
1. `pct snapshot 310 pre_alpha8_deploy_<ts>` and same for 311 (standing rule — see [[feedback-backup-before-major-updates]]).
2. `cd /project/src/void-v2 && ./deploy/push.sh` (TARGET defaults `root@192.168.1.216`).
3. `ssh root@192.168.1.216 'cd /opt/void-server && npm run migrate'` (applies 012014).
4. `ssh root@192.168.1.216 'pip install --break-system-packages speedtest-cli'` (or Ookla CLI).
5. `systemctl restart void-server` on .216.
6. Verify `curl http://192.168.1.216:3000/health``version: 2.0.0-alpha.8`; open `#/sacred-valley`, confirm both bands + a health-check pass populated the tiles.
---
## Self-Review notes (author)
- **Spec coverage:** two bands (T4/T22), 7 cards (T5/7/9/11/12/13/16), layout persistence (T1/T2/T10), weather/host/speedtest backends (T6/T8/T14-15), registry (T17), health engine (T19), `/api/health/*` (T20), icon cache local (T21), avatar placeholder (T22), theme tokens (T3), release (T23). All spec sections map to a task.
- **Deviation:** config is **JSON** not YAML (avoids adding `js-yaml`) — noted in header + T17. Flag to user if YAML is preferred.
- **Resolved against repo:** `validate` writes `req.body` (T2 ✓), `api.js` lacks `put` so T10 adds it (✓), `requireOwner` exported from `cap.js` (✓).
- **Remaining verify-before-implement flag:** the real jobs/search/pending-changes response shapes (T11T13) — each task says to read the existing full view + route first and match fields. These reuse endpoints already exist; only the field names need confirming.