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>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
# Device icons, last-seen timer & uploadable icon sets — design
|
||||
|
||||
Date: 2026-06-09
|
||||
Feature area: Void dashboard → LAN Devices band (`lan_devices`, migration 024)
|
||||
|
||||
## Goal
|
||||
Let the user assign an icon to each discovered LAN device (device-type icon OR
|
||||
brand logo — "both"), show how long ago an absent device was last seen, and
|
||||
manage/extend the available icons by uploading new icon sets from Settings.
|
||||
|
||||
## Background (existing code reused)
|
||||
- `lan_devices` table (migration 024): MAC-keyed inventory; already has
|
||||
`last_seen timestamptz` and `present boolean`. No icon column yet.
|
||||
- `public/views/devices_band.js`: renders tiles + an edit (✎) flow; `/api/devices`
|
||||
PATCH (`lib/api/routes/devices.js`, zod `patchBody`).
|
||||
- **Existing icon proxy** (reused for brand logos): `GET /api/icons/:slug.png`
|
||||
→ `lib/health/icons.js#getIcon()` fetches `walkxcode/dashboard-icons` PNGs via
|
||||
jsDelivr and caches them to `/var/lib/void/icons`. `validSlug = ^[a-z0-9-]+$`.
|
||||
`public/components/service_tile.js` renders `<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).
|
||||
Reference in New Issue
Block a user