From 2f89a1aa504610a5cb6ffb55735194b98cf76a5c Mon Sep 17 00:00:00 2001 From: root Date: Tue, 9 Jun 2026 08:17:51 +1000 Subject: [PATCH 01/22] docs(spec): device icons, last-seen timer & uploadable icon sets Design for: per-device icon (type-set or brand logo), "seen Nh ago" on absent tiles, and a Settings "Icon sets" panel with multi-file/zip/URL ingest. Co-Authored-By: Claude Opus 4.8 --- ...06-09-device-icons-and-last-seen-design.md | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-09-device-icons-and-last-seen-design.md diff --git a/docs/superpowers/specs/2026-06-09-device-icons-and-last-seen-design.md b/docs/superpowers/specs/2026-06-09-device-icons-and-last-seen-design.md new file mode 100644 index 0000000..c6f74f2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-device-icons-and-last-seen-design.md @@ -0,0 +1,117 @@ +# Device icons, last-seen timer & uploadable icon sets — design + +Date: 2026-06-09 +Feature area: Void dashboard → LAN Devices band (`lan_devices`, migration 024) + +## Goal +Let the user assign an icon to each discovered LAN device (device-type icon OR +brand logo — "both"), show how long ago an absent device was last seen, and +manage/extend the available icons by uploading new icon sets from Settings. + +## Background (existing code reused) +- `lan_devices` table (migration 024): MAC-keyed inventory; already has + `last_seen timestamptz` and `present boolean`. No icon column yet. +- `public/views/devices_band.js`: renders tiles + an edit (✎) flow; `/api/devices` + PATCH (`lib/api/routes/devices.js`, zod `patchBody`). +- **Existing icon proxy** (reused for brand logos): `GET /api/icons/:slug.png` + → `lib/health/icons.js#getIcon()` fetches `walkxcode/dashboard-icons` PNGs via + jsDelivr and caches them to `/var/lib/void/icons`. `validSlug = ^[a-z0-9-]+$`. + `public/components/service_tile.js` renders `` + with a letter fallback on error. + +## Icon model +A device's `icon` value is one of: +- `set::` → bundled/uploaded type icon, served `/api/icon-sets//` +- `brand:` → dashboard-icons logo, served `/api/icons/.png` (existing) +- `NULL` → auto-default chosen by group/vendor (pure function) + +Auto-default mapping (group → bundled `devices` set): +Network→router, Entertainment→tv, Smart Home→plug, Personal→phone, else→unknown. + +## Components + +### 1. Data — migration 02x +`ALTER TABLE lan_devices ADD COLUMN icon text;` (nullable). No backfill (NULL = +auto-default). Down: drop column. + +### 2. Bundled type-icon set (the "set of favicons") +Download ~15 **Tabler Icons** (MIT) SVGs into the repo at +`public/icons/devices/` as the read-only bundled set named `devices`: +router, phone, tablet, laptop, desktop, tv, speaker, camera, printer, console, +plug, server, watch, nas, unknown. Monochrome line icons → match blackflame. + +### 3. Uploadable icon sets (persistent, outside git) +- Storage: `/var/lib/void/icon-sets//.(svg|png)` (persistent volume, + survives redeploys — NOT in git-tracked `public/`). Env override + `ICON_SETS_DIR`, default `/var/lib/void/icon-sets`. +- A "set" is a directory of icon files. Set/name validated `^[a-z0-9-]+$`. +- **Three ingest methods**, all converging on the same per-file processor: + 1. **Multi-file** — one or more SVG/PNG files. + 2. **Zip archive** — server unpacks; each entry runs the per-file processor. + Reject path traversal / absolute paths / nested dirs (flatten basenames); + skip non-image entries; cap entry count + uncompressed total (zip-bomb + guard). + 3. **URL ingest** — server fetches a remote URL; if the payload is a zip it is + unpacked (as above), otherwise treated as a single image. http/https only + (scheme allowlist, SSRF guard), 8 s timeout, total size cap. +- **Per-file processor (shared):** validate name slug + extension; magic-byte + check (PNG/JPEG/SVG); **sanitize SVGs** (strip `'); + 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. From 59aba14ef7696d9ebda9d77f86d048751669dad0 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 9 Jun 2026 08:26:41 +1000 Subject: [PATCH 03/22] =?UTF-8?q?feat(devices):=20migration=20025=20?= =?UTF-8?q?=E2=80=94=20lan=5Fdevices.icon=20column?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- lib/db/migrations/025_lan_device_icon.sql | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 lib/db/migrations/025_lan_device_icon.sql diff --git a/lib/db/migrations/025_lan_device_icon.sql b/lib/db/migrations/025_lan_device_icon.sql new file mode 100644 index 0000000..05b932d --- /dev/null +++ b/lib/db/migrations/025_lan_device_icon.sql @@ -0,0 +1,4 @@ +-- 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; From 1626b3f80d921c9108f11cddba5a2766d3da5680 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 9 Jun 2026 08:26:44 +1000 Subject: [PATCH 04/22] feat(devices): repo returns + patches icon Co-Authored-By: Claude Opus 4.8 --- lib/db/repos/lan_devices.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/db/repos/lan_devices.js b/lib/db/repos/lan_devices.js index 626dacd..ecce4a1 100644 --- a/lib/db/repos/lan_devices.js +++ b/lib/db/repos/lan_devices.js @@ -1,6 +1,6 @@ import { pool } from '../pool.js'; -const COLS = 'mac, ip, vendor, name, grp, note, status, randomized, flagged, first_seen, last_seen, present'; +const COLS = 'mac, ip, vendor, name, grp, note, status, randomized, flagged, first_seen, last_seen, present, icon'; export async function listKnown() { const { rows } = await pool.query( @@ -70,7 +70,7 @@ export async function prune() { return rowCount; } -const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged']; +const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged', 'icon']; export async function update(mac, patch) { const sets = [], vals = []; for (const k of PATCHABLE) { From bfecb757b449b6451723785724a0278255aab62e Mon Sep 17 00:00:00 2001 From: root Date: Tue, 9 Jun 2026 08:28:06 +1000 Subject: [PATCH 05/22] feat(icons): SVG sanitizer for uploaded icons Co-Authored-By: Claude Opus 4.8 --- lib/icons/sanitize.js | 16 ++++++++++++++++ tests/icons/sanitize.test.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 lib/icons/sanitize.js create mode 100644 tests/icons/sanitize.test.js diff --git a/lib/icons/sanitize.js b/lib/icons/sanitize.js new file mode 100644 index 0000000..0d6e518 --- /dev/null +++ b/lib/icons/sanitize.js @@ -0,0 +1,16 @@ +// 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(//gi, ''); + s = s.replace(//gi, ''); + s = s.replace(/\son[a-z]+\s*=\s*"[^"]*"/gi, ''); + s = s.replace(/\son[a-z]+\s*=\s*'[^']*'/gi, ''); + // Unquoted handlers, e.g. . Value runs until whitespace, + // quote, or the tag's closing > / />. + s = s.replace(/\son[a-z]+\s*=\s*[^\s">]+/gi, ''); + s = s.replace(/(href|xlink:href)\s*=\s*("|')\s*javascript:[^"']*\2/gi, '$1=$2#$2'); + return s; +} diff --git a/tests/icons/sanitize.test.js b/tests/icons/sanitize.test.js new file mode 100644 index 0000000..f268392 --- /dev/null +++ b/tests/icons/sanitize.test.js @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeSvg } from '../../lib/icons/sanitize.js'; + +describe('sanitizeSvg', () => { + it('strips