diff --git a/docs/superpowers/plans/2026-06-02-void-v2-plan6-sacred-valley.md b/docs/superpowers/plans/2026-06-02-void-v2-plan6-sacred-valley.md new file mode 100644 index 0000000..ffb19d3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-void-v2-plan6-sacred-valley.md @@ -0,0 +1,1922 @@ +# 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}` + + `¤t=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 ``; 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: `` 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 = ` + + + + + + `; + 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 012–014 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_` 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 012–014). +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 (T11–T13) — 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.