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

40 KiB
Raw Blame History

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.jssanitizeSvg(input) pure.
  • Create lib/icons/ingest.jsprocessFile, unpackZip, fetchUrl, guards.
  • Create lib/icons/sets.jslistSets, 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.jsresolveIcon, 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

-- 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

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:

const COLS = 'mac, ip, vendor, name, grp, note, status, randomized, flagged, first_seen, last_seen, present, icon';
  • Step 2: Add icon to PATCHABLE
const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged', 'icon'];
  • Step 3: Commit (verified by Task 8)
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

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

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

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

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>):

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
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)

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:

export const iconRef = z.string().regex(/^(set:[a-z0-9-]+:[a-z0-9-]+|brand:[a-z0-9-]+)$/).nullable();

Then add to patchBody:

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
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):

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
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

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
// 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
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)

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

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":
  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():
  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
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

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

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

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):
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

# 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)
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.