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>
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_devicestable (migration 024): MAC-keyed inventory; already haslast_seen timestamptzandpresent boolean. No icon column yet.public/views/devices_band.js: renders tiles + an edit (✎) flow;/api/devicesPATCH (lib/api/routes/devices.js, zodpatchBody).- Existing icon proxy (reused for brand logos):
GET /api/icons/:slug.png→lib/health/icons.js#getIcon()fetcheswalkxcode/dashboard-iconsPNGs via jsDelivr and caches them to/var/lib/void/icons.validSlug = ^[a-z0-9-]+$.public/components/service_tile.jsrenders<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-trackedpublic/). Env overrideICON_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:
- Multi-file — one or more SVG/PNG files.
- 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).
- 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…] }](bundleddevicesscanned frompublic/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 bundleddevicesset is read-only (409).- Extend
patchBodyindevices.jswithicon: z.string().regex(/^(set:[a-z0-9-]+:[a-z0-9-]+|brand:[a-z0-9-]+)$/).nullable().optional(). - Ensure
GET /api/devicesreturnsiconandlast_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
devicesfirst, then uploads), fetched fromGET /api/icon-sets. - Brand: a search box → live preview from
/api/icons/<slug>.png. Selecting writesiconvia the existing PATCH.
- Type: grid grouped by set (bundled
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; bundleddevicesis 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).