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