# 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::' (type icon) or 'brand:' -- (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 '); expect(out).not.toMatch(/script/i); expect(out).toMatch(/ { const out = sanitizeSvg(''); expect(out).not.toMatch(/onload|onclick/i); }); it('neutralizes javascript: hrefs', () => { const out = sanitizeSvg('x'); expect(out).not.toMatch(/javascript:/i); }); it('drops ', () => { const out = sanitizeSvg('x'); expect(out).not.toMatch(/foreignObject/i); }); it('accepts a Buffer', () => { expect(sanitizeSvg(Buffer.from(''))).toMatch(//gi, ''); s = s.replace(//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('') }); 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(' 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'), ''); }); 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; 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 ``): ```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 `` 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::' or 'brand:'. 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::` / `brand:` is identical in `iconRef` (T7), `resolveIcon` (T9), picker (T10), tile (T11). `processFile`/`writeIcon`/`listSets`/`readIcon` signatures match across T4–T6 and the router T6.