Files
Void-Homelab/docs/superpowers/specs/2026-06-09-device-icons-and-last-seen-design.md
root 2f89a1aa50 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 <noreply@anthropic.com>
2026-06-09 08:17:51 +10:00

6.4 KiB

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.pnglib/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 <img src=/api/icons/${slug}.png> with a letter fallback on error.

Icon model

A device's icon value is one of:

  • set:<set>:<name> → bundled/uploaded type icon, served /api/icon-sets/<set>/<name>
  • brand:<slug> → dashboard-icons logo, served /api/icons/<slug>.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/<set>/<name>.(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 <script>, on* handlers, external refs — uploaded SVGs render inline → XSS risk even behind CF Access); enforce per-file size cap (e.g. 256 KB); write into the set dir.

4. API (lib/api/routes/)

  • GET /api/icon-sets[{ set, readonly, icons:[name…] }] (bundled devices scanned from public/icons/devices, uploads scanned from ICON_SETS_DIR).
  • GET /api/icon-sets/:set/:file → serve the icon (correct Content-Type; Cache-Control). Validates slugs; 404 on miss.
  • POST /api/icon-sets/:set (requireOwner) → create/extend a set. Accepts EITHER multipart files (one or more SVG/PNG, and/or a .zip) OR a JSON body { url } for URL ingest. All inputs run through the shared per-file processor (§3). Returns the updated set. SSRF guard + size/timeout caps on URL ingest.
  • DELETE /api/icon-sets/:set (requireOwner) → remove an uploaded set; the bundled devices set is read-only (409).
  • Extend patchBody in devices.js with icon: z.string().regex(/^(set:[a-z0-9-]+:[a-z0-9-]+|brand:[a-z0-9-]+)$/).nullable().optional().
  • Ensure GET /api/devices returns icon and last_seen.

5. Frontend — devices_band.js

  • resolveIcon(iconRef) (pure): set:/api/icon-sets/<set>/<name>; brand:/api/icons/<slug>.png; null → auto-default → set:devices:<name>. <img> with the existing letter fallback on error.
  • Tile shows the icon. Edit (✎) mode gains an icon picker with two tabs:
    • Type: grid grouped by set (bundled devices first, then uploads), fetched from GET /api/icon-sets.
    • Brand: a search box → live preview from /api/icons/<slug>.png. Selecting writes icon via the existing PATCH.
  • relativeTime(ts) (pure): <60s "just now"; <60m "Nm ago"; <24h "Nh ago"; else "Nd ago". Shown as "seen Nh ago" on absent tiles only (present tiles keep the online dot).

6. Settings — expandable "Icon sets" section

  • Collapsible panel (public/views/settings* pattern): lists each set as a grid of its icons; uploaded sets get a Delete; bundled devices is read-only.
  • Upload control: a set-name field plus three inputs → POST /api/icon-sets/:set, refresh the list on success:
    • multi-file picker (SVG/PNG),
    • zip picker (.zip),
    • a URL field ("ingest from URL" — image or zip).

Testing (vitest)

Pure/unit: resolveIcon, relativeTime, auto-default mapping, SVG sanitizer, slug validation, patchBody icon regex, zip-entry guard (traversal/zip-bomb), URL SSRF/scheme guard. Integration: icon-sets list/upload/delete (tmp dir via env override), multi-file + zip + URL ingest (mock fetcher) all landing files, devices PATCH accepts/round-trips icon, GET returns icon+last_seen. Reuse the existing icon-proxy test patterns.

Deploy

Per backup-before-major-updates: pct snapshot void-db (310) + void-app (311), run the migration, deploy via the health-gated script, headless-render the Devices band + Settings panel to confirm icons + picker + last-seen display. Ensure ICON_SETS_DIR exists + is writable by the void user; document the env var. Commit + push to Gitea Hynes/Void-Homelab; wiki page update.

Out of scope (YAGNI)

Per-icon recolor/theming, auto-icon-by-vendor guessing beyond the group default, icon sets shared across other Void features, scraping a whole remote icon-pack repo (URL ingest is single-file or single-zip, not a directory crawl).