# 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.