23 TDD tasks across 4 phases: grid framework + data cards, reuse cards, speedtest, Little Blue health band. Verified against repo patterns (validate, api.put, requireOwner, pg-boss, migrations). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
74 KiB
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 bylib/db/migrate.js. Next free numbers: 012, 013, 014. - Routes:
export const router = Router(), validate viavalidate({query/body})(zod), wrap handlers inasyncWrap, call a repo inlib/db/repos/. Mount inlib/api/index.js(already behindagentOrOwner). Owner-only:router.use(requireOwner)fromlib/api/cap.js. - Tests:
tests/api/*.test.jsusesetup()fromtests/api/helpers.js(resetDb + migrateUp +OWNER_TOKEN='test-token',ownerHeaders). Repo tests intests/repos/. Run a single file:npx vitest run tests/api/NAME.test.js. - Frontend views export
render(main, ctx). Build DOM only withel()/mount()/safeHref()frompublic/dom.js— neverinnerHTMLfrom data. VERSIONconst isserver.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
-- 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
// 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
// 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
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
// 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
// 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:
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.jsassigns the parsed body back toreq.body, so the handler readingreq.bodyis correct; a zod failure becomes aValidationError→ 400.
- Step 6: Commit
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
/* ===== 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
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)
// 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
// 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
// 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)
// 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:// public/views/cards/clock.js (temporary stub) export default { id: 'clock', title: 'Clock', size: 's', mount() {}, start() {}, stop() {} };(and identical stubs for
weather.jstitle 'Weather' size 's',host_perf.jstitle 'Host Perf' size 'm').
- Step 7: Commit
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)
// 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
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)
// 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
// 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
// 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
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)
// 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
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)
// 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
// 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
// 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
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)
// 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
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)
// 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(puremoveId+ a DOM wiring function)
// 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()insacred_valley.js, after the card loop:
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.jsexportsget/post/patch/delbut noput. Add one line to the exportedapiobject:put: (p, body) => call('PUT', p, body ?? {}),(mirrorspost). The internalcall()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
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/reposfor jobs,lib/api/routes/pending_changes.js, andpublic/views/jobs.js/public/views/inbox.js/public/views/search.jsfor 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 endpointpublic/views/jobs.jsuses — confirm its path/shape first and adapt the fields below to match.
// 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 appendjobstoCARD_MODULES. -
Step 3: Manual check the card renders counts. Commit:
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(seepublic/app.js::pollPending) — reuse it.
// 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:
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 bypublic/views/search.js(each hit haskind+id+title).
// 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:
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
-- 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
// 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
// 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:
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)
// 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-cliJSON; injectable runner)
// 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 addspeedtestto theWORKERSarray. -
Step 5: Schedule hourly — in
lib/cron/index.js, insidestartCron(), add:
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
// 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 (
runis owner-only, enqueues a job)
// 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
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-cliinstalled (pip install --break-system-packages speedtest-cli) or the Ookla CLI; otherwisespeedtestjobs 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)
// 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:
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.
[
{ "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
// 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
// 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:
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
-- 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
// 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
// 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:
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)
// 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
// 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:
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
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
// 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
// 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.checkworker soPOST /checkresolves (re-uses checker). Createlib/jobs/workers/health_check.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
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 beforeagentOrOwner? No — icons are public assets for<img>; mount onappdirectly inserver.jsso no auth header is needed) - Modify:
server.js(mount icons router onapp, likeingestRouter) - Test:
tests/api/icons.test.js
Rationale:
<img>tags can't send the bearer header, so the icon route must be reachable withoutagentOrOwner. Mount it onapp(like the ingest webhook), not under/apibehind auth. Path stays/api/icons/:slug.pngfor tidiness — mount the router at/api/iconsonappdirectly.
- Step 1: Failing test (inject a fake fetcher; assert fetch-once + sanitize)
// 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
// 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
// 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
appinserver.js(next to the ingest mount, beforemountApi):
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
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)
// public/components/littleblue_avatar.js
import { el } from '../dom.js';
// Placeholder: a simple blue humanoid water-wisp. Swap for final art later.
export function littleblueAvatar() {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 40 48'); svg.setAttribute('width', '40'); svg.setAttribute('height', '48');
svg.innerHTML = `
<defs><radialGradient id="lbg" cx="40%" cy="30%" r="70%">
<stop offset="0%" stop-color="#bff0f3"/><stop offset="55%" stop-color="#4fb6c4"/><stop offset="100%" stop-color="#1d5f70"/>
</radialGradient></defs>
<path d="M20 2 C12 14 6 20 6 30 a14 14 0 0 0 28 0 C34 20 28 14 20 2 Z" fill="url(#lbg)"/>
<circle cx="15" cy="28" r="2.4" fill="#06222a"/><circle cx="25" cy="28" r="2.4" fill="#06222a"/>
<path d="M15 35 q5 4 10 0" stroke="#06222a" stroke-width="1.6" fill="none" stroke-linecap="round"/>`;
return el('div', { class: 'lb-av' }, svg);
}
- Step 2: Service tile (auto-icon from local cache + first-letter fallback)
// 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
// 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:
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:
#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-valleyshows 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
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
VERSIONinserver.js:11to'2.0.0-alpha.8'andpackage.jsonversionto2.0.0-alpha.8. -
Step 2: Prepend a CHANGELOG entry:
## 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.mdadd a Plan 6 note: installspeedtest-clion CT 311 (pip install --break-system-packages speedtest-cli); ensureICON_CACHE=/var/lib/void/iconsdir exists and is owned byvoid(server auto-creates, but pre-create for clarity); migrations 012–014 run vianpm 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
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)
pct snapshot 310 pre_alpha8_deploy_<ts>and same for 311 (standing rule — see feedback-backup-before-major-updates).cd /project/src/void-v2 && ./deploy/push.sh(TARGET defaultsroot@192.168.1.216).ssh root@192.168.1.216 'cd /opt/void-server && npm run migrate'(applies 012–014).ssh root@192.168.1.216 'pip install --break-system-packages speedtest-cli'(or Ookla CLI).systemctl restart void-serveron .216.- 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:
validatewritesreq.body(T2 ✓),api.jslacksputso T10 adds it (✓),requireOwnerexported fromcap.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.