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