Files
Void-Homelab/docs/superpowers/plans/2026-06-09-device-icons.md
2026-06-09 08:22:58 +10:00

1056 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Device Icons, Last-Seen Timer & Uploadable Icon Sets — 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:** Let each LAN device carry an icon (device-type set icon OR brand logo), show "seen Nh ago" on absent device tiles, and manage/extend icons via a Settings panel that ingests new icon sets by multi-file, zip, or URL.
**Architecture:** Reuse the existing dashboard-icons proxy (`/api/icons/:slug.png`) for brand logos. Add a bundled Tabler device-icon set in `public/icons/devices/`, plus uploadable sets persisted in `ICON_SETS_DIR` (default `/var/lib/void/icon-sets`, outside git) served by a new `/api/icon-sets` router. Pure helper modules (sanitize, ingest, sets, icon_util) are unit-tested with vitest; the wired API + UI are verified via the existing smoke + headless-render flow.
**Tech Stack:** Node/Express, PostgreSQL (`pg`), zod, multer (already a dep), adm-zip (new dep), vanilla DOM (`public/dom.js`), vitest.
Spec: `docs/superpowers/specs/2026-06-09-device-icons-and-last-seen-design.md`
---
## File Structure
- Create `lib/db/migrations/025_lan_device_icon.sql` — add `icon` column.
- Modify `lib/db/repos/lan_devices.js` — add `icon` to `COLS` + `PATCHABLE`.
- Modify `lib/api/routes/devices.js` — add `icon` to `patchBody`.
- Create `lib/icons/sanitize.js``sanitizeSvg(input)` pure.
- Create `lib/icons/ingest.js``processFile`, `unpackZip`, `fetchUrl`, guards.
- Create `lib/icons/sets.js``listSets`, `iconPath`, `writeIcon`, `deleteSet`.
- Create `lib/api/routes/icon_sets.js` — GET list / GET file / POST / DELETE.
- Modify `server.js` — mount `/api/icon-sets`.
- Create `public/icons/devices/*.svg` — bundled Tabler set (downloaded).
- Create `public/views/icon_util.js``resolveIcon`, `relativeTime`, `autoDefaultIcon` pure.
- Create `public/views/icon_picker.js` — 2-tab picker component.
- Create `public/views/icon_sets_panel.js` — Settings panel component.
- Modify `public/views/devices_band.js` — render icon + last-seen, wire picker.
- Modify `public/views/settings.js` — add expandable "Icon sets" section.
- Modify `public/style.css` — tile icon, picker, panel styles.
- Tests: `tests/icons/sanitize.test.js`, `tests/icons/ingest.test.js`, `tests/icons/sets.test.js`, `tests/views/icon_util.test.js`.
---
## Task 1: Migration — add `icon` column
**Files:**
- Create: `lib/db/migrations/025_lan_device_icon.sql`
- [ ] **Step 1: Write the migration**
```sql
-- 025_lan_device_icon.sql
-- Per-device icon reference: 'set:<set>:<name>' (type icon) or 'brand:<slug>'
-- (dashboard-icons logo). NULL => UI auto-defaults from the device group.
ALTER TABLE lan_devices ADD COLUMN IF NOT EXISTS icon text;
```
- [ ] **Step 2: Apply it to the dev/prod DB during deploy (Task 13).** No code test here; correctness is exercised by Task 8's repo round-trip.
- [ ] **Step 3: Commit**
```bash
git add lib/db/migrations/025_lan_device_icon.sql
git commit -m "feat(devices): migration 025 — lan_devices.icon column"
```
---
## Task 2: Repo — expose & accept `icon`
**Files:**
- Modify: `lib/db/repos/lan_devices.js:3` (COLS) and the `PATCHABLE` array (~line 90)
- [ ] **Step 1: Add `icon` to the selected columns**
Change the `COLS` constant:
```js
const COLS = 'mac, ip, vendor, name, grp, note, status, randomized, flagged, first_seen, last_seen, present, icon';
```
- [ ] **Step 2: Add `icon` to PATCHABLE**
```js
const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged', 'icon'];
```
- [ ] **Step 3: Commit** (verified by Task 8)
```bash
git add lib/db/repos/lan_devices.js
git commit -m "feat(devices): repo returns + patches icon"
```
---
## Task 3: SVG sanitizer (pure)
**Files:**
- Create: `lib/icons/sanitize.js`
- Test: `tests/icons/sanitize.test.js`
- [ ] **Step 1: Write the failing test**
```js
import { describe, it, expect } from 'vitest';
import { sanitizeSvg } from '../../lib/icons/sanitize.js';
describe('sanitizeSvg', () => {
it('strips <script> tags', () => {
const out = sanitizeSvg('<svg><script>alert(1)</script><path d="M0 0"/></svg>');
expect(out).not.toMatch(/script/i);
expect(out).toMatch(/<path/);
});
it('strips on* event handlers', () => {
const out = sanitizeSvg('<svg onload="x()"><rect onclick="y()"/></svg>');
expect(out).not.toMatch(/onload|onclick/i);
});
it('neutralizes javascript: hrefs', () => {
const out = sanitizeSvg('<svg><a href="javascript:alert(1)">x</a></svg>');
expect(out).not.toMatch(/javascript:/i);
});
it('drops <foreignObject>', () => {
const out = sanitizeSvg('<svg><foreignObject><body>x</body></foreignObject></svg>');
expect(out).not.toMatch(/foreignObject/i);
});
it('accepts a Buffer', () => {
expect(sanitizeSvg(Buffer.from('<svg><path/></svg>'))).toMatch(/<svg/);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tests/icons/sanitize.test.js`
Expected: FAIL — cannot find module `lib/icons/sanitize.js`.
- [ ] **Step 3: Write minimal implementation**
```js
// lib/icons/sanitize.js
// Focused SVG sanitizer for owner-uploaded icons. NOT a general-purpose
// sanitizer — it removes the script/handler/foreignObject/js-uri vectors that
// matter for inline-rendered icons. (Owner-only upload behind CF Access.)
export function sanitizeSvg(input) {
let s = Buffer.isBuffer(input) ? input.toString('utf8') : String(input);
s = s.replace(/<script[\s\S]*?<\/script>/gi, '');
s = s.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, '');
s = s.replace(/\son[a-z]+\s*=\s*"[^"]*"/gi, '');
s = s.replace(/\son[a-z]+\s*=\s*'[^']*'/gi, '');
s = s.replace(/(href|xlink:href)\s*=\s*("|')\s*javascript:[^"']*\2/gi, '$1=$2#$2');
return s;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tests/icons/sanitize.test.js`
Expected: PASS (5 tests).
- [ ] **Step 5: Commit**
```bash
git add lib/icons/sanitize.js tests/icons/sanitize.test.js
git commit -m "feat(icons): SVG sanitizer for uploaded icons"
```
---
## Task 4: Ingest — per-file processor, zip unpack, URL fetch
**Files:**
- Create: `lib/icons/ingest.js`
- Test: `tests/icons/ingest.test.js`
- Add dependency: `adm-zip`
- [ ] **Step 1: Add the zip dependency**
Run: `npm install adm-zip@^0.5.16`
Expected: adds `adm-zip` to dependencies.
- [ ] **Step 2: Write the failing test**
```js
import { describe, it, expect } from 'vitest';
import AdmZip from 'adm-zip';
import { processFile, unpackZip, fetchUrl, MAX_FILE } from '../../lib/icons/ingest.js';
const PNG = Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a, 0,0,0,0]);
describe('processFile', () => {
it('slugifies name, keeps png', () => {
const r = processFile({ name: 'My Router.png', buffer: PNG });
expect(r.name).toBe('my-router.png');
expect(r.buffer).toBe(PNG);
});
it('sanitizes svg', () => {
const r = processFile({ name: 'x.svg', buffer: Buffer.from('<svg><script>1</script><path/></svg>') });
expect(r.buffer.toString()).not.toMatch(/script/i);
});
it('rejects non-image extension', () => {
expect(() => processFile({ name: 'x.exe', buffer: PNG })).toThrow();
});
it('rejects oversize', () => {
expect(() => processFile({ name: 'x.png', buffer: Buffer.alloc(MAX_FILE + 1, 1) })).toThrow();
});
it('rejects png with bad magic', () => {
expect(() => processFile({ name: 'x.png', buffer: Buffer.from('not a png') })).toThrow();
});
});
describe('unpackZip', () => {
it('extracts images, skips junk + traversal', () => {
const z = new AdmZip();
z.addFile('a.png', PNG);
z.addFile('../evil.png', PNG);
z.addFile('notes.txt', Buffer.from('hi'));
const out = unpackZip(z.toBuffer());
expect(out.map(f => f.name)).toEqual(['a.png']);
});
});
describe('fetchUrl', () => {
it('rejects non-http schemes', async () => {
await expect(fetchUrl('file:///etc/passwd')).rejects.toThrow();
});
it('rejects localhost/private hosts', async () => {
await expect(fetchUrl('http://127.0.0.1/x.png')).rejects.toThrow();
});
it('fetches via injected fetcher', async () => {
const fake = async () => ({ ok: true, arrayBuffer: async () => PNG.buffer.slice(PNG.byteOffset, PNG.byteOffset + PNG.length), headers: new Map([['content-type','image/png']]) });
const r = await fetchUrl('https://example.com/x.png', { fetcher: fake });
expect(Buffer.isBuffer(r.buffer)).toBe(true);
});
});
```
- [ ] **Step 3: Run test to verify it fails**
Run: `npx vitest run tests/icons/ingest.test.js`
Expected: FAIL — module not found.
- [ ] **Step 4: Write the implementation**
```js
// lib/icons/ingest.js
import path from 'node:path';
import AdmZip from 'adm-zip';
import { sanitizeSvg } from './sanitize.js';
export const MAX_FILE = 256 * 1024; // 256 KB per icon
export const MAX_ZIP_ENTRIES = 200;
export const MAX_ZIP_TOTAL = 5 * 1024 * 1024; // 5 MB uncompressed
export const MAX_URL_BYTES = 5 * 1024 * 1024;
const EXT = { '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg' };
const PNG_SIG = [0x89,0x50,0x4e,0x47];
const JPG_SIG = [0xff,0xd8,0xff];
function slugBase(name) {
return path.basename(name, path.extname(name)).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
function magicOk(ext, buf) {
if (ext === '.png') return PNG_SIG.every((b, i) => buf[i] === b);
if (ext === '.jpg' || ext === '.jpeg') return JPG_SIG.every((b, i) => buf[i] === b);
if (ext === '.svg') return buf.toString('utf8', 0, 400).includes('<svg');
return false;
}
// Validate + normalize one icon. Returns { name, buffer, ext, contentType }. Throws on invalid.
export function processFile({ name, buffer }) {
const ext = path.extname(name).toLowerCase();
if (!EXT[ext]) throw new Error('unsupported_type');
if (!buffer || buffer.length === 0) throw new Error('empty');
if (buffer.length > MAX_FILE) throw new Error('too_large');
if (!magicOk(ext, buffer)) throw new Error('bad_magic');
const base = slugBase(name);
if (!base) throw new Error('bad_name');
const out = ext === '.svg' ? Buffer.from(sanitizeSvg(buffer)) : buffer;
return { name: `${base}${ext}`, buffer: out, ext, contentType: EXT[ext] };
}
// Extract image entries from a zip buffer; flatten basenames, skip traversal/junk.
export function unpackZip(buffer) {
const zip = new AdmZip(buffer);
const entries = zip.getEntries();
if (entries.length > MAX_ZIP_ENTRIES) throw new Error('too_many_entries');
const out = []; let total = 0;
for (const e of entries) {
if (e.isDirectory) continue;
const ext = path.extname(e.entryName).toLowerCase();
if (!EXT[ext]) continue; // skip non-images
if (/(^|[\\/])\.\.([\\/]|$)/.test(e.entryName)) continue; // skip traversal
const data = e.getData();
total += data.length;
if (total > MAX_ZIP_TOTAL) throw new Error('zip_too_big');
try { out.push(processFile({ name: path.basename(e.entryName), buffer: data })); }
catch { /* skip individually-invalid entries */ }
}
return out;
}
const PRIVATE_HOST = /^(localhost|127\.|0\.0\.0\.0|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|\[?::1\]?)/i;
// Fetch a remote icon or zip. SSRF guard: http/https only, no localhost/private,
// size + timeout caps. `fetcher` injectable for tests.
export async function fetchUrl(url, { fetcher = fetch } = {}) {
let u;
try { u = new URL(url); } catch { throw new Error('bad_url'); }
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad_scheme');
if (PRIVATE_HOST.test(u.hostname)) throw new Error('blocked_host');
const res = await fetcher(url, { signal: AbortSignal.timeout(8000), redirect: 'error' });
if (!res.ok) throw new Error('fetch_failed');
const ab = await res.arrayBuffer();
if (ab.byteLength > MAX_URL_BYTES) throw new Error('too_large');
const ct = (res.headers.get ? res.headers.get('content-type') : res.headers.get?.('content-type')) || '';
return { buffer: Buffer.from(ab), contentType: ct };
}
export function isZip(buf) { return buf && buf.length > 4 && buf[0] === 0x50 && buf[1] === 0x4b; }
```
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run tests/icons/ingest.test.js`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add lib/icons/ingest.js tests/icons/ingest.test.js package.json package-lock.json
git commit -m "feat(icons): ingest — file processor, zip unpack, URL fetch (guards)"
```
---
## Task 5: Icon-set store (filesystem)
**Files:**
- Create: `lib/icons/sets.js`
- Test: `tests/icons/sets.test.js`
- [ ] **Step 1: Write the failing test**
```js
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import * as sets from '../../lib/icons/sets.js';
const PNG = Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]);
let dir;
beforeEach(() => { dir = mkdtempSync(path.join(tmpdir(), 'iconsets-')); sets._setDirs({ setsDir: dir, bundledDir: path.join(dir, '__bundled') }); mkdirSync(path.join(dir, '__bundled'), { recursive: true }); writeFileSync(path.join(dir, '__bundled', 'router.svg'), '<svg><path/></svg>'); });
describe('sets store', () => {
it('lists the read-only bundled set', async () => {
const list = await sets.listSets();
const dev = list.find(s => s.set === 'devices');
expect(dev.readonly).toBe(true);
expect(dev.icons).toContain('router.svg');
});
it('writes + lists an uploaded set', async () => {
await sets.writeIcon('mine', 'nas.png', PNG);
const mine = (await sets.listSets()).find(s => s.set === 'mine');
expect(mine.readonly).toBe(false);
expect(mine.icons).toContain('nas.png');
});
it('refuses to write the reserved bundled set', async () => {
await expect(sets.writeIcon('devices', 'x.png', PNG)).rejects.toThrow();
});
it('deletes an uploaded set, not the bundled one', async () => {
await sets.writeIcon('mine', 'a.png', PNG);
await sets.deleteSet('mine');
expect((await sets.listSets()).find(s => s.set === 'mine')).toBeUndefined();
await expect(sets.deleteSet('devices')).rejects.toThrow();
});
it('rejects bad slugs (traversal)', async () => {
await expect(sets.writeIcon('../x', 'a.png', PNG)).rejects.toThrow();
expect(() => sets.iconPath('mine', '../../etc/passwd')).toThrow();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tests/icons/sets.test.js`
Expected: FAIL — module not found.
- [ ] **Step 3: Write the implementation**
```js
// lib/icons/sets.js
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
const BUNDLED_SET = 'devices'; // read-only, ships in public/icons/devices
let setsDir = process.env.ICON_SETS_DIR || '/var/lib/void/icon-sets';
let bundledDir = path.resolve('public/icons/devices');
export function _setDirs({ setsDir: s, bundledDir: b }) { if (s) setsDir = s; if (b) bundledDir = b; }
const SLUG = /^[a-z0-9-]+$/;
const FILE = /^[a-z0-9-]+\.(svg|png|jpg|jpeg)$/;
function okSet(s) { return SLUG.test(s); }
async function listDir(dir) {
try { return (await readdir(dir)).filter(f => FILE.test(f)).sort(); } catch { return []; }
}
export async function listSets() {
const out = [{ set: BUNDLED_SET, readonly: true, icons: await listDir(bundledDir) }];
let uploaded = [];
try { uploaded = await readdir(setsDir, { withFileTypes: true }); } catch { /* none yet */ }
for (const d of uploaded) {
if (d.isDirectory() && okSet(d.name) && d.name !== BUNDLED_SET) {
out.push({ set: d.name, readonly: false, icons: await listDir(path.join(setsDir, d.name)) });
}
}
return out;
}
// Resolve an on-disk path for serving. Throws on bad slugs.
export function iconPath(set, file) {
if (!okSet(set) || !FILE.test(file)) throw new Error('bad_slug');
return set === BUNDLED_SET ? path.join(bundledDir, file) : path.join(setsDir, set, file);
}
export async function readIcon(set, file) {
return readFile(iconPath(set, file));
}
export async function writeIcon(set, name, buffer) {
if (set === BUNDLED_SET) throw new Error('reserved_set');
if (!okSet(set) || !FILE.test(name)) throw new Error('bad_slug');
const dir = path.join(setsDir, set);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, name), buffer);
}
export async function deleteSet(set) {
if (set === BUNDLED_SET) throw new Error('reserved_set');
if (!okSet(set)) throw new Error('bad_slug');
await rm(path.join(setsDir, set), { recursive: true, force: true });
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tests/icons/sets.test.js`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add lib/icons/sets.js tests/icons/sets.test.js
git commit -m "feat(icons): filesystem icon-set store (bundled read-only + uploads)"
```
---
## Task 6: `/api/icon-sets` router + mount
**Files:**
- Create: `lib/api/routes/icon_sets.js`
- Modify: `server.js` (import + mount near `/api/icons`, ~line 54)
- [ ] **Step 1: Write the router**
```js
// lib/api/routes/icon_sets.js
import { Router } from 'express';
import multer from 'multer';
import { requireOwner } from '../cap.js';
import { asyncWrap } from '../errors.js';
import * as sets from '../../icons/sets.js';
import { processFile, unpackZip, fetchUrl, isZip } from '../../icons/ingest.js';
export const router = Router();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 6 * 1024 * 1024, files: 50 } });
// GET /api/icon-sets — list sets + their icons (open; <img> can't send bearer).
router.get('/', asyncWrap(async (_req, res) => res.json(await sets.listSets())));
// GET /api/icon-sets/:set/:file — serve one icon.
router.get('/:set/:file', asyncWrap(async (req, res) => {
let buf;
try { buf = await sets.readIcon(req.params.set, req.params.file); }
catch (e) { return res.status(e.message === 'bad_slug' ? 400 : 404).end(); }
const ct = req.params.file.endsWith('.svg') ? 'image/svg+xml'
: req.params.file.endsWith('.png') ? 'image/png' : 'image/jpeg';
res.set('Content-Type', ct).set('Cache-Control', 'public, max-age=86400').send(buf);
}));
// POST /api/icon-sets/:set — owner upload: multipart files (incl .zip) and/or { url }.
router.post('/:set', requireOwner, upload.array('files'), asyncWrap(async (req, res) => {
const set = req.params.set;
const items = []; // [{name, buffer}]
for (const f of req.files || []) {
if (isZip(f.buffer)) items.push(...unpackZip(f.buffer));
else items.push(processFile({ name: f.originalname, buffer: f.buffer }));
}
if (req.body?.url) {
const { buffer } = await fetchUrl(req.body.url);
if (isZip(buffer)) items.push(...unpackZip(buffer));
else {
const name = new URL(req.body.url).pathname.split('/').pop() || 'icon.png';
items.push(processFile({ name, buffer }));
}
}
if (!items.length) return res.status(400).json({ error: { code: 'no_icons' } });
for (const it of items) await sets.writeIcon(set, it.name, it.buffer);
res.json((await sets.listSets()).find(s => s.set === set) || { set, icons: [] });
}));
// DELETE /api/icon-sets/:set — owner remove an uploaded set.
router.delete('/:set', requireOwner, asyncWrap(async (req, res) => {
try { await sets.deleteSet(req.params.set); }
catch (e) { return res.status(e.message === 'reserved_set' ? 409 : 400).json({ error: { code: e.message } }); }
res.json({ ok: true });
}));
```
- [ ] **Step 2: Mount it in `server.js`**
After the `iconsRouter` mount (~line 54), add the import at top with the other route imports:
```js
import { router as iconSetsRouter } from './lib/api/routes/icon_sets.js';
```
and the mount next to `/api/icons` (both must sit BEFORE any agent/owner global gate so GET serves to `<img>`):
```js
app.use('/api/icon-sets', iconSetsRouter);
```
- [ ] **Step 3: Smoke-check the routes load**
Run: `node -e "import('./lib/api/routes/icon_sets.js').then(()=>console.log('ok'))"`
Expected: prints `ok` (no import errors).
- [ ] **Step 4: Commit**
```bash
git add lib/api/routes/icon_sets.js server.js
git commit -m "feat(api): /api/icon-sets — list/serve/upload(zip,url)/delete"
```
---
## Task 7: Devices PATCH accepts `icon`
**Files:**
- Modify: `lib/api/routes/devices.js` (`patchBody`, ~line 56)
- Test: `tests/icons/devices_icon.test.js`
- [ ] **Step 1: Write the failing test (zod schema accepts/rejects icon refs)**
```js
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import { iconRef } from '../../lib/api/routes/devices.js';
describe('icon ref validation', () => {
it('accepts set + brand refs and null', () => {
expect(iconRef.safeParse('set:devices:router').success).toBe(true);
expect(iconRef.safeParse('brand:apple').success).toBe(true);
expect(iconRef.safeParse(null).success).toBe(true);
});
it('rejects junk', () => {
expect(iconRef.safeParse('set:bad').success).toBe(false);
expect(iconRef.safeParse('javascript:alert').success).toBe(false);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tests/icons/devices_icon.test.js`
Expected: FAIL — `iconRef` not exported.
- [ ] **Step 3: Implement — export `iconRef`, add it to patchBody**
In `lib/api/routes/devices.js`, just above `patchBody`, add and export:
```js
export const iconRef = z.string().regex(/^(set:[a-z0-9-]+:[a-z0-9-]+|brand:[a-z0-9-]+)$/).nullable();
```
Then add to `patchBody`:
```js
const patchBody = z.object({
name: z.string().max(120).optional(),
grp: z.enum(['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']).optional(),
status: z.enum(['new', 'known', 'ignored']).optional(),
note: z.string().max(500).optional(),
flagged: z.boolean().optional(),
icon: iconRef.optional()
});
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tests/icons/devices_icon.test.js`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add lib/api/routes/devices.js tests/icons/devices_icon.test.js
git commit -m "feat(devices): PATCH accepts icon ref"
```
---
## Task 8: Bundled Tabler device icons
**Files:**
- Create: `public/icons/devices/*.svg` (15 files)
- [ ] **Step 1: Download the curated set from Tabler (MIT)**
Run this script (maps our names → Tabler icon slugs; Tabler raw SVGs are MIT):
```bash
mkdir -p public/icons/devices
declare -A MAP=(
[router]=router [phone]=device-mobile [tablet]=device-tablet
[laptop]=device-laptop [desktop]=device-desktop [tv]=device-tv
[speaker]=speakerphone [camera]=camera [printer]=printer
[console]=device-gamepad-2 [plug]=plug [server]=server
[watch]=device-watch [nas]=database [unknown]=device-unknown
)
for name in "${!MAP[@]}"; do
curl -fsSL "https://raw.githubusercontent.com/tabler/tabler-icons/main/icons/outline/${MAP[$name]}.svg" \
-o "public/icons/devices/${name}.svg" || echo "MISS ${name} (${MAP[$name]})"
done
ls public/icons/devices/
```
Expected: 15 `.svg` files. If any `MISS`, pick the nearest existing Tabler outline icon name and re-fetch (verify names at github.com/tabler/tabler-icons/tree/main/icons/outline).
- [ ] **Step 2: Make them theme-colored** — Tabler icons use `stroke="currentColor"` already, so they inherit the tile text color. No edit needed; confirm with:
Run: `grep -l currentColor public/icons/devices/*.svg | wc -l`
Expected: `15`.
- [ ] **Step 3: Commit**
```bash
git add public/icons/devices
git commit -m "feat(icons): bundled Tabler device icon set"
```
---
## Task 9: Frontend pure helpers
**Files:**
- Create: `public/views/icon_util.js`
- Test: `tests/views/icon_util.test.js`
- [ ] **Step 1: Write the failing test**
```js
import { describe, it, expect } from 'vitest';
import { resolveIcon, relativeTime, autoDefaultIcon } from '../../public/views/icon_util.js';
describe('autoDefaultIcon', () => {
it('maps groups to bundled icons', () => {
expect(autoDefaultIcon('Network')).toBe('set:devices:router');
expect(autoDefaultIcon('Entertainment')).toBe('set:devices:tv');
expect(autoDefaultIcon('Smart Home')).toBe('set:devices:plug');
expect(autoDefaultIcon('Personal')).toBe('set:devices:phone');
expect(autoDefaultIcon('whatever')).toBe('set:devices:unknown');
});
});
describe('resolveIcon', () => {
it('resolves set + brand refs', () => {
expect(resolveIcon('set:devices:router')).toBe('/api/icon-sets/devices/router.svg');
expect(resolveIcon('set:mine:nas')).toBe('/api/icon-sets/mine/nas.svg');
expect(resolveIcon('brand:apple')).toBe('/api/icons/apple.png');
});
it('returns null for junk', () => { expect(resolveIcon('nope')).toBeNull(); });
});
describe('relativeTime', () => {
const base = Date.parse('2026-06-09T12:00:00Z');
it('formats buckets', () => {
expect(relativeTime('2026-06-09T11:59:30Z', base)).toBe('just now');
expect(relativeTime('2026-06-09T11:40:00Z', base)).toBe('20m ago');
expect(relativeTime('2026-06-09T09:00:00Z', base)).toBe('3h ago');
expect(relativeTime('2026-06-06T12:00:00Z', base)).toBe('3d ago');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tests/views/icon_util.test.js`
Expected: FAIL — module not found.
- [ ] **Step 3: Write the implementation**
```js
// public/views/icon_util.js — pure helpers (no DOM), unit-tested.
const GROUP_DEFAULT = {
Network: 'router', Entertainment: 'tv', 'Smart Home': 'plug', Personal: 'phone'
};
export function autoDefaultIcon(grp) {
return `set:devices:${GROUP_DEFAULT[grp] || 'unknown'}`;
}
// Note: bundled 'devices' icons are .svg; brand icons are served .png by the proxy.
export function resolveIcon(ref) {
if (typeof ref !== 'string') return null;
let m = ref.match(/^set:([a-z0-9-]+):([a-z0-9-]+)$/);
if (m) return `/api/icon-sets/${m[1]}/${m[2]}.svg`;
m = ref.match(/^brand:([a-z0-9-]+)$/);
if (m) return `/api/icons/${m[1]}.png`;
return null;
}
export function relativeTime(iso, now = Date.now()) {
const t = typeof iso === 'number' ? iso : Date.parse(iso);
const s = Math.max(0, Math.floor((now - t) / 1000));
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
```
> NOTE on resolveIcon + uploaded sets: uploaded icons may be `.png`/`.jpg`, not
> `.svg`. To keep `resolveIcon` pure we always request `.svg`; the `<img>` error
> fallback (Task 10) retries `.png` then shows the letter. (Simpler than carrying
> the extension in the ref. Acceptable: most uploaded sets are svg; the retry
> covers png/jpg.)
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tests/views/icon_util.test.js`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add public/views/icon_util.js tests/views/icon_util.test.js
git commit -m "feat(devices): pure icon resolver + relativeTime helpers"
```
---
## Task 10: Icon picker component
**Files:**
- Create: `public/views/icon_picker.js`
- [ ] **Step 1: Implement the picker** (verified via headless render in Task 13)
```js
// public/views/icon_picker.js — modal-less inline picker with Type + Brand tabs.
import { el, mount, clear } from '../dom.js';
import { api } from '../api.js';
import { resolveIcon } from './icon_util.js';
// onPick(ref) called with 'set:<set>:<name>' or 'brand:<slug>'. Returns an element.
export function iconPicker(currentRef, onPick) {
const box = el('div', { class: 'icon-picker' });
const tabs = el('div', { class: 'ip-tabs' });
const body = el('div', { class: 'ip-body' });
const typeTab = el('button', { class: 'ip-tab active' }, 'Type');
const brandTab = el('button', { class: 'ip-tab' }, 'Brand');
typeTab.onclick = () => { typeTab.classList.add('active'); brandTab.classList.remove('active'); showType(); };
brandTab.onclick = () => { brandTab.classList.add('active'); typeTab.classList.remove('active'); showBrand(); };
async function showType() {
clear(body); body.append(el('div', { class: 'muted' }, 'Loading…'));
let list = [];
try { list = await api.get('/api/icon-sets'); } catch { /* ignore */ }
clear(body);
for (const s of list) {
const grid = el('div', { class: 'ip-grid' }, s.icons.map(file => {
const name = file.replace(/\.[a-z]+$/, '');
const ref = `set:${s.set}:${name}`;
const b = el('button', { class: 'ip-icon', title: name }, el('img', { src: `/api/icon-sets/${s.set}/${file}` }));
b.onclick = () => onPick(ref);
return b;
}));
body.append(el('div', { class: 'ip-set' }, el('div', { class: 'ip-set-hd' }, s.set + (s.readonly ? '' : ' ·')), grid));
}
}
function showBrand() {
clear(body);
const inp = el('input', { class: 'dv-edit-name', placeholder: 'brand slug e.g. apple, google-nest' });
const prev = el('div', { class: 'ip-grid' });
inp.oninput = () => {
const slug = inp.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
clear(prev);
if (!slug) return;
const b = el('button', { class: 'ip-icon' }, el('img', { src: `/api/icons/${slug}.png` }));
b.onclick = () => onPick(`brand:${slug}`);
prev.append(b);
};
body.append(inp, prev);
}
mount(tabs, typeTab, brandTab);
mount(box, tabs, body);
showType();
return box;
}
```
- [ ] **Step 2: Commit**
```bash
git add public/views/icon_picker.js
git commit -m "feat(devices): icon picker (Type sets + Brand search)"
```
---
## Task 11: Wire icon + last-seen into the devices band
**Files:**
- Modify: `public/views/devices_band.js`
- [ ] **Step 1: Add imports** at the top (after existing imports):
```js
import { resolveIcon, relativeTime, autoDefaultIcon } from './icon_util.js';
import { iconPicker } from './icon_picker.js';
```
- [ ] **Step 2: Render the icon + last-seen in `view()`** — replace the body of `view()` so the tile leads with an icon and absent tiles show "seen Nh ago":
```js
function view() {
clear(t);
const edit = el('button', { class: 'dv-edit-btn', title: 'Edit device' }, '✎');
edit.onclick = editMode;
const ref = d.icon || autoDefaultIcon(d.grp);
const src = resolveIcon(ref);
const img = el('img', { class: 'dv-icon', src, alt: '' });
img.onerror = () => {
if (src && src.endsWith('.svg')) { img.src = src.replace(/\.svg$/, '.png'); return; }
img.replaceWith(el('div', { class: 'dv-icon-fb' }, (d.name?.[0] || '?').toUpperCase()));
};
const seen = d.present === false && d.last_seen
? el('span', { class: 'dv-seen' }, 'seen ' + relativeTime(d.last_seen)) : null;
mount(t, img,
el('span', { class: 'dv-nm' }, d.name || 'Unknown'),
el('span', { class: 'dv-ip' }, d.ip || ''),
d.mac ? el('span', { class: 'dv-mac' }, d.mac) : null,
el('span', { class: 'dv-vendor' },
(d.vendor || '') + (d.randomized ? ' · randomized' : '') + (d.present === false ? ' · absent' : '')),
seen,
d.mac ? edit : null);
}
```
- [ ] **Step 3: Add an "Icon" control to `editMode()`** — track a chosen ref and include it in the PATCH. Replace `editMode()`:
```js
function editMode() {
clear(t);
let chosenIcon = d.icon || null;
const nameI = el('input', { class: 'dv-edit-name', value: d.name || '' });
const grpS = el('select', { class: 'dv-edit-grp' }, ...GROUPS.map(g => el('option', { value: g }, g)));
grpS.value = d.grp || 'Flagged';
const pickerWrap = el('div', { class: 'dv-picker-wrap' });
pickerWrap.style.display = 'none';
const iconBtn = el('button', { class: 'ghost' }, 'Icon');
iconBtn.onclick = () => {
if (pickerWrap.style.display === 'none') {
clear(pickerWrap);
pickerWrap.append(iconPicker(chosenIcon, ref => { chosenIcon = ref; iconBtn.textContent = 'Icon ✓'; pickerWrap.style.display = 'none'; }));
pickerWrap.style.display = 'block';
} else pickerWrap.style.display = 'none';
};
const save = el('button', { class: 'dv-add' }, 'Save');
save.onclick = async () => {
await api.patch('/api/devices/' + d.mac, { name: nameI.value.trim() || null, grp: grpS.value, icon: chosenIcon });
load();
};
const del = el('button', { class: 'ghost dv-ignore' }, 'Delete');
del.onclick = async () => { await api.del('/api/devices/' + d.mac); load(); };
const cancel = el('button', { class: 'ghost' }, 'Cancel');
cancel.onclick = view;
mount(t, el('span', { class: 'dv-mac' }, d.mac), nameI, grpS, iconBtn, save, del, cancel, pickerWrap);
}
```
- [ ] **Step 4: Commit**
```bash
git add public/views/devices_band.js
git commit -m "feat(devices): show icon + last-seen, icon picker in edit"
```
---
## Task 12: Settings "Icon sets" panel
**Files:**
- Create: `public/views/icon_sets_panel.js`
- Modify: `public/views/settings.js` (add a collapsible section that mounts the panel)
- [ ] **Step 1: Build the panel component**
```js
// public/views/icon_sets_panel.js
import { el, mount, clear } from '../dom.js';
import { api } from '../api.js';
export function iconSetsPanel() {
const root = el('div', { class: 'icon-sets-panel' });
async function refresh() {
clear(root);
let list = [];
try { list = await api.get('/api/icon-sets'); } catch { mount(root, el('div', { class: 'muted' }, 'unavailable')); return; }
for (const s of list) {
const grid = el('div', { class: 'ip-grid' }, s.icons.map(f =>
el('div', { class: 'ip-icon' }, el('img', { src: `/api/icon-sets/${s.set}/${f}`, title: f }))));
const head = el('div', { class: 'isp-hd' }, el('b', {}, s.set), el('span', { class: 'muted' }, ` ${s.icons.length}`));
if (!s.readonly) {
const del = el('button', { class: 'ghost' }, 'Delete');
del.onclick = async () => { await api.del('/api/icon-sets/' + s.set); refresh(); };
head.append(del);
}
mount(root, el('div', { class: 'isp-set' }, head, grid));
}
mount(root, uploadForm(refresh));
}
refresh();
return root;
}
function uploadForm(onDone) {
const setI = el('input', { class: 'dv-edit-name', placeholder: 'new set name (a-z0-9-)' });
const fileI = el('input', { type: 'file', accept: '.svg,.png,.jpg,.jpeg,.zip', multiple: true });
const urlI = el('input', { class: 'dv-edit-name', placeholder: 'or ingest from URL (image or .zip)' });
const err = el('span', { class: 'muted', style: { fontSize: '11px' } }, '');
const up = el('button', { class: 'dv-add' }, 'Upload');
up.onclick = async () => {
const set = setI.value.trim().toLowerCase();
if (!/^[a-z0-9-]+$/.test(set)) { err.textContent = 'set name: a-z 0-9 - only'; return; }
const fd = new FormData();
for (const f of fileI.files) fd.append('files', f);
if (urlI.value.trim()) fd.append('url', urlI.value.trim());
if (!fileI.files.length && !urlI.value.trim()) { err.textContent = 'pick files or a URL'; return; }
up.textContent = 'Uploading…'; up.disabled = true;
try { await api.postForm('/api/icon-sets/' + set, fd); onDone(); }
catch { err.textContent = 'upload failed'; up.textContent = 'Upload'; up.disabled = false; }
};
return el('div', { class: 'isp-upload' }, setI, fileI, urlI, up, err);
}
```
- [ ] **Step 2: Ensure `api.postForm` exists** — check `public/api.js`. If there is no multipart helper, add one (do NOT set Content-Type; the browser sets the multipart boundary):
```js
// in public/api.js, alongside post/patch/del:
postForm: (path, formData) => req(path, { method: 'POST', body: formData }),
```
If `req()` currently always JSON-stringifies/sets headers, guard it: when `body` is a `FormData`, skip `JSON.stringify` and skip the `Content-Type` header. Verify by reading `public/api.js` before editing.
- [ ] **Step 3: Add the collapsible section to `settings.js`** — read `public/views/settings.js` for its section pattern, then add (using whatever `el`/section helper it already uses):
```js
import { iconSetsPanel } from './icon_sets_panel.js';
// …inside the settings render, append a collapsible section:
const isBody = iconSetsPanel();
isBody.style.display = 'none';
const isToggle = el('button', { class: 'ghost' }, '▸ Icon sets');
isToggle.onclick = () => {
const open = isBody.style.display !== 'none';
isBody.style.display = open ? 'none' : 'block';
isToggle.textContent = (open ? '▸' : '▾') + ' Icon sets';
};
// mount isToggle + isBody where other settings sections are mounted.
```
- [ ] **Step 4: Commit**
```bash
git add public/views/icon_sets_panel.js public/views/settings.js public/api.js
git commit -m "feat(settings): expandable Icon sets panel (view/upload/delete)"
```
---
## Task 13: Styles, full verification & deploy
**Files:**
- Modify: `public/style.css`
- [ ] **Step 1: Add styles** (match blackflame; reuse existing vars). Append to `public/style.css`:
```css
.dv-icon { width: 20px; height: 20px; object-fit: contain; opacity: .9; }
.dv-icon-fb { width: 20px; height: 20px; display: grid; place-items: center; font-size: 11px; background: var(--surface-2, #1b1b22); border-radius: 4px; }
.dv-seen { font-size: 11px; color: var(--muted, #8a8a94); }
.icon-picker { border: 1px solid var(--border, #2a2a36); border-radius: 6px; padding: 6px; margin-top: 6px; max-width: 320px; }
.ip-tabs { display: flex; gap: 4px; margin-bottom: 6px; }
.ip-tab.active { color: var(--accent, #ff4f2e); border-bottom: 1px solid var(--accent, #ff4f2e); }
.ip-grid { display: flex; flex-wrap: wrap; gap: 6px; }
.ip-icon { width: 34px; height: 34px; display: grid; place-items: center; background: transparent; border: 1px solid var(--border, #2a2a36); border-radius: 4px; cursor: pointer; }
.ip-icon img { width: 22px; height: 22px; object-fit: contain; }
.ip-set-hd, .isp-hd { font-size: 12px; margin: 6px 0 3px; text-transform: capitalize; }
.isp-upload { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
```
- [ ] **Step 2: Run the full test suite**
Run: `npm test`
Expected: all green (existing suite + the new icon tests). Fix any regressions before proceeding.
- [ ] **Step 3: Snapshot before DB change** (per backup-before-major-updates)
Run (from a host that can reach Z):
```bash
ssh root@192.168.1.124 'pct snapshot 310 pre_device_icons; pct snapshot 311 pre_device_icons'
```
Expected: two snapshots created.
- [ ] **Step 4: Deploy via the health-gated script** (applies migration 025, restarts void-app). Use the project's standard deploy path — confirm `ICON_SETS_DIR` exists + is writable by the `void` user on void-app (CT 311):
```bash
ssh root@192.168.1.216 'install -d -o void -g void /var/lib/void/icon-sets'
```
Then run the repo's health-gated deploy (same one used for 2.4.0).
- [ ] **Step 5: Smoke + headless verify** (use the `void-test-and-verify` / `headless-ui-check` skills)
- `GET /api/icon-sets` returns the bundled `devices` set with 15 icons.
- `GET /api/icon-sets/devices/router.svg` returns an SVG (200).
- Headless-render the dashboard: a device tile shows an icon; an absent tile shows "seen Nh ago"; open edit → Icon picker shows Type grid + Brand search.
- Headless-render Settings: the "Icon sets" section expands and lists the bundled set; the upload form renders.
- [ ] **Step 6: Bump version + commit + push + tag**
```bash
# bump package.json "version" (minor, e.g. 2.5.0)
git add -A
git commit -m "feat: device icons, last-seen timer & uploadable icon sets (2.5.0)"
git push origin feat/device-icons
```
- [ ] **Step 7: Merge to main + push + tag** (after review)
```bash
git checkout main && git merge --no-ff feat/device-icons
git tag v2.5.0 && git push origin main --tags
```
- [ ] **Step 8: Document** — update the Void wiki (Devices band page + a short "Icon sets" how-to) and the project memory per the document-everything rule.
---
## Self-Review (completed)
- **Spec coverage:** icon model (T1,T2,T7,T9) · bundled set (T8) · uploadable sets multi-file/zip/URL (T4,T5,T6,T12) · SVG sanitize (T3) · API GET/POST/DELETE + serve (T6) · devices PATCH icon (T7) · tile icon + picker (T10,T11) · last-seen timer (T9,T11) · Settings panel (T12) · tests + snapshot+deploy+headless (T13). All spec sections map to a task.
- **Placeholder scan:** none — every code step has complete code; the two "read the file first" notes (api.js `req()` shape, settings.js section pattern) are explicit verification steps, not deferred logic.
- **Type consistency:** ref format `set:<set>:<name>` / `brand:<slug>` is identical in `iconRef` (T7), `resolveIcon` (T9), picker (T10), tile (T11). `processFile`/`writeIcon`/`listSets`/`readIcon` signatures match across T4T6 and the router T6.