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

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

74 KiB
Raw Blame History

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

-- 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.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
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.js title 'Weather' size 's', host_perf.js title '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}` +
    `&current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m`;
  const res = await fetch(url, { signal: AbortSignal.timeout(6000) });
  if (!res.ok) throw new Error(`open-meteo ${res.status}`);
  return res.json();
}

export function _setFetcher(fn) { fetcher = fn; }
export function _resetCache() { cache = null; fetcher = defaultFetcher; }

export async function current() {
  if (cache && Date.now() - cache.at < TTL_MS) return cache.data;
  const raw = await fetcher();
  const c = raw.current || {};
  const data = {
    temp: c.temperature_2m, feels_like: c.apparent_temperature,
    humidity: c.relative_humidity_2m, wind: c.wind_speed_10m,
    code: c.weather_code, label: CODES[c.weather_code] || 'Unknown'
  };
  cache = { at: Date.now(), data };
  return data;
}
  • Step 4: Implement the route + mount it
// 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 (pure moveId + 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() in sacred_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.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

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"

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.
// 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:

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.
// 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 by public/views/search.js (each hit has kind + 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-cli JSON; 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 add speedtest to the WORKERS array.

  • Step 5: Schedule hourly — in lib/cron/index.js, inside startCron(), 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 (run is 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-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)
// 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.check worker so POST /check resolves (re-uses checker). Create lib/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 before agentOrOwner? No — icons are public assets for <img>; mount on app directly in server.js so no auth header is needed)
  • Modify: server.js (mount icons router on app, like ingestRouter)
  • Test: tests/api/icons.test.js

Rationale: <img> tags can't send the bearer header, so the icon route must be reachable without agentOrOwner. Mount it on app (like the ingest webhook), not under /api behind auth. Path stays /api/icons/:slug.png for tidiness — mount the router at /api/icons on app directly.

  • Step 1: Failing test (inject a fake fetcher; assert fetch-once + sanitize)
// 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 app in server.js (next to the ingest mount, before mountApi):
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-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

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:

## 2.0.0-alpha.8 — Sacred Valley (Plan 6)
- Two-band #/sacred-valley dashboard: draggable data cards (clock, weather, host-perf, speedtest, jobs, inbox, search) with server-persisted layout.
- Little Blue Health band: config service registry, 60s pg-boss health checks, grouped status tiles, locally-cached service icons (no CDN leak).
- New endpoints: /api/dashboard/layout, /api/weather, /api/host, /api/speedtest/*, /api/health/*, /api/icons/:slug.png.
- Migrations 012 (dashboard_layout), 013 (speedtest_results), 014 (service_status).
  • Step 3: In deploy/README.md add a Plan 6 note: install speedtest-cli on CT 311 (pip install --break-system-packages speedtest-cli); ensure ICON_CACHE=/var/lib/void/icons dir exists and is owned by void (server auto-creates, but pre-create for clarity); migrations 012014 run via npm run migrate.

  • Step 4: Run the full suite — expect all green.

Run: npx vitest run Expected: PASS (existing suite + the new dashboard/weather/host/speedtest/health/icons/registry/reorder/registry tests).

  • Step 5: Commit
git add server.js package.json CHANGELOG.md deploy/README.md
git commit -m "chore: version 2.0.0-alpha.8 — Sacred Valley (Plan 6)"

Deploy (after the plan, standard recipe)

  1. pct snapshot 310 pre_alpha8_deploy_<ts> and same for 311 (standing rule — see feedback-backup-before-major-updates).
  2. cd /project/src/void-v2 && ./deploy/push.sh (TARGET defaults root@192.168.1.216).
  3. ssh root@192.168.1.216 'cd /opt/void-server && npm run migrate' (applies 012014).
  4. ssh root@192.168.1.216 'pip install --break-system-packages speedtest-cli' (or Ookla CLI).
  5. systemctl restart void-server on .216.
  6. Verify curl http://192.168.1.216:3000/healthversion: 2.0.0-alpha.8; open #/sacred-valley, confirm both bands + a health-check pass populated the tiles.

Self-Review notes (author)

  • Spec coverage: two bands (T4/T22), 7 cards (T5/7/9/11/12/13/16), layout persistence (T1/T2/T10), weather/host/speedtest backends (T6/T8/T14-15), registry (T17), health engine (T19), /api/health/* (T20), icon cache local (T21), avatar placeholder (T22), theme tokens (T3), release (T23). All spec sections map to a task.
  • Deviation: config is JSON not YAML (avoids adding js-yaml) — noted in header + T17. Flag to user if YAML is preferred.
  • Resolved against repo: validate writes req.body (T2 ✓), api.js lacks put so T10 adds it (✓), requireOwner exported from cap.js (✓).
  • Remaining verify-before-implement flag: the real jobs/search/pending-changes response shapes (T11T13) — each task says to read the existing full view + route first and match fields. These reuse endpoints already exist; only the field names need confirming.