36 Commits

Author SHA1 Message Date
root
600057582e feat(sacred-valley): hybrid free/snap canvas + blank & blackflame cards (2.8.0)
Replace masonry grid with an absolute-positioned 12-col canvas: drag to
move, corner to resize, per-card free/overlap toggle (Alt = no-snap).
Geometry persisted (migration 027: dashboard_layout.geom + extras).
Two new addable decorative cards: blank spacer + animated blackflame
(canvas particle flame). Old layout auto-migrates by flow-placement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:33:45 +10:00
root
e8f655ed27 feat(sacred-valley): masonry card layout — content-height row spans + dense flow
Cards keep their column span but now span grid rows proportional to
measured content height (ResizeObserver re-packs async cards), with
grid-auto-flow: dense. Fixes mismatched sizes / rigid rows / vertical gaps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:58:24 +10:00
root
25ac261862 feat(discover): name service candidates by port-service + matched device at scan time
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:48:16 +10:00
root
15de56dbe6 feat(littleblue): discovered services show matching network-device name
Cross-references each candidate host IP with lan_devices (known) so a tile shows
e.g. 'H Tower' instead of '192.168.1.15:32400'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:45:56 +10:00
root
442bb6ccc9 feat(littleblue): edit (✎) affordance for service tiles + name 8 discovered services
Service tiles now have an inline edit (name/category/url/icon, save/delete) like the
Devices band — fixes 'can't edit after adding'. Touch-visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:39:43 +10:00
root
ea20c55917 fix(devices): edit (✎) button always visible on touch devices
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:09:20 +10:00
root
4ef7fa2d75 fix(health): derive /health version from package.json (kills the manual server.js bump gotcha)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:58:44 +10:00
root
b17cdb7f77 fix(sv): Backups card byte formatter — tenths for GB, MB under 1G
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:49:00 +10:00
root
b967c0bfdd feat(sv): Backups card — offsite DR status (Core-4 -> Farm) + /api/backups (2.6.0)
migration 026 backup_runs; POST ingest (owner) from offsite-backup.sh, GET for the
Sacred Valley card showing last run, per-guest sizes, Farm free, schedule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:47:17 +10:00
root
16e324102e fix(icons): serve icons no-cache so updates propagate (2.5.2)
Icon route used Cache-Control: public, max-age=86400, so changed icons stayed
stuck in CF + browser caches for a day. Switch to no-cache (revalidate; Express
ETag => 304 when unchanged) so icon edits show up immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:25:54 +10:00
root
18eba2d911 fix(devices): recolor bundled icons to theme light + larger size (2.5.1)
Tabler icons use currentColor which doesn't inherit through <img>, so they
rendered black on the dark theme. Bake --text (#e8e6ed) into the 15 bundled
SVGs and bump icon sizes (tile 20->30px, picker 22->28px).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:19:27 +10:00
root
b16456fc1b fix(server): bump hardcoded /health VERSION to 2.5.0 (deploy gate)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:26:39 +10:00
root
cc82b16f0a Merge feat/device-icons: device icons, last-seen timer & uploadable icon sets (2.5.0)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:23:48 +10:00
root
1a28a5e57e chore(release): 2.5.0 — device icons, last-seen & uploadable icon sets
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:23:48 +10:00
root
fdf282b845 fix: defer icon sets panel creation until first settings section expand
Previously iconSetsPanel() was called eagerly on settings render,
triggering a GET /api/icon-sets request even while the section was
collapsed. Now the panel is created and appended on first toggle-open,
with subsequent clicks toggling display as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:12:56 +10:00
root
26a9be51d0 fix: drop jpg/jpeg support from icon system (svg + png only)
Icons.display path only handles svg/png, so jpg-backed icons never
rendered. Remove jpg/jpeg: drop from EXT map and magicOk in ingest.js,
narrow FILE regex in sets.js to (svg|png), update the file input
accept attribute in icon_sets_panel.js, and simplify the content-type
ternary in the icon_sets route (jpeg branch was now unreachable).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:12:28 +10:00
root
e309c32d8f fix: remove dead resolveIcon import from icon_picker.js
The import was unused — resolveIcon is never called in this file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:11:49 +10:00
root
086bd1e6a3 fix: strengthen SSRF guard in fetchUrl with DNS resolution check
Add exported isBlockedAddress(ip) helper covering loopback, 0.0.0.0,
private v4 (10/8, 172.16-31, 192.168/16), link-local (169.254/16,
fe80::/10), and IPv6 ULA (fc00::/7). In fetchUrl, after the existing
literal-hostname fast-reject, resolve the hostname via dns.lookup
(all:true) when using the real fetcher and block if any resolved
address isBlockedAddress. Injected fetcher (tests) skips DNS.
Drop unused contentType from return value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:11:42 +10:00
root
24d7bd72b4 test: add HTTP integration tests for /api/icon-sets
Covers GET (open, returns bundled devices set), POST without auth
(must return 401 not 500), POST with owner bearer (uploads icon,
returns set), and GET /:set/:file (serves with correct content-type).
Uses _setDirs for temp-dir isolation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:10:48 +10:00
root
3ea150bad1 fix: extract softAuth to shared module and apply to icon_sets router
Move the softAuth middleware from devices.js into a new shared
lib/api/soft_auth.js module. Apply router.use(softAuth) and
router.use(errorMiddleware) to icon_sets.js so that POST/DELETE
owner-only routes return 401 (not 500) when no auth is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:10:16 +10:00
root
5e38208eb3 feat(devices): styles for device icons, picker, settings panel
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:03:45 +10:00
root
d317f0e314 feat(settings): expandable Icon sets panel (view/upload/delete)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:58:18 +10:00
root
2bf66ec570 feat(devices): show icon + last-seen, icon picker in edit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:56:21 +10:00
root
0e9c8affd4 feat(devices): icon picker (Type sets + Brand search)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:54:51 +10:00
root
055a88932e feat(devices): pure icon resolver + relativeTime helpers
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:53:28 +10:00
root
69f1df2789 feat(icons): bundled Tabler device icon set
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:52:24 +10:00
root
b049aedd22 feat(devices): PATCH accepts icon ref
Export reusable iconRef zod validator (set:<set>:<name> | brand:<slug> | null)
and add it as an optional field to patchBody so PATCH /devices/:mac accepts icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:46:07 +10:00
root
4efeca74b2 feat(api): /api/icon-sets — list/serve/upload(zip,url)/delete
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:39:20 +10:00
root
9e99e0664f feat(icons): filesystem icon-set store (bundled read-only + uploads)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:37:56 +10:00
root
207ea906ee feat(icons): ingest — file processor, zip unpack, URL fetch (guards)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:36:32 +10:00
root
bfecb757b4 feat(icons): SVG sanitizer for uploaded icons
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:29:28 +10:00
root
1626b3f80d feat(devices): repo returns + patches icon
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:26:44 +10:00
root
59aba14ef7 feat(devices): migration 025 — lan_devices.icon column
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:26:41 +10:00
root
0e55fdef42 docs(plan): device icons, last-seen & uploadable icon sets — 13-task TDD plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:22:58 +10:00
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
root
1b960ec52b feat(sv): Storage · capacity card — ZFS pools, dropped pools, per-CT disk
Read-only Proxmox storage health (same PROXMOX_RO_TOKEN as the cluster card):
ZFS pool health+usage, dropped zfspool storages (the donatello/leonardo SATA
signal), and per-LXC rootfs fill, with a HEALTHY/WATCH/ATTENTION roll-up.
Closes the monitoring gap from the 2026-06-09 audit (C1 + H2 were invisible).
Pure normalizeStorage() unit-tested (4 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 03:27:15 +10:00
63 changed files with 3308 additions and 171 deletions

View File

@@ -3,6 +3,10 @@
All notable changes to Void 2.0 are documented here.
Format: [Keep a Changelog](https://keepachangelog.com).
## 2.4.0 — Storage · capacity card (Sacred Valley)
- **New "Storage · capacity" card** (`public/views/cards/storage.js`, `/api/storage`, `lib/proxmox/storage.js`) — read-only Proxmox health via the same `PROXMOX_RO_TOKEN` as the cluster card. Shows: **ZFS pools** (health + usage meter), **dropped pools** (a configured zfspool storage that's no longer `available` — the donatello/leonardo SATA-bus signal, rendered red), and **per-container disk fill** (top LXC by rootfs %), with a HEALTHY/WATCH/ATTENTION roll-up badge. Thresholds: 80% warn, 90% crit; a non-ONLINE or dropped pool is always crit.
- Closes the monitoring gap from the 2026-06-09 audit (the Void couldn't previously see C1 = pools offline or H2 = a container at 95%). Pure `normalizeStorage()` is unit-tested.
## 2.3.0 — MagicMirror² as a Void app
- **New "MagicMirror" Apps view** (`#/mirror`, `public/views/mirror.js`) — embeds the smart-mirror dashboard (CT 111) via the shared `embedView` factory, like Timelapse / AI Usage.
- **Exposure:** MagicMirror (LAN-only `192.168.1.224:8080`) is now published at **mirror.hynesy.com** through Traefik + the `*.hynesy.com` tunnel, private behind **CF Access** (Farm policy / Google IdP). A Traefik `mirror-frame` middleware replaces MM's `X-Frame-Options: SAMEORIGIN` with a CSP `frame-ancestors` allowing the Void origins so the iframe renders.

View File

@@ -0,0 +1,150 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Blackflame card — mockup</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<style>
:root{
--bg:#0a0a0e; --panel:#14141c; --panel-2:#1c1c26; --border:#2a2a36;
--text:#e8e6ed; --muted:#888094;
--accent:#ff4f2e; --accent-dim:#7a2716; --accent-soft:#3a1610;
--font-display:'Cinzel',serif; --font-mono:'JetBrains Mono',monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:#06060a;min-height:100vh;display:grid;place-items:center;
font-family:var(--font-mono);color:var(--text);gap:18px;padding:40px}
/* a Sacred-Valley-style card shell */
.sv-card{
width:440px;height:440px;position:relative;border-radius:14px;
background:radial-gradient(120% 120% at 50% 18%, #14131b 0%, #0b0a10 60%, #08070c 100%);
border:1px solid var(--border);overflow:hidden;
box-shadow:0 0 0 1px #00000060, 0 24px 60px -20px #000,
inset 0 0 70px -30px var(--accent-soft);
}
.sv-card::after{ /* faint inner ring */
content:"";position:absolute;inset:0;border-radius:14px;pointer-events:none;
box-shadow:inset 0 0 0 1px #ff4f2e12;
}
canvas{position:absolute;inset:0;width:100%;height:100%}
.label{
position:absolute;left:0;right:0;bottom:22px;text-align:center;z-index:3;
font-family:var(--font-display);letter-spacing:.42em;text-transform:uppercase;
font-size:15px;color:#f4ece9;text-indent:.42em;
text-shadow:0 0 18px #ff4f2e88, 0 0 4px #000;
}
.sub{
position:absolute;left:0;right:0;bottom:8px;text-align:center;z-index:3;
font-family:var(--font-mono);font-size:9.5px;letter-spacing:.32em;
text-transform:uppercase;color:#6a6475;
}
.crest{ /* the still, dark heart of the flame */
position:absolute;top:50%;left:50%;width:84px;height:84px;z-index:2;
transform:translate(-50%,-58%);border-radius:50%;
background:radial-gradient(circle at 50% 45%, #000 38%, #0a0a10 60%, transparent 72%);
box-shadow:0 0 26px 10px #000, 0 0 60px 18px #ff4f2e22;
}
.hint{font-family:var(--font-mono);font-size:11px;color:#6a6475;letter-spacing:.04em}
</style>
</head>
<body>
<div class="sv-card" id="card">
<canvas id="fx"></canvas>
<div class="crest"></div>
<div class="label">The&nbsp;Void</div>
<div class="sub">Sacred Valley · core</div>
</div>
<div class="hint">Blackflame · canvas particle flame (dark heart, crimson licks, rising embers)</div>
<script>
const cv = document.getElementById('fx');
const ctx = cv.getContext('2d');
let W,H,DPR;
function resize(){
DPR = Math.min(2, window.devicePixelRatio||1);
const r = cv.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = W*DPR; cv.height = H*DPR;
ctx.setTransform(DPR,0,0,DPR,0,0);
}
resize(); addEventListener('resize', resize);
// ---- rising flame: stacked soft blobs along oscillating "tongues" + embers ----
const TAU = Math.PI*2;
const tongues = Array.from({length:5}, (_,i)=>({
phase: Math.random()*TAU, speed: .6+Math.random()*.5,
x: 0.5 + (i-2)*0.085, sway: 0.03+Math.random()*0.04, w: 0.34-Math.abs(i-2)*0.05
}));
const embers = Array.from({length:46}, ()=>spawn());
function spawn(){ return {
x: 0.5 + (Math.random()-0.5)*0.5, y: 1+Math.random()*0.2,
vy: 0.0016+Math.random()*0.0030, vx:(Math.random()-0.5)*0.0012,
r: 0.6+Math.random()*1.8, life: 0, max: 120+Math.random()*120,
hot: Math.random()<0.5 };
}
function blob(x,y,r,c0,c1){
const g = ctx.createRadialGradient(x,y,0,x,y,r);
g.addColorStop(0,c0); g.addColorStop(1,c1);
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,r,0,TAU); ctx.fill();
}
let t=0;
function frame(){
t += 0.016;
// base wash
ctx.globalCompositeOperation='source-over';
ctx.clearRect(0,0,W,H);
// flame body — additive so overlaps glow
ctx.globalCompositeOperation='lighter';
const baseY = H*0.92;
for(const tg of tongues){
const cx = W*(tg.x + Math.sin(t*tg.speed+tg.phase)*tg.sway);
const height = H*(0.62 + 0.08*Math.sin(t*1.7+tg.phase));
const steps = 26;
for(let s=0;s<steps;s++){
const f = s/steps; // 0 base → 1 tip
const y = baseY - f*height;
const flick = Math.sin(t*3.2 + tg.phase + f*7)*W*0.012*(0.4+f);
const x = cx + flick + Math.sin(t*tg.speed*1.3+f*4)*W*tg.sway*0.6;
const r = W*tg.w*(1-f*0.78)*(0.9+0.1*Math.sin(t*5+f*9));
// colour ramps dark-red → crimson → fades at the tip (black-flame: dark heart)
const a = (1-f)*0.5;
if(f<0.18){ blob(x,y,r*1.1, `rgba(60,12,6,${a*0.7})`, 'rgba(60,12,6,0)'); }
else if(f<0.62){ blob(x,y,r, `rgba(255,79,46,${a*0.5})`, 'rgba(122,39,22,0)'); }
else { blob(x,y,r*0.8, `rgba(255,150,90,${a*0.5})`, 'rgba(255,79,46,0)'); }
}
}
// embers
for(const e of embers){
e.life++; e.y -= e.vy; e.x += e.vx + Math.sin(t*2+e.y*8)*0.0004;
if(e.y<0.18 || e.life>e.max){ Object.assign(e, spawn()); }
const px=e.x*W, py=e.y*H, fade=1-e.life/e.max;
const col = e.hot? `rgba(255,170,110,${fade*0.9})` : `rgba(255,79,46,${fade*0.8})`;
ctx.shadowBlur=8; ctx.shadowColor='#ff4f2e';
blob(px,py,e.r*1.6, col, 'rgba(255,79,46,0)');
}
ctx.shadowBlur=0;
// carve the dark heart back in (the "black" of black-flame)
ctx.globalCompositeOperation='source-over';
const cx=W*0.5, cy=H*0.46, cr=W*0.20;
const core = ctx.createRadialGradient(cx,cy,0,cx,cy,cr);
core.addColorStop(0,'rgba(4,4,8,0.96)');
core.addColorStop(0.55,'rgba(6,5,10,0.7)');
core.addColorStop(1,'rgba(8,7,12,0)');
ctx.fillStyle=core; ctx.beginPath(); ctx.arc(cx,cy,cr,0,TAU); ctx.fill();
// base glow pool
ctx.globalCompositeOperation='lighter';
blob(W*0.5, H*0.95, W*0.4, 'rgba(255,79,46,0.10)', 'rgba(255,79,46,0)');
requestAnimationFrame(frame);
}
frame();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -34,6 +34,8 @@ import { router as littleblueRouter } from './routes/littleblue.js';
import { router as aiUsageRouter } from './routes/ai_usage.js';
import { router as infraRouter } from './routes/infra.js';
import { router as clusterRouter } from './routes/cluster.js';
import { router as storageRouter } from './routes/storage.js';
import { router as backupsRouter } from './routes/backups.js';
import { router as kuttRouter } from './routes/kutt.js';
export function mountApi(app) {
@@ -50,6 +52,8 @@ export function mountApi(app) {
api.use('/actions', actionsRouter);
api.use('/infra', infraRouter);
api.use('/cluster', clusterRouter);
api.use('/storage', storageRouter);
api.use('/backups', backupsRouter);
api.use('/little-blue', littleblueRouter);
api.use('/ai-usage', aiUsageRouter);
api.use('/projects', projectsRouter);

30
lib/api/routes/backups.js Normal file
View File

@@ -0,0 +1,30 @@
import { Router } from 'express';
import { z } from 'zod';
import { asyncWrap } from '../errors.js';
import { requireOwner } from '../cap.js';
import { validate } from '../validate.js';
import * as backups from '../../db/repos/backups.js';
export const router = Router();
export const ingest = z.object({
ok: z.boolean().optional(),
total_bytes: z.number().int().nonnegative().nullable().optional(),
won_free_bytes: z.number().int().nonnegative().nullable().optional(),
guests: z.array(z.object({
vmid: z.union([z.number().int(), z.string()]),
name: z.string().max(64),
bytes: z.number().int().nonnegative()
})).max(50).nullable().optional(),
duration_sec: z.number().int().nonnegative().nullable().optional()
});
// POST /api/backups — the offsite-backup script reports a run (owner only).
router.post('/', requireOwner, validate({ body: ingest }), asyncWrap(async (req, res) => {
res.status(201).json(await backups.record(req.body));
}));
// GET /api/backups — latest run + count, for the Sacred Valley "Backups" card.
router.get('/', asyncWrap(async (_req, res) => {
res.json({ latest: await backups.latest(), count: await backups.count(), schedule: 'Sun 02:00' });
}));

View File

@@ -8,11 +8,23 @@ import * as repo from '../../db/repos/dashboard_layout.js';
export const router = Router();
router.use(requireOwner);
const geomCell = z.object({
x: z.number(), y: z.number(),
w: z.number().positive(), h: z.number().positive(),
free: z.boolean().optional()
});
const layoutSchema = z.object({
card_order: z.array(z.string()).default([]),
hidden: z.array(z.string()).default([]),
// Per-card width: an integer column span 112 (legacy 's'|'m'|'l' still accepted).
sizes: z.record(z.union([z.number().int().min(1).max(12), z.enum(['s', 'm', 'l'])])).default({})
sizes: z.record(z.union([z.number().int().min(1).max(12), z.enum(['s', 'm', 'l'])])).default({}),
// Hybrid-canvas geometry, keyed by card id → {x,y,w,h,free} in 12-col grid units.
geom: z.record(geomCell).default({}),
// User-added decorative card instances that must survive reloads.
extras: z.array(z.object({
id: z.string().min(1).max(64),
type: z.enum(['blank', 'blackflame'])
})).default([])
});
router.get('/layout', asyncWrap(async (_req, res) => {

View File

@@ -5,32 +5,9 @@ import { requireOwner } from '../cap.js';
import { validate } from '../validate.js';
import * as devices from '../../db/repos/lan_devices.js';
import { isRandomizedMac } from '../../infra/scan.js';
import * as agents from '../../db/repos/agents.js';
import { timingSafeStrEqual } from '../../auth/safe_compare.js';
import { accessOwnerEmail } from '../../auth/cf_access.js';
import { softAuth } from '../soft_auth.js';
export const router = Router();
// Soft auth: identifies the actor if auth is present but never blocks the request.
// Owner-only sub-routes enforce 401/403 via requireOwner.
async function softAuth(req, _res, next) {
try {
const cfEmail = await accessOwnerEmail(req);
if (cfEmail) { req.actor = { kind: 'user', id: null }; return next(); }
const auth = req.headers.authorization || '';
const [scheme, token] = auth.split(' ');
if (scheme === 'Bearer' && token) {
if (process.env.OWNER_TOKEN && timingSafeStrEqual(token, process.env.OWNER_TOKEN)) {
req.actor = { kind: 'user', id: null }; return next();
}
try {
const agent = await agents.verifyToken(token);
if (agent) req.actor = { kind: 'agent', id: agent.id, capabilities: agent.capabilities || {}, scopes: agent.scopes || {} };
} catch { /* ignore */ }
}
} catch { /* ignore */ }
next();
}
const GROUP_ORDER = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
router.use(softAuth);
@@ -53,12 +30,14 @@ router.get('/discovered', requireOwner, asyncWrap(async (_req, res) => {
}));
const macParam = z.object({ mac: z.string().regex(/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i) });
export const iconRef = z.string().regex(/^(set:[a-z0-9-]+:[a-z0-9-]+|brand:[a-z0-9-]+)$/).nullable();
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()
flagged: z.boolean().optional(),
icon: iconRef.optional()
});
// PATCH /devices/:mac — name / edit / promote (owner). This is "add from discovered".

View File

@@ -5,6 +5,7 @@ import { requireOwner } from '../cap.js';
import { validate } from '../validate.js';
import { grouped, iconSlug } from '../../health/registry.js';
import * as services from '../../db/repos/monitored_services.js';
import * as devices from '../../db/repos/lan_devices.js';
import * as statusRepo from '../../db/repos/service_status.js';
import { enqueue } from '../../jobs/queue.js';
@@ -29,7 +30,13 @@ router.get('/services', asyncWrap(async (_req, res) => {
// GET /services/discovered — candidates from a LAN scan, awaiting review (owner).
router.get('/services/discovered', requireOwner, asyncWrap(async (_req, res) => {
res.json((await services.listDiscovered()).map(s => ({ ...s, icon: iconSlug(s) })));
// Cross-reference each candidate's host IP with the Network Devices band so the
// tile can show a known device name instead of a bare IP:port.
const byIp = Object.fromEntries(
(await devices.listKnown()).filter(d => d.ip).map(d => [d.ip, d.name]));
res.json((await services.listDiscovered()).map(s => ({
...s, icon: iconSlug(s), device: byIp[s.host] || null
})));
}));
const checkCfg = z.object({ type: z.enum(['http', 'tcp']).optional(), path: z.string().max(200).optional() });

View File

@@ -0,0 +1,57 @@
// lib/api/routes/icon_sets.js
import { Router } from 'express';
import multer from 'multer';
import { requireOwner } from '../cap.js';
import { asyncWrap, errorMiddleware } from '../errors.js';
import { softAuth } from '../soft_auth.js';
import * as sets from '../../icons/sets.js';
import { processFile, unpackZip, fetchUrl, isZip } from '../../icons/ingest.js';
export const router = Router();
router.use(softAuth);
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' : 'image/png';
// no-cache => browsers/CF revalidate (304 via Express's ETag when unchanged), so
// icon updates propagate immediately instead of being stuck for a day. Icons are
// tiny, so the revalidation cost is negligible.
res.set('Content-Type', ct).set('Cache-Control', 'no-cache').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 });
}));
router.use(errorMiddleware);

17
lib/api/routes/storage.js Normal file
View File

@@ -0,0 +1,17 @@
import { Router } from 'express';
import { asyncWrap } from '../errors.js';
import { storageHealth } from '../../proxmox/storage.js';
// Read-only storage/capacity health for the Sacred Valley card. Cached briefly so
// multiple polling clients coalesce into one set of PVE calls. Owner or any authed agent.
export const router = Router();
let cache = { at: 0, data: null };
const TTL = 15_000;
router.get('/', asyncWrap(async (_req, res) => {
if (cache.data && Date.now() - cache.at < TTL) return res.json(cache.data);
const data = await storageHealth();
cache = { at: Date.now(), data };
res.json(data);
}));

25
lib/api/soft_auth.js Normal file
View File

@@ -0,0 +1,25 @@
// lib/api/soft_auth.js — shared middleware
// Soft auth: identifies the actor if auth is present but never blocks the request.
// Owner-only sub-routes enforce 401/403 via requireOwner.
import * as agents from '../db/repos/agents.js';
import { timingSafeStrEqual } from '../auth/safe_compare.js';
import { accessOwnerEmail } from '../auth/cf_access.js';
export async function softAuth(req, _res, next) {
try {
const cfEmail = await accessOwnerEmail(req);
if (cfEmail) { req.actor = { kind: 'user', id: null }; return next(); }
const auth = req.headers.authorization || '';
const [scheme, token] = auth.split(' ');
if (scheme === 'Bearer' && token) {
if (process.env.OWNER_TOKEN && timingSafeStrEqual(token, process.env.OWNER_TOKEN)) {
req.actor = { kind: 'user', id: null }; return next();
}
try {
const agent = await agents.verifyToken(token);
if (agent) req.actor = { kind: 'agent', id: agent.id, capabilities: agent.capabilities || {}, scopes: agent.scopes || {} };
} catch { /* ignore */ }
}
} catch { /* ignore */ }
next();
}

View File

@@ -0,0 +1,4 @@
-- 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;

View File

@@ -0,0 +1,12 @@
-- 026_backup_runs.sql
-- Offsite DR backup run history, fed by /usr/local/bin/offsite-backup.sh on CT 300
-- (Core-4 vzdump -> Farm/Won). Powers the Sacred Valley "Backups" card.
CREATE TABLE IF NOT EXISTS backup_runs (
id serial PRIMARY KEY,
ran_at timestamptz NOT NULL DEFAULT now(),
ok boolean NOT NULL DEFAULT true,
total_bytes bigint,
won_free_bytes bigint,
guests jsonb, -- [{vmid,name,bytes}]
duration_sec integer
);

View File

@@ -0,0 +1,7 @@
-- 027_dashboard_geom.sql
-- Sacred Valley hybrid canvas: free/snap geometry per card + decorative card
-- instances (blank spacers, blackflame). geom is keyed by card id →
-- {x,y,w,h,free} in (fractional) 12-col grid units; extras lists the
-- user-added decorative cards so they survive reloads.
ALTER TABLE dashboard_layout ADD COLUMN IF NOT EXISTS geom jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE dashboard_layout ADD COLUMN IF NOT EXISTS extras jsonb NOT NULL DEFAULT '[]'::jsonb;

21
lib/db/repos/backups.js Normal file
View File

@@ -0,0 +1,21 @@
import { pool } from '../pool.js';
export async function record({ ok = true, total_bytes = null, won_free_bytes = null,
guests = null, duration_sec = null }) {
const { rows: [r] } = await pool.query(
`INSERT INTO backup_runs (ok, total_bytes, won_free_bytes, guests, duration_sec)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[ok, total_bytes, won_free_bytes, guests ? JSON.stringify(guests) : null, duration_sec]);
return r;
}
export async function latest() {
const { rows: [r] } = await pool.query(
`SELECT * FROM backup_runs ORDER BY id DESC LIMIT 1`);
return r || null;
}
export async function count() {
const { rows: [r] } = await pool.query(`SELECT count(*)::int AS n FROM backup_runs`);
return r.n;
}

View File

@@ -1,24 +1,28 @@
import { pool } from '../pool.js';
const DEFAULTS = { card_order: [], hidden: [], sizes: {} };
const DEFAULTS = { card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] };
export async function get() {
const { rows } = await pool.query(
`SELECT card_order, hidden, sizes FROM dashboard_layout WHERE owner_key = 'owner'`
`SELECT card_order, hidden, sizes, geom, extras
FROM dashboard_layout WHERE owner_key = 'owner'`
);
return rows[0] || { ...DEFAULTS };
}
export async function put({ card_order = [], hidden = [], sizes = {} }) {
export async function put({ card_order = [], hidden = [], sizes = {}, geom = {}, extras = [] }) {
await pool.query(
`INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, updated_at)
VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, now())
`INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, geom, extras, updated_at)
VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, $4::jsonb, $5::jsonb, now())
ON CONFLICT (owner_key) DO UPDATE
SET card_order = EXCLUDED.card_order,
hidden = EXCLUDED.hidden,
sizes = EXCLUDED.sizes,
geom = EXCLUDED.geom,
extras = EXCLUDED.extras,
updated_at = now()`,
[JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes)]
[JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes),
JSON.stringify(geom), JSON.stringify(extras)]
);
return get();
}

View File

@@ -1,6 +1,6 @@
import { pool } from '../pool.js';
const COLS = 'mac, ip, vendor, name, grp, note, status, randomized, flagged, first_seen, last_seen, present';
const COLS = 'mac, ip, vendor, name, grp, note, status, randomized, flagged, first_seen, last_seen, present, icon';
export async function listKnown() {
const { rows } = await pool.query(
@@ -70,7 +70,7 @@ export async function prune() {
return rowCount;
}
const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged'];
const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged', 'icon'];
export async function update(mac, patch) {
const sets = [], vals = [];
for (const k of PATCHABLE) {

129
lib/icons/ingest.js Normal file
View File

@@ -0,0 +1,129 @@
// lib/icons/ingest.js
import path from 'node:path';
import dns from 'node:dns';
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' };
const PNG_SIG = [0x89,0x50,0x4e,0x47];
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 === '.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;
/**
* Returns true if the given IP string is a blocked (loopback, private,
* link-local, or ULA) address that should not be fetched.
* Handles both IPv4 and IPv6.
*/
export function isBlockedAddress(ip) {
if (!ip) return true;
// IPv6 loopback
if (ip === '::1') return true;
// IPv4-mapped loopback ::ffff:127.x.x.x
const v4mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
const v4 = v4mapped ? v4mapped[1] : ip;
if (/^\d+\.\d+\.\d+\.\d+$/.test(v4)) {
const parts = v4.split('.').map(Number);
const [a, b] = parts;
// 0.0.0.0
if (a === 0) return true;
// 127.0.0.0/8 — loopback
if (a === 127) return true;
// 10.0.0.0/8 — private
if (a === 10) return true;
// 172.16.0.0/12 — private
if (a === 172 && b >= 16 && b <= 31) return true;
// 192.168.0.0/16 — private
if (a === 192 && b === 168) return true;
// 169.254.0.0/16 — link-local
if (a === 169 && b === 254) return true;
return false;
}
// IPv6 checks (expand to lower-case for prefix matching)
const lower = ip.toLowerCase();
// ::1 is caught above; handle full-form loopback
if (lower === '0:0:0:0:0:0:0:1') return true;
// fe80::/10 — link-local (fe80 febf)
if (/^fe[89ab][0-9a-f]:/i.test(lower)) return true;
// fc00::/7 — ULA (fc00 fdff)
if (/^f[cd][0-9a-f]{2}:/i.test(lower)) return true;
return false;
}
function dnsLookupAll(hostname) {
return new Promise((resolve, reject) =>
dns.lookup(hostname, { all: true }, (err, addrs) => err ? reject(err) : resolve(addrs))
);
}
// Fetch a remote icon or zip. SSRF guard: http/https only, no localhost/private,
// DNS-resolved address check, size + timeout caps. `fetcher` injectable for tests.
export async function fetchUrl(url, { fetcher } = {}) {
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');
// Fast literal-hostname guard (catches raw IP strings and 'localhost' without DNS)
if (PRIVATE_HOST.test(u.hostname)) throw new Error('blocked_host');
// DNS resolution guard — only when using the real fetcher (not in tests)
if (!fetcher) {
const addrs = await dnsLookupAll(u.hostname).catch(() => []);
if (addrs.some(a => isBlockedAddress(a.address))) throw new Error('blocked_host');
}
const realFetcher = fetcher ?? fetch;
const res = await realFetcher(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');
return { buffer: Buffer.from(ab) };
}
export function isZip(buf) { return buf && buf.length > 4 && buf[0] === 0x50 && buf[1] === 0x4b; }

16
lib/icons/sanitize.js Normal file
View File

@@ -0,0 +1,16 @@
// 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, '');
// Unquoted handlers, e.g. <svg onload=alert(1)>. Value runs until whitespace,
// quote, or the tag's closing > / />.
s = s.replace(/\son[a-z]+\s*=\s*[^\s">]+/gi, '');
s = s.replace(/(href|xlink:href)\s*=\s*("|')\s*javascript:[^"']*\2/gi, '$1=$2#$2');
return s;
}

52
lib/icons/sets.js Normal file
View File

@@ -0,0 +1,52 @@
// 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)$/;
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 });
}

View File

@@ -1,9 +1,18 @@
import net from 'node:net';
import * as services from '../../db/repos/monitored_services.js';
import * as devices from '../../db/repos/lan_devices.js';
import { log } from '../../log.js';
export const NAME = 'discover.lan';
// Well-known homelab ports → likely service, so candidates get a real name.
const PORT_SVC = {
2424: 'Void', 5055: 'Overseerr', 6767: 'Bazarr', 7878: 'Radarr', 8006: 'Proxmox VE',
8096: 'Jellyfin', 8123: 'Home Assistant', 8265: 'Tdarr', 8384: 'Syncthing', 8989: 'Sonarr',
9000: 'Portainer', 9090: 'Cockpit', 9696: 'Prowlarr', 11434: 'Ollama', 19999: 'Netdata',
32400: 'Plex'
};
// Common homelab web/service ports to probe.
const PORTS = [80, 81, 443, 2424, 3000, 3001, 5000, 5055, 6767, 6875, 7878, 8000,
8006, 8080, 8081, 8096, 8123, 8265, 8384, 8443, 8989, 9000, 9090, 9696, 11434, 19999, 32400, 60072];
@@ -55,13 +64,18 @@ export async function handler(job) {
// 1) TCP sweep → live host:ports
const open = (await mapPool(targets, 120, async (t) => (await _tcp(t.host, t.port)) ? t : null)).filter(Boolean);
// 2) HTTP-probe each, build + upsert discovered candidates (no-clobber in the repo)
// 2) HTTP-probe each, build + upsert discovered candidates (no-clobber in the repo).
// Cross-reference the Network Devices band so candidates are named by service+device.
const deviceByIp = Object.fromEntries(
(await devices.listKnown()).filter(d => d.ip).map(d => [d.ip, d.name]));
let added = 0;
for (const { host, port } of open) {
const scheme = HTTPS_PORTS.has(port) ? 'https' : 'http';
const url = `${scheme}://${host}:${port}`;
const probe = await _http(url);
const name = (probe && probe.title) || `${host}:${port}`;
const dev = deviceByIp[host];
const svc = PORT_SVC[port] || (probe && probe.title) || null;
const name = svc ? (dev ? `${svc} · ${dev}` : svc) : (dev ? `${dev} :${port}` : `${host}:${port}`);
const id = `disc-${host.replace(/\./g, '-')}-${port}`;
const check = scheme === 'https' ? { type: 'tcp' } : { type: 'http' };
const r = await services.upsertDiscovered({ id, name, category: 'other', host, url, check });

94
lib/proxmox/storage.js Normal file
View File

@@ -0,0 +1,94 @@
import { Agent } from 'undici';
// Read-only Proxmox storage + capacity health for the Sacred Valley card. Same
// PVEAuditor token as the cluster card (PROXMOX_RO_TOKEN). Surfaces the two things
// that have actually bitten this homelab and were previously invisible:
// 1. a ZFS pool dropping out (the donatello/leonardo SATA-bus incident) — seen as
// a zfspool storage whose status is no longer 'available'.
// 2. a container rootfs filling up (mediastack hitting 95%) — per-LXC disk/maxdisk.
let insecure;
function tlsDispatcher() {
if (process.env.PROXMOX_INSECURE_TLS !== '1') return undefined;
insecure ??= new Agent({ connect: { rejectUnauthorized: false } });
return insecure;
}
async function pveGet(path, { apiUrl, token, fetchImpl = fetch }) {
const res = await fetchImpl(`${apiUrl}/api2/json${path}`, {
headers: { Authorization: `PVEAPIToken=${token}` },
dispatcher: tlsDispatcher()
});
if (!res.ok) throw new Error(`pve ${path} -> ${res.status}`);
return (await res.json())?.data ?? [];
}
export const WARN = 80, CRIT = 90;
const pct = (used, total) => (total > 0 ? Math.round((used / total) * 100) : null);
const sev = p => (p == null ? 'ok' : p >= CRIT ? 'crit' : p >= WARN ? 'warn' : 'ok');
const worstOf = items => items.reduce(
(w, x) => (x.status === 'crit' || w === 'crit') ? 'crit' : (x.status === 'warn' || w === 'warn') ? 'warn' : 'ok', 'ok');
// Pure: fold /nodes/*/disks/zfs + /cluster/resources(storage,vm) into the card shape.
export function normalizeStorage(storageRes = [], vmRes = [], zfsByNode = {}) {
// Imported ZFS pools (health + usage)
const pools = [];
for (const [node, list] of Object.entries(zfsByNode)) {
for (const z of (list || [])) {
const p = pct(z.alloc, z.size);
pools.push({
name: z.name, node, health: z.health, used: z.alloc, total: z.size, pct: p,
status: z.health !== 'ONLINE' ? 'crit' : sev(p)
});
}
}
pools.sort((a, b) => a.name.localeCompare(b.name) || a.node.localeCompare(b.node));
// zfspool storages that are configured but NOT available = a pool that has dropped
// out (or never imported). This is the donatello/leonardo signal.
const down = storageRes
.filter(s => s.plugintype === 'zfspool' && s.status !== 'available')
.map(s => ({ name: s.storage, node: s.node, state: s.status || 'unavailable', status: 'crit' }))
.sort((a, b) => a.name.localeCompare(b.name) || a.node.localeCompare(b.node));
// Per-guest rootfs fill. LXC report disk/maxdisk; QEMU usually report disk=0
// (no agent) so they're skipped rather than shown as 0%.
const guests = vmRes
.filter(v => v.type === 'lxc' && v.maxdisk > 0 && v.disk > 0)
.map(v => {
const p = pct(v.disk, v.maxdisk);
return { vmid: v.vmid, name: v.name, node: v.node, used: v.disk, total: v.maxdisk, pct: p, status: sev(p) };
})
.sort((a, b) => b.pct - a.pct);
const alerts = [
...down.map(d => `${d.name} (${d.node}) ${d.state}`),
...pools.filter(p => p.health !== 'ONLINE').map(p => `pool ${p.name} ${p.health}`),
...guests.filter(g => g.status !== 'ok').map(g => `CT ${g.vmid} ${g.name} ${g.pct}%`)
];
return { worst: worstOf([...pools, ...down, ...guests]), pools, down, guests, alerts };
}
export async function storageHealth(opts = {}) {
const cfg = {
apiUrl: opts.apiUrl || process.env.PROXMOX_API_URL,
token: opts.token || process.env.PROXMOX_RO_TOKEN || process.env.PROXMOX_API_TOKEN,
fetchImpl: opts.fetchImpl || fetch
};
if (!cfg.apiUrl || !cfg.token) return { error: 'proxmox_not_configured', at: Date.now() };
try {
const [storageRes, vmRes, nodes] = await Promise.all([
pveGet('/cluster/resources?type=storage', cfg),
pveGet('/cluster/resources?type=vm', cfg),
pveGet('/nodes', cfg)
]);
const zfsByNode = {};
await Promise.all((nodes || [])
.filter(n => n.status === 'online')
.map(async n => { zfsByNode[n.node] = await pveGet(`/nodes/${n.node}/disks/zfs`, cfg).catch(() => []); }));
return { ...normalizeStorage(storageRes, vmRes, zfsByNode), at: Date.now() };
} catch (e) {
return { error: String(e.message || e), at: Date.now() };
}
}

14
package-lock.json generated
View File

@@ -1,15 +1,16 @@
{
"name": "void-server",
"version": "2.0.0-alpha.16",
"version": "2.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "void-server",
"version": "2.0.0-alpha.16",
"version": "2.8.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0",
"adm-zip": "^0.5.17",
"bcrypt": "^6.0.0",
"dompurify": "^3.4.7",
"dotenv": "^17.4.2",
@@ -965,6 +966,15 @@
"node": ">= 0.6"
}
},
"node_modules/adm-zip": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
"license": "MIT",
"engines": {
"node": ">=12.0"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "void-server",
"version": "2.3.0",
"version": "2.8.0",
"type": "module",
"private": true,
"scripts": {
@@ -12,6 +12,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0",
"adm-zip": "^0.5.17",
"bcrypt": "^6.0.0",
"dompurify": "^3.4.7",
"dotenv": "^17.4.2",

View File

@@ -11,11 +11,14 @@ function token() { return localStorage.getItem(TOKEN_KEY) || ''; }
async function call(method, path, body) {
const headers = { 'Authorization': 'Bearer ' + token() };
if (body !== undefined) headers['Content-Type'] = 'application/json';
// FormData bodies: let the browser set the multipart/form-data boundary
// automatically — do NOT set Content-Type or JSON.stringify.
const isFormData = body instanceof FormData;
if (body !== undefined && !isFormData) headers['Content-Type'] = 'application/json';
const res = await fetch(path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body)
body: body === undefined ? undefined : (isFormData ? body : JSON.stringify(body))
});
if (res.status === 401) { await promptForToken(); return call(method, path, body); }
if (res.status === 204) return null;
@@ -61,11 +64,14 @@ function promptForToken() {
}
export const api = {
get: (p) => call('GET', p),
post: (p, body) => call('POST', p, body ?? {}),
put: (p, body) => call('PUT', p, body ?? {}),
patch: (p, body) => call('PATCH', p, body ?? {}),
del: (p) => call('DELETE', p),
get: (p) => call('GET', p),
post: (p, body) => call('POST', p, body ?? {}),
put: (p, body) => call('PUT', p, body ?? {}),
patch: (p, body) => call('PATCH', p, body ?? {}),
del: (p) => call('DELETE', p),
// POST a FormData body (multipart/form-data). Content-Type is omitted so
// the browser appends the correct multipart boundary automatically.
postForm: (p, fd) => call('POST', p, fd),
setToken: (v) => localStorage.setItem(TOKEN_KEY, v),
hasToken: () => !!token()
};

View File

@@ -1,14 +1,13 @@
import { el } from '../dom.js';
// Builds the refined-B chrome shell and returns { root, body }. The card module
// fills `body` in its mount(); start()/stop() own its refresh timer.
// fills `body` in its mount(); start()/stop() own its refresh timer. Position +
// size are set by the Sacred Valley canvas (absolute geometry), not here.
// Decorative cards (blank / blackflame) carry no title bar.
export function svCard(def) {
const body = el('div', { class: 'sv-card-body' });
const root = el('div', {
class: 'sv-card', dataset: { cardId: def.id },
style: { gridColumn: 'span ' + (def.span || 6) } // 12-col grid; per-card width
},
el('div', { class: 'sv-card-title' }, def.title),
const root = el('div', { class: 'sv-card', dataset: { cardId: def.id } },
def.title ? el('div', { class: 'sv-card-title' }, def.title) : null,
body
);
return { root, body };

View File

@@ -0,0 +1,20 @@
<!--
tags: [video, photo, aperture, camera, content, entertainment, multimedia, broadcast, audio]
category: Media
version: "1.0"
unicode: "ea54"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#e8e6ed"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 7h1a2 2 0 0 0 2 -2a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2" />
<path d="M9 13a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
</svg>

After

Width:  |  Height:  |  Size: 555 B

View File

@@ -0,0 +1,23 @@
<!--
tags: [game, play, entertainment, console, joystick, joypad, controller, device, gamepad, hardware]
category: Devices
version: "1.68"
unicode: "f1d2"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#e8e6ed"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 5h3.5a5 5 0 0 1 0 10h-5.5l-4.015 4.227a2.3 2.3 0 0 1 -3.923 -2.035l1.634 -8.173a5 5 0 0 1 4.904 -4.019h3.4" />
<path d="M14 15l4.07 4.284a2.3 2.3 0 0 0 3.925 -2.023l-1.6 -8.232" />
<path d="M8 9v2" />
<path d="M7 10h2" />
<path d="M14 10h2" />
</svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -0,0 +1,22 @@
<!--
tags: [monitor, computer, imac, device, desktop, hardware, technology, electronic, gadget, equipment]
category: Devices
version: "1.0"
unicode: "ea89"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#e8e6ed"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 5a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-10" />
<path d="M7 20h10" />
<path d="M9 16v4" />
<path d="M15 16v4" />
</svg>

After

Width:  |  Height:  |  Size: 528 B

View File

@@ -0,0 +1,20 @@
<!--
tags: [workstation, mac, notebook, portable, screen, computer, device, laptop, hardware, technology]
category: Devices
version: "1.2"
unicode: "eb64"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#e8e6ed"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 19l18 0" />
<path d="M5 7a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v8a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1l0 -8" />
</svg>

After

Width:  |  Height:  |  Size: 482 B

View File

@@ -0,0 +1,21 @@
<!--
tags: [storage, data, memory, database, repository, records, information, table, content, record]
category: Database
version: "1.0"
unicode: "ea88"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#e8e6ed"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 6a8 3 0 1 0 16 0a8 3 0 1 0 -16 0" />
<path d="M4 6v6a8 3 0 0 0 16 0v-6" />
<path d="M4 12v6a8 3 0 0 0 16 0v-6" />
</svg>

After

Width:  |  Height:  |  Size: 492 B

View File

@@ -0,0 +1,21 @@
<!--
tags: [iphone, phone, smartphone, cellphone, device, mobile, hardware, technology, electronic, gadget]
category: Devices
version: "1.0"
unicode: "ea8a"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#e8e6ed"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 5a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2v-14" />
<path d="M11 4h2" />
<path d="M12 17v.01" />
</svg>

After

Width:  |  Height:  |  Size: 505 B

View File

@@ -0,0 +1,22 @@
<!--
tags: [electricity, charger, socket, connection, plug, hardware, technology, electronic, gadget, equipment]
category: Devices
version: "1.6"
unicode: "ebd9"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#e8e6ed"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9.785 6l8.215 8.215l-2.054 2.054a5.81 5.81 0 1 1 -8.215 -8.215l2.054 -2.054" />
<path d="M4 20l3.5 -3.5" />
<path d="M15 4l-3.5 3.5" />
<path d="M20 9l-3.5 3.5" />
</svg>

After

Width:  |  Height:  |  Size: 551 B

View File

@@ -0,0 +1,21 @@
<!--
tags: [fax, office, device, printer, hardware, technology, electronic, gadget, equipment]
category: Devices
version: "1.0"
unicode: "eb0e"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#e8e6ed"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M17 17h2a2 2 0 0 0 2 -2v-4a2 2 0 0 0 -2 -2h-14a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h2" />
<path d="M17 9v-4a2 2 0 0 0 -2 -2h-6a2 2 0 0 0 -2 2v4" />
<path d="M7 15a2 2 0 0 1 2 -2h6a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-6a2 2 0 0 1 -2 -2l0 -4" />
</svg>

After

Width:  |  Height:  |  Size: 599 B

View File

@@ -0,0 +1,24 @@
<!--
tags: [wifi, device, wireless, signal, station, cast, router, hardware, technology, electronic]
category: Devices
version: "1.0"
unicode: "eb18"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#e8e6ed"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 15a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2l0 -4" />
<path d="M17 17l0 .01" />
<path d="M13 17l0 .01" />
<path d="M15 13l0 -2" />
<path d="M11.75 8.75a4 4 0 0 1 6.5 0" />
<path d="M8.5 6.5a8 8 0 0 1 13 0" />
</svg>

After

Width:  |  Height:  |  Size: 617 B

View File

@@ -0,0 +1,22 @@
<!--
tags: [storage, hosting, www, server, hardware, technology, electronic, gadget, equipment]
category: Devices
version: "1.0"
unicode: "eb1f"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#e8e6ed"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3" />
<path d="M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2" />
<path d="M7 8l0 .01" />
<path d="M7 16l0 .01" />
</svg>

After

Width:  |  Height:  |  Size: 589 B

View File

@@ -0,0 +1,21 @@
<!--
tags: [voice, loud, microphone, loudspeaker, event, protest, speaker, shout, listen, speakerphone]
category: Media
version: "1.31"
unicode: "ed61"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#e8e6ed"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 8a3 3 0 0 1 0 6" />
<path d="M10 8v11a1 1 0 0 1 -1 1h-1a1 1 0 0 1 -1 -1v-5" />
<path d="M12 8l4.524 -3.77a.9 .9 0 0 1 1.476 .692v12.156a.9 .9 0 0 1 -1.476 .692l-4.524 -3.77h-8a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h8" />
</svg>

After

Width:  |  Height:  |  Size: 594 B

View File

@@ -0,0 +1,20 @@
<!--
tags: [ipad, mobile, touchscreen, portable, device, tablet, hardware, technology, electronic, gadget]
category: Devices
version: "1.0"
unicode: "ea8c"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#e8e6ed"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 4a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1v-16" />
<path d="M11 17a1 1 0 1 0 2 0a1 1 0 0 0 -2 0" />
</svg>

After

Width:  |  Height:  |  Size: 508 B

View File

@@ -0,0 +1,20 @@
<!--
tags: [screen, display, movie, film, watch, audio, video, media, device, hardware]
category: Devices
version: "1.0"
unicode: "ea8d"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#e8e6ed"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 9a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2l0 -9" />
<path d="M16 3l-4 4l-4 -4" />
</svg>

After

Width:  |  Height:  |  Size: 470 B

View File

@@ -0,0 +1,21 @@
<!--
category: System
tags: [mystery, undefined, unclear, unidentified, uncertain, ambiguous, obscure, unseen, anonymous, unspecified]
unicode: "fef4"
version: "3.5"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#e8e6ed"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2l0 -14" />
<path d="M12 16v.01" />
<path d="M12 13a2 2 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483" />
</svg>

After

Width:  |  Height:  |  Size: 567 B

View File

@@ -0,0 +1,21 @@
<!--
tags: [arm, hour, date, minutes, sec., timer, device, watch, hardware, technology]
category: Devices
version: "1.8"
unicode: "ebf9"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#e8e6ed"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 9a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v6a3 3 0 0 1 -3 3h-6a3 3 0 0 1 -3 -3v-6" />
<path d="M9 18v3h6v-3" />
<path d="M9 6v-3h6v3" />
</svg>

After

Width:  |  Height:  |  Size: 489 B

View File

@@ -382,14 +382,9 @@ ul.plain li:last-child { border-bottom: none; }
/* reserved for a future agent-output phase — unused now:
--hue-dross: #ff4f2e; --hue-yerin: #c45a4a; --hue-orthos: #6fa86a; */
}
#sv-cards { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; align-items: start; }
.sv-card { grid-column: span 6; } /* fallback; svCard sets an inline span (112) */
@media (max-width: 700px) { #sv-cards { grid-template-columns: 1fr; } .sv-card { grid-column: 1 / -1 !important; } }
.sv-ed-span { display: inline-flex; align-items: center; gap: 3px; }
.sv-ed-step { width: 18px; height: 20px; border: 1px solid var(--border); background: transparent; color: var(--muted);
border-radius: 3px; font-size: 14px; line-height: 1; cursor: pointer; padding: 0; }
.sv-ed-step:hover { color: var(--accent); border-color: var(--accent-dim); }
.sv-span-val { font-family: var(--font-mono); font-size: 12px; color: var(--text); min-width: 14px; text-align: center; }
/* Hybrid canvas: cards are absolutely placed (JS sets left/top/width/height in
12-col grid units); the board grows to fit its content. See sacred_valley.js. */
#sv-cards { position: relative; width: 100%; min-height: 200px; }
.sv-card {
position: relative; border: 1px solid #2c242a; border-radius: 10px; padding: 16px 18px;
@@ -407,8 +402,11 @@ ul.plain li:last-child { border-bottom: none; }
border-color: #4a2c28; transform: translateY(-2px);
box-shadow: 0 8px 28px rgba(0,0,0,.45), inset 0 0 46px rgba(255,79,46,.06), 0 0 0 1px rgba(255,79,46,.10);
}
.sv-card.dragging { opacity: .5; }
.sv-card.drag-over { border-color: var(--accent); }
.sv-card.dragging { transition: none; box-shadow: 0 16px 44px -12px #000, 0 0 0 1px var(--accent-dim); }
.sv-card.free { box-shadow: 0 0 0 1px var(--accent-dim), 0 10px 30px -12px #000; }
#sv-cards.editing .sv-card { transition: none; }
#sv-cards.editing .sv-card:hover { transform: none; }
.sv-card-body { height: 100%; overflow: auto; }
.sv-card-title {
font-family: var(--font-display); font-size: 13px; letter-spacing: .16em; text-transform: uppercase;
color: var(--text); padding-bottom: 7px; margin-bottom: 12px;
@@ -572,6 +570,18 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
.dv-tile { position: relative; }
.dv-edit-btn { position: absolute; top: 5px; right: 5px; background: transparent; border: 1px solid var(--border); color: var(--muted); border-radius: 3px; font-size: 11px; line-height: 1; padding: 2px 5px; cursor: pointer; opacity: 0; }
.dv-tile:hover .dv-edit-btn { opacity: 1; }
/* touch devices have no hover — keep the ✎ edit button always visible there */
@media (hover: none) { .dv-edit-btn { opacity: .85; } }
/* Little Blue service-tile edit affordance */
.lb-tile-wrap { position: relative; }
.lb-edit-btn { position: absolute; top: 5px; right: 5px; z-index: 5; background: var(--panel-2); border: 1px solid var(--border); color: var(--muted); border-radius: 3px; font-size: 11px; line-height: 1; padding: 2px 5px; cursor: pointer; opacity: 0; }
.lb-tile-wrap:hover .lb-edit-btn { opacity: 1; }
.lb-edit-btn:hover { color: var(--accent); border-color: var(--accent-dim); }
@media (hover: none) { .lb-edit-btn { opacity: .85; } }
.lb-edit { display: flex; flex-direction: column; gap: 4px; padding: 8px; }
.lb-edit .dv-edit-name, .lb-edit .dv-edit-grp { width: 100%; margin: 0; }
.lb-edit-btns { display: flex; gap: 4px; margin-top: 2px; }
.lb-edit-btns button { font-size: 11px; padding: 2px 8px; }
.dv-edit-btn:hover { color: var(--accent); border-color: var(--accent-dim); }
.dv-tile .dv-edit-name, .dv-tile .dv-edit-grp { margin: 2px 0; width: 100%; }
.dv-tile .dv-add, .dv-tile .dv-ignore, .dv-tile .ghost { margin-top: 4px; margin-right: 4px; font-size: 11px; padding: 2px 8px; }
@@ -614,18 +624,38 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
background: rgba(10,10,14,.88); border: 1px solid var(--border); border-radius: 6px; padding: 3px 5px; }
#sv-cards.editing .sv-card-edit { display: flex; }
#sv-cards.editing .sv-card { outline: 1px dashed var(--accent-dim); }
.sv-grip { cursor: grab; color: var(--muted); font-size: 14px; padding: 0 2px; user-select: none; }
.sv-grip { cursor: grab; color: var(--muted); font-size: 14px; padding: 0 2px; user-select: none; touch-action: none; }
.sv-grip:active { cursor: grabbing; }
.sv-ed-sizes { display: flex; gap: 2px; }
.sv-ed-size, .sv-ed-hide { width: 20px; height: 20px; border: 1px solid var(--border); background: transparent;
border-radius: 3px; font-size: 11px; cursor: pointer; padding: 0; line-height: 1; }
.sv-ed-size { color: var(--muted); }
.sv-ed-size:hover { color: var(--accent); border-color: var(--accent-dim); }
.sv-card[data-size="s"] .sv-ed-size[data-s="s"],
.sv-card[data-size="m"] .sv-ed-size[data-s="m"],
.sv-card[data-size="l"] .sv-ed-size[data-s="l"] { background: var(--accent-dim); color: var(--text); border-color: var(--accent); }
.sv-ed-hide { color: var(--bad); font-size: 12px; }
.sv-ed-free, .sv-ed-hide { width: 20px; height: 20px; border: 1px solid var(--border); background: transparent;
border-radius: 3px; font-size: 12px; cursor: pointer; padding: 0; line-height: 1; }
.sv-ed-free { color: var(--muted); }
.sv-ed-free:hover { color: var(--accent); border-color: var(--accent-dim); }
.sv-card.free .sv-ed-free { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.sv-ed-hide { color: var(--bad); }
.sv-ed-hide:hover { background: var(--bad); color: var(--bg); }
/* resize handle — bottom-right corner, edit mode only */
.sv-resize { display: none; position: absolute; right: 3px; bottom: 3px; width: 16px; height: 16px; z-index: 4;
cursor: nwse-resize; touch-action: none; border-radius: 0 0 9px 0;
background: linear-gradient(135deg, transparent 45%, var(--muted) 45% 55%, transparent 55%,
transparent 62%, var(--muted) 62% 72%, transparent 72%); opacity: .6; }
.sv-resize:hover { opacity: 1; }
#sv-cards.editing .sv-resize { display: block; }
.sv-tray-hint { font-family: var(--font-ui); font-size: 11px; margin-left: 6px; }
/* decorative cards (blank spacer / blackflame) — no chrome padding, full-bleed body */
.sv-card-decor { padding: 0; overflow: hidden; }
.sv-card-decor .sv-card-body { position: absolute; inset: 0; padding: 0; overflow: hidden; }
.sv-card-decor:hover { transform: none; }
.sv-blank { width: 100%; height: 100%;
background-image: repeating-linear-gradient(45deg, rgba(255,255,255,.025) 0 9px, transparent 9px 18px); }
.sv-flame-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; pointer-events: none; }
.sv-flame-crest { position: absolute; top: 46%; left: 50%; width: 22%; aspect-ratio: 1; transform: translate(-50%,-50%);
border-radius: 50%; pointer-events: none;
background: radial-gradient(circle at 50% 45%, #000 38%, #0a0a10 60%, transparent 72%);
box-shadow: 0 0 26px 10px #000, 0 0 60px 18px rgba(255,79,46,.13); }
.sv-flame-label { position: absolute; left: 0; right: 0; bottom: 14px; text-align: center; pointer-events: none;
font-family: var(--font-display); letter-spacing: .42em; text-indent: .42em; text-transform: uppercase;
font-size: 14px; color: #f4ece9; text-shadow: 0 0 18px rgba(255,79,46,.55), 0 0 4px #000; }
#sv-tray { flex-wrap: wrap; align-items: center; gap: 8px; margin: 2px 0 18px; padding: 10px 12px;
border: 1px dashed var(--border); border-radius: 8px; }
@@ -635,3 +665,26 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
.sv-tray-chip:hover { border-color: var(--accent); color: var(--accent); }
.hidden { display: none !important; }
/* Storage card (sv-cluster container) — warn dot + capacity meter + subheader */
.sv-cluster .status-warn .dot { background: var(--warn); box-shadow: 0 0 7px var(--warn); }
.sv-cluster .ok { color: var(--ok); }
.sv-cluster .bad { color: var(--bad); }
.sv-cluster .st-meter { height: 3px; background: var(--accent-soft); border-radius: 2px; margin: 3px 0 9px; overflow: hidden; }
.sv-cluster .st-fill { height: 100%; border-radius: 2px; }
.sv-cluster .st-fill.ok { background: var(--ok); }
.sv-cluster .st-fill.warn { background: var(--warn); }
.sv-cluster .st-fill.bad { background: var(--bad); }
.sv-cluster .sv-subhdr { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; margin: 11px 0 5px; font-family: var(--font-mono); }
.dv-icon { width: 30px; height: 30px; object-fit: contain; opacity: .95; }
.dv-icon-fb { width: 30px; height: 30px; display: grid; place-items: center; font-size: 14px; color: var(--text); background: var(--panel-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: 40px; height: 40px; display: grid; place-items: center; background: transparent; border: 1px solid var(--border, #2a2a36); border-radius: 4px; cursor: pointer; }
.ip-icon img { width: 28px; height: 28px; 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; }

View File

@@ -0,0 +1,50 @@
// public/views/cards/backups.js — offsite DR backup status (Core-4 -> Farm/Won).
// Fed by /usr/local/bin/offsite-backup.sh which POSTs each run to /api/backups.
import { el, mount } from '../../dom.js';
import { api } from '../../api.js';
let body, timer;
const gb = b => (b == null ? ''
: b >= 1e12 ? (b / 1e12).toFixed(1) + 'T'
: b >= 1e9 ? (b / 1e9).toFixed(1) + 'G'
: Math.round(b / 1e6) + 'M');
function ago(ts) {
const s = Math.max(0, (Date.now() - Date.parse(ts)) / 1000);
if (s < 3600) return Math.floor(s / 60) + 'm';
if (s < 86400) return Math.floor(s / 3600) + 'h';
return Math.floor(s / 86400) + 'd';
}
async function load() {
if (!body) return;
try {
const d = await api.get('/api/backups');
const r = d.latest;
if (!r) { mount(body, el('span', { class: 'muted' }, 'No offsite backups yet.')); return; }
const stale = (Date.now() - Date.parse(r.ran_at)) > 8 * 86400000; // >8d overdue
const status = (!r.ok || stale) ? 'bad' : 'ok';
const kids = [];
kids.push(el('div', { class: 'sv-row' },
el('span', { class: 'k' }, 'Last run'),
el('span', { class: 'cl-badge ' + status }, r.ok ? ago(r.ran_at) + ' ago' : 'FAILED')));
kids.push(el('div', { class: 'sv-row' },
el('span', { class: 'k' }, 'Pushed to Farm'), el('span', {}, gb(r.total_bytes))));
for (const g of (r.guests || []))
kids.push(el('div', { class: 'sv-row' },
el('span', { class: 'k' }, 'CT ' + g.vmid + ' ' + g.name),
el('span', { class: 'muted' }, gb(g.bytes))));
kids.push(el('div', { class: 'sv-row' },
el('span', { class: 'k' }, 'Farm free'), el('span', {}, gb(r.won_free_bytes))));
kids.push(el('div', { class: 'sv-row' },
el('span', { class: 'k' }, 'Schedule'), el('span', { class: 'muted' }, d.schedule || 'weekly')));
mount(body, el('div', { class: 'sv-cluster' }, ...kids));
} catch { mount(body, el('span', { class: 'muted' }, 'Backups unavailable')); }
}
export default {
id: 'backups', title: 'Backups · offsite', size: 's',
mount(e) { body = e; load(); },
start() { timer = setInterval(load, 60000); },
stop() { clearInterval(timer); body = null; }
};

View File

@@ -0,0 +1,114 @@
// Decorative blackflame card — the animated centrepiece (dark heart, crimson
// licks, rising embers). Canvas particle flame ported from the approved mock.
// Instanceable via the factory; stop() tears down the rAF loop + observer.
import { el } from '../../dom.js';
const TAU = Math.PI * 2;
function startFlame(canvas) {
const ctx = canvas.getContext('2d');
let W = 0, H = 0, DPR = 1, raf = 0, t = 0;
function resize() {
DPR = Math.min(2, window.devicePixelRatio || 1);
const r = canvas.getBoundingClientRect();
W = r.width; H = r.height;
canvas.width = Math.max(1, Math.round(W * DPR));
canvas.height = Math.max(1, Math.round(H * DPR));
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
const tongues = Array.from({ length: 5 }, (_, i) => ({
phase: Math.random() * TAU, speed: 0.6 + Math.random() * 0.5,
x: 0.5 + (i - 2) * 0.085, sway: 0.03 + Math.random() * 0.04, w: 0.34 - Math.abs(i - 2) * 0.05
}));
const spawn = () => ({
x: 0.5 + (Math.random() - 0.5) * 0.5, y: 1 + Math.random() * 0.2,
vy: 0.0016 + Math.random() * 0.0030, vx: (Math.random() - 0.5) * 0.0012,
r: 0.6 + Math.random() * 1.8, life: 0, max: 120 + Math.random() * 120, hot: Math.random() < 0.5
});
const embers = Array.from({ length: 46 }, spawn);
function blob(x, y, r, c0, c1) {
if (r <= 0) return;
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, c0); g.addColorStop(1, c1);
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fill();
}
function frame() {
t += 0.016;
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, W, H);
ctx.globalCompositeOperation = 'lighter';
const baseY = H * 0.92;
for (const tg of tongues) {
const cx = W * (tg.x + Math.sin(t * tg.speed + tg.phase) * tg.sway);
const height = H * (0.62 + 0.08 * Math.sin(t * 1.7 + tg.phase));
const steps = 26;
for (let s = 0; s < steps; s++) {
const f = s / steps;
const y = baseY - f * height;
const flick = Math.sin(t * 3.2 + tg.phase + f * 7) * W * 0.012 * (0.4 + f);
const x = cx + flick + Math.sin(t * tg.speed * 1.3 + f * 4) * W * tg.sway * 0.6;
const r = W * tg.w * (1 - f * 0.78) * (0.9 + 0.1 * Math.sin(t * 5 + f * 9));
const a = (1 - f) * 0.5;
if (f < 0.18) blob(x, y, r * 1.1, `rgba(60,12,6,${a * 0.7})`, 'rgba(60,12,6,0)');
else if (f < 0.62) blob(x, y, r, `rgba(255,79,46,${a * 0.5})`, 'rgba(122,39,22,0)');
else blob(x, y, r * 0.8, `rgba(255,150,90,${a * 0.5})`, 'rgba(255,79,46,0)');
}
}
for (const e of embers) {
e.life++; e.y -= e.vy; e.x += e.vx + Math.sin(t * 2 + e.y * 8) * 0.0004;
if (e.y < 0.18 || e.life > e.max) Object.assign(e, spawn());
const px = e.x * W, py = e.y * H, fade = 1 - e.life / e.max;
const col = e.hot ? `rgba(255,170,110,${fade * 0.9})` : `rgba(255,79,46,${fade * 0.8})`;
ctx.shadowBlur = 8; ctx.shadowColor = '#ff4f2e';
blob(px, py, e.r * 1.6, col, 'rgba(255,79,46,0)');
}
ctx.shadowBlur = 0;
// carve the dark heart back in (the "black" of black-flame)
ctx.globalCompositeOperation = 'source-over';
const cx = W * 0.5, cy = H * 0.46, cr = W * 0.20;
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, cr);
core.addColorStop(0, 'rgba(4,4,8,0.96)');
core.addColorStop(0.55, 'rgba(6,5,10,0.7)');
core.addColorStop(1, 'rgba(8,7,12,0)');
ctx.fillStyle = core; ctx.beginPath(); ctx.arc(cx, cy, cr, 0, TAU); ctx.fill();
ctx.globalCompositeOperation = 'lighter';
blob(W * 0.5, H * 0.95, W * 0.4, 'rgba(255,79,46,0.10)', 'rgba(255,79,46,0)');
raf = requestAnimationFrame(frame);
}
resize();
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
ro?.observe(canvas);
raf = requestAnimationFrame(frame);
return () => { cancelAnimationFrame(raf); ro?.disconnect(); };
}
export function blackflameCard(id) {
let teardown = null;
return {
id,
type: 'blackflame',
title: '',
decorative: true,
size: 'l',
mount(body) {
const canvas = el('canvas', { class: 'sv-flame-canvas' });
const crest = el('div', { class: 'sv-flame-crest' });
const label = el('div', { class: 'sv-flame-label' }, 'The Void');
body.append(canvas, crest, label);
// defer one frame so the canvas has a measured size
requestAnimationFrame(() => { teardown = startFlame(canvas); });
},
start() {},
stop() { teardown && teardown(); teardown = null; }
};
}

View File

@@ -0,0 +1,16 @@
// A decorative blank spacer card — deliberate empty space / grouping on the
// Sacred Valley canvas. Instanceable: each gets a unique id via the factory.
import { el } from '../../dom.js';
export function blankCard(id) {
return {
id,
type: 'blank',
title: '',
decorative: true,
size: 'm',
mount(body) { body.appendChild(el('div', { class: 'sv-blank' })); },
start() {},
stop() {}
};
}

View File

@@ -0,0 +1,62 @@
// public/views/cards/storage.js — Proxmox storage health: ZFS pools, dropped pools,
// and per-container disk fill. Surfaces the two failure modes that have actually bitten
// this homelab (a pool dropping off the SATA bus; a container rootfs filling up).
import { el, mount } from '../../dom.js';
import { api } from '../../api.js';
let body, timer;
const gb = b => (b >= 1e12 ? (b / 1e12).toFixed(1) + 'T' : Math.round(b / 1e9) + 'G');
const cls = s => (s === 'crit' ? 'bad' : s === 'warn' ? 'warn' : 'ok');
const dotClass = s => 'status-' + (s === 'crit' ? 'down' : s === 'warn' ? 'warn' : 'ok');
function meterRow(label, value, p, status) {
const wrap = el('div', { class: dotClass(status) });
wrap.appendChild(el('div', { class: 'sv-row' },
el('span', { class: 'k' }, el('span', { class: 'dot' }), label),
el('span', { class: cls(status) }, value)));
if (p != null) {
wrap.appendChild(el('div', { class: 'st-meter' },
el('div', { class: 'st-fill ' + cls(status), style: { width: Math.min(p, 100) + '%' } })));
}
return wrap;
}
async function load() {
if (!body) return;
try {
const s = await api.get('/api/storage');
if (s.error) { mount(body, el('span', { class: 'muted' }, 'Storage: ' + s.error)); return; }
const kids = [];
// overall badge
kids.push(el('div', { class: 'sv-row' },
el('span', { class: 'k' }, 'Status'),
el('span', { class: 'cl-badge ' + (s.worst === 'ok' ? 'ok' : 'bad') },
s.worst === 'ok' ? 'HEALTHY' : s.worst === 'warn' ? 'WATCH' : 'ATTENTION')));
// dropped pools first (most urgent — e.g. donatello/leonardo off the bus)
for (const d of (s.down || []))
kids.push(meterRow(d.name + ' · ' + d.node, '⚠ ' + String(d.state).toUpperCase(), null, 'crit'));
// imported ZFS pools
for (const p of (s.pools || []))
kids.push(meterRow(p.name + ' · ' + p.node,
(p.health !== 'ONLINE' ? p.health + ' · ' : '') + (p.pct ?? '') + '%', p.pct, p.status));
// container disk fill (top few by %)
const top = (s.guests || []).slice(0, 5);
if (top.length) kids.push(el('div', { class: 'sv-subhdr' }, 'Container disk'));
for (const g of top)
kids.push(meterRow('CT ' + g.vmid + ' ' + g.name, g.pct + '% · ' + gb(g.used) + '/' + gb(g.total), g.pct, g.status));
mount(body, el('div', { class: 'sv-cluster' }, ...kids));
} catch { mount(body, el('span', { class: 'muted' }, 'Storage unavailable')); }
}
export default {
id: 'storage', title: 'Storage · capacity', size: 'm',
mount(e) { body = e; load(); },
start() { timer = setInterval(load, 30000); },
stop() { clearInterval(timer); body = null; }
};

View File

@@ -3,6 +3,8 @@
// newly-seen devices. Kept SEPARATE from Little Blue's homelab-service band.
import { el, mount, clear } from '../dom.js';
import { api } from '../api.js';
import { resolveIcon, relativeTime, autoDefaultIcon } from './icon_util.js';
import { iconPicker } from './icon_picker.js';
let host;
const GROUPS = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
@@ -13,26 +15,50 @@ function tile(d) {
clear(t);
const edit = el('button', { class: 'dv-edit-btn', title: 'Edit device' }, '✎');
edit.onclick = editMode;
mount(t,
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);
}
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 }); load(); };
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, save, del, cancel);
mount(t, el('span', { class: 'dv-mac' }, d.mac), nameI, grpS, iconBtn, save, del, cancel, pickerWrap);
}
view();
return t;

View File

@@ -5,6 +5,7 @@ import { serviceTile } from '../components/service_tile.js';
import { isRemoteHost } from './service_url.js';
const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' };
const CATS = ['agents', 'infrastructure', 'media', 'other'];
let host, timer, scanning = false;
async function promote(id) {
@@ -17,6 +18,36 @@ function scan() {
setTimeout(() => { scanning = false; load(); }, 30000); // LAN sweep ~25s
}
// Inline edit form for a service (name / category / url / icon) — PATCH or DELETE.
function editForm(s) {
const nameI = el('input', { class: 'dv-edit-name', value: s.name || '', placeholder: 'name' });
const catS = el('select', { class: 'dv-edit-grp' }, ...CATS.map(c => el('option', { value: c }, TITLE[c])));
catS.value = s.category || 'other';
const urlI = el('input', { class: 'dv-edit-name', value: s.url || '', placeholder: 'http://host:port' });
const iconI = el('input', { class: 'dv-edit-name', value: s.icon || '', placeholder: 'icon slug e.g. plex' });
const save = el('button', { class: 'dv-add' }, 'Save');
save.onclick = async () => {
const patch = { name: nameI.value.trim(), category: catS.value, url: urlI.value.trim() };
const ic = iconI.value.trim(); if (ic) patch.icon = ic;
try { await api.patch('/api/health/services/' + s.id, patch); load(); } catch { /* */ }
};
const del = el('button', { class: 'ghost dv-ignore' }, 'Delete');
del.onclick = async () => { try { await api.del('/api/health/services/' + s.id); load(); } catch { /* */ } };
const cancel = el('button', { class: 'ghost' }, 'Cancel');
cancel.onclick = load;
return el('div', { class: 'tile lb-edit' }, nameI, catS, urlI, iconI,
el('div', { class: 'lb-edit-btns' }, save, del, cancel));
}
// A service tile wrapped with an ✎ edit button that swaps to the edit form.
function tileWithEdit(s, remote) {
const wrap = el('div', { class: 'lb-tile-wrap' });
const edit = el('button', { class: 'lb-edit-btn', title: 'Edit service' }, '✎');
edit.onclick = (e) => { e.preventDefault(); e.stopPropagation(); mount(wrap, editForm(s)); };
mount(wrap, serviceTile(s, remote), edit);
return wrap;
}
// Owner-only; returns a section element or null (skipped for non-owner / none).
async function discoveredSection() {
let cand;
@@ -30,8 +61,8 @@ async function discoveredSection() {
el('div', { class: 'tiles' }, cand.map(c =>
el('div', { class: 'tile disc' },
el('div', { class: 'tile-main' },
el('div', { class: 'tile-nm' }, c.name),
el('div', { class: 'tile-host' }, c.url)),
el('div', { class: 'tile-nm' }, c.device || c.name),
el('div', { class: 'tile-host' }, c.device ? `${c.name} · ${c.url}` : c.url)),
el('button', { class: 'disc-add', title: 'Add to the band', onclick: () => promote(c.id) }, '+')))));
}
@@ -46,7 +77,7 @@ async function load() {
el('span', { class: 'gname' }, TITLE[g.category] || g.category),
el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`),
el('span', { class: 'line' })),
el('div', { class: 'tiles' }, g.services.map(s => serviceTile(s, remote)))));
el('div', { class: 'tiles' }, g.services.map(s => tileWithEdit(s, remote)))));
const disc = await discoveredSection();
mount(host,
el('div', { class: 'lbwrap' }, littleblueAvatar(),

View File

@@ -0,0 +1,56 @@
// public/views/icon_picker.js — inline picker with Type + Brand tabs.
import { el, mount, clear } from '../dom.js';
import { api } from '../api.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;
}

View File

@@ -0,0 +1,70 @@
// Icon sets management panel — list, upload, delete custom icon sets.
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 {
root.appendChild(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.addEventListener('click', async () => {
await api.del('/api/icon-sets/' + s.set);
refresh();
});
head.appendChild(del);
}
root.appendChild(el('div', { class: 'isp-set' }, head, grid));
}
root.appendChild(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,.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.addEventListener('click', async () => {
const set = setI.value.trim().toLowerCase();
if (!/^[a-z0-9-]+$/.test(set)) { err.textContent = 'set name: a-z 0-9 - only'; return; }
if (!fileI.files.length && !urlI.value.trim()) { err.textContent = 'pick files or a URL'; 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());
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);
}

24
public/views/icon_util.js Normal file
View File

@@ -0,0 +1,24 @@
// 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`;
}

View File

@@ -3,8 +3,6 @@ import { api } from '../api.js';
import { renderHealthBand, stopHealthBand } from './health_band.js';
import { renderDevicesBand, stopDevicesBand } from './devices_band.js';
import { svCard } from '../components/sv_card.js';
import { moveId } from '../components/sv_reorder.js';
import { orderCards } from './cards/registry.js';
import clock from './cards/clock.js';
import weather from './cards/weather.js';
import hostPerf from './cards/host_perf.js';
@@ -14,127 +12,230 @@ import search from './cards/search.js';
import speedtest from './cards/speedtest.js';
import aiUsage from './cards/ai_usage.js';
import cluster from './cards/cluster.js';
import storage from './cards/storage.js';
import backups from './cards/backups.js';
import { blankCard } from './cards/blank.js';
import { blackflameCard } from './cards/blackflame.js';
const CARD_MODULES = [clock, weather, hostPerf, cluster, jobs, inbox, search, speedtest, aiUsage];
const BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
const CARD_MODULES = [clock, weather, hostPerf, cluster, storage, backups, jobs, inbox, search, speedtest, aiUsage];
const BUILTIN_BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
// ---- hybrid canvas geometry ----
// Cards are absolutely placed on a 12-column grid. {x,y,w,h} are in grid units
// (x,w in columns; y,h in rows of ROW_H px). Snap mode keeps them integer; a
// per-card `free` flag (or holding Alt while dragging) allows fractional
// placement + overlap. Everything scales with board width, so x/w stay relative.
const COLS = 12;
const ROW_H = 28; // px per grid row
const GUTTER = 12; // visual gap baked into each card's rendered size
const SIZE_W = { s: 3, m: 4, l: 6 };
const SIZE_H = { s: 6, m: 8, l: 10 };
let active = []; // mounted cards needing stop()
let renderGen = 0; // guards overlapping async renders
let editing = false;
let layout = { card_order: [], hidden: [], sizes: {} };
let mainEl;
let layout = { hidden: [], geom: {}, extras: [] };
const grid = () => document.getElementById('sv-cards');
async function saveLayout() {
try { await api.put('/api/dashboard/layout', layout); }
catch (e) { console.error('save layout', e); }
function defFor(extra) {
if (extra.type === 'blank') return blankCard(extra.id);
if (extra.type === 'blackflame') return blackflameCard(extra.id);
return null;
}
// ---- per-card edit controls (drag grip + size + hide), shown only in edit mode via CSS
const STR_SPAN = { s: 2, m: 6, l: 12 }; // legacy size → 12-col span (s = 1/6, m = 1/2, l = full)
function spanOf(def) {
const v = layout.sizes?.[def.id];
if (typeof v === 'number') return Math.max(1, Math.min(12, v));
if (typeof v === 'string') return STR_SPAN[v] || 6;
return STR_SPAN[def.size] || 6;
function visibleDefs() {
const hidden = new Set(layout.hidden || []);
const builtins = CARD_MODULES.filter(d => !hidden.has(d.id));
const extras = (layout.extras || []).map(defFor).filter(Boolean);
return [...builtins, ...extras];
}
function curSpan(id) {
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
const m = node && (node.style.gridColumn || '').match(/span (\d+)/);
return m ? +m[1] : spanOf(BY_ID.get(id) || {});
function defaultSize(def) {
if (def.type === 'blackflame') return { w: 6, h: 10 };
if (def.type === 'blank') return { w: 3, h: 4 };
return { w: SIZE_W[def.size] || 4, h: SIZE_H[def.size] || 8 };
}
function setSpan(id, delta) {
const span = Math.max(1, Math.min(12, curSpan(id) + delta));
layout.sizes = { ...layout.sizes, [id]: span };
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
if (node) {
node.style.gridColumn = 'span ' + span;
const lbl = node.querySelector('.sv-span-val');
if (lbl) lbl.textContent = span;
function geomOf(def) {
const g = (layout.geom || {})[def.id];
if (g) return g;
const { w, h } = defaultSize(def);
return { x: 0, y: 0, w, h };
}
async function saveLayout() {
try {
await api.put('/api/dashboard/layout', {
card_order: [], sizes: {},
hidden: layout.hidden || [], geom: layout.geom || {}, extras: layout.extras || []
});
} catch (e) { console.error('save layout', e); }
}
// Backfill geometry for any visible card lacking it (first load / migration /
// a newly-shipped built-in). New cards flow below whatever already has a spot.
function autoPlaceMissing(defs) {
const geom = { ...(layout.geom || {}) };
let baseY = 0;
for (const id in geom) baseY = Math.max(baseY, geom[id].y + geom[id].h);
let cx = 0, cy = baseY, rowH = 0;
for (const d of defs) {
if (geom[d.id]) continue;
const { w, h } = defaultSize(d);
if (cx + w > COLS) { cx = 0; cy += rowH; rowH = 0; }
geom[d.id] = { x: cx, y: cy, w, h };
cx += w; rowH = Math.max(rowH, h);
}
layout.geom = geom;
}
function cellW() { return grid().clientWidth / COLS; }
function applyGeom(node, g) {
const cw = cellW();
node.style.position = 'absolute';
node.style.left = (g.x * cw) + 'px';
node.style.top = (g.y * ROW_H) + 'px';
node.style.width = Math.max(40, g.w * cw - GUTTER) + 'px';
node.style.height = Math.max(40, g.h * ROW_H - GUTTER) + 'px';
node.style.zIndex = g.free ? 5 : 1;
node.classList.toggle('free', !!g.free);
}
function fitBoard() {
let max = 0;
grid().querySelectorAll('.sv-card').forEach(n => { max = Math.max(max, n.offsetTop + n.offsetHeight); });
grid().style.height = (max + GUTTER) + 'px';
}
function relayout() {
active.forEach(def => {
const n = grid().querySelector(`.sv-card[data-card-id="${def.id}"]`);
if (n) applyGeom(n, geomOf(def));
});
fitBoard();
}
// ---- drag / resize (pointer-based; snap unless free or Alt held) ----
function beginDrag(ev, def, mode) {
if (!editing) return;
ev.preventDefault(); ev.stopPropagation();
const node = grid().querySelector(`.sv-card[data-card-id="${def.id}"]`);
if (!node) return;
const g = { ...geomOf(def) };
const start = { px: ev.clientX, py: ev.clientY, x: g.x, y: g.y, w: g.w, h: g.h };
node.classList.add('dragging');
const cw = cellW();
function moveTo(e) {
const dxc = (e.clientX - start.px) / cw;
const dyc = (e.clientY - start.py) / ROW_H;
const freeNow = g.free || e.altKey;
if (mode === 'move') {
let nx = start.x + dxc, ny = start.y + dyc;
if (!freeNow) { nx = Math.round(nx); ny = Math.round(ny); }
g.x = Math.max(0, Math.min(COLS - g.w, nx));
g.y = Math.max(0, ny);
} else {
let nw = start.w + dxc, nh = start.h + dyc;
if (!freeNow) { nw = Math.round(nw); nh = Math.round(nh); }
g.w = Math.max(2, Math.min(COLS - g.x, nw));
g.h = Math.max(2, nh);
}
applyGeom(node, g); fitBoard();
}
function end() {
document.removeEventListener('pointermove', moveTo);
document.removeEventListener('pointerup', end);
node.classList.remove('dragging');
layout.geom = { ...layout.geom, [def.id]: g };
applyGeom(node, g);
saveLayout();
}
document.addEventListener('pointermove', moveTo);
document.addEventListener('pointerup', end);
}
function toggleFree(id) {
const g = { ...(layout.geom[id] || geomOf({ id })) };
g.free = !g.free;
layout.geom = { ...layout.geom, [id]: g };
const n = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
if (n) applyGeom(n, g);
saveLayout();
}
function editOverlay(def) {
const grip = el('span', { class: 'sv-grip', draggable: true, title: 'Drag to reorder' }, '⠿');
const stepper = el('span', { class: 'sv-ed-span' },
el('button', { class: 'sv-ed-step', title: 'Narrower', onclick: () => setSpan(def.id, -1) }, ''),
el('span', { class: 'sv-span-val', title: 'Width (of 12)' }, String(spanOf(def))),
el('button', { class: 'sv-ed-step', title: 'Wider', onclick: () => setSpan(def.id, +1) }, '+'));
const hide = el('button', { class: 'sv-ed-hide', title: 'Hide card', onclick: () => hideCard(def.id) }, '✕');
return el('div', { class: 'sv-card-edit' }, grip, stepper, hide);
const grip = el('span', { class: 'sv-grip', title: 'Drag to move' }, '⠿');
grip.addEventListener('pointerdown', e => beginDrag(e, def, 'move'));
const free = el('button', { class: 'sv-ed-free', title: 'Free / snap placement', onclick: () => toggleFree(def.id) }, '');
const hide = el('button', { class: 'sv-ed-hide', title: def.decorative ? 'Delete card' : 'Hide card', onclick: () => removeCard(def) }, '✕');
const resize = el('span', { class: 'sv-resize', title: 'Drag to resize' });
resize.addEventListener('pointerdown', e => beginDrag(e, def, 'resize'));
const frag = document.createDocumentFragment();
frag.append(el('div', { class: 'sv-card-edit' }, grip, free, hide), resize);
return frag;
}
function mountOne(def) {
const span = spanOf(def);
const { root, body } = svCard({ ...def, span });
const { root, body } = svCard(def);
if (def.decorative) root.classList.add('sv-card-decor');
root.appendChild(editOverlay(def));
applyGeom(root, geomOf(def));
grid().appendChild(root);
try { def.mount(body); def.start && def.start(); active.push(def); }
catch (e) { body.appendChild(el('span', { class: 'muted' }, 'card failed')); console.error(def.id, e); }
}
function setSize(id, s) {
layout.sizes = { ...layout.sizes, [id]: s };
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
if (node) node.dataset.size = s;
saveLayout();
function placeNew(def) {
let maxBottom = 0;
for (const id in layout.geom) { const g = layout.geom[id]; maxBottom = Math.max(maxBottom, g.y + g.h); }
const { w, h } = defaultSize(def);
layout.geom = { ...layout.geom, [def.id]: { x: 0, y: maxBottom, w, h } };
}
function hideCard(id) {
if (!layout.hidden.includes(id)) layout.hidden = [...layout.hidden, id];
const def = BY_ID.get(id);
if (def?.stop) def.stop();
active = active.filter(d => d.id !== id);
grid().querySelector(`.sv-card[data-card-id="${id}"]`)?.remove();
renderTray();
saveLayout();
function addBuiltin(id) {
layout.hidden = (layout.hidden || []).filter(x => x !== id);
const def = BUILTIN_BY_ID.get(id);
if (!def) return;
placeNew(def); mountOne(def); relayout(); renderTray(); saveLayout();
}
function showCard(id) {
layout.hidden = layout.hidden.filter(x => x !== id);
const def = BY_ID.get(id);
if (def) { mountOne(def); wireDrag(); }
renderTray();
saveLayout();
function addDecor(type) {
const id = `${type}-${Date.now().toString(36)}`;
const def = defFor({ id, type });
if (!def) return;
layout.extras = [...(layout.extras || []), { id, type }];
placeNew(def); mountOne(def); relayout(); renderTray(); saveLayout();
}
function onReorder(newOrder) {
const frag = document.createDocumentFragment();
newOrder.forEach(id => { const n = grid().querySelector(`.sv-card[data-card-id="${id}"]`); if (n) frag.appendChild(n); });
grid().appendChild(frag);
layout.card_order = newOrder;
saveLayout();
}
// Drag from the grip only; the rest of the card is inert so the search box etc. work.
function wireDrag() {
let dragId = null;
grid().querySelectorAll('.sv-card').forEach(card => {
if (card._wired) return; card._wired = true;
const g = card.querySelector('.sv-grip');
if (g) {
g.addEventListener('dragstart', (e) => { dragId = card.dataset.cardId; card.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; });
g.addEventListener('dragend', () => { card.classList.remove('dragging'); dragId = null; });
}
card.addEventListener('dragover', (e) => { if (dragId) { e.preventDefault(); card.classList.add('drag-over'); } });
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
card.addEventListener('drop', (e) => {
e.preventDefault(); card.classList.remove('drag-over');
if (!dragId || dragId === card.dataset.cardId) return;
const ids = [...grid().querySelectorAll('.sv-card')].map(c => c.dataset.cardId);
onReorder(moveId(ids, dragId, card.dataset.cardId));
});
});
function removeCard(def) {
const d = active.find(a => a.id === def.id);
if (d && d.stop) d.stop();
active = active.filter(a => a.id !== def.id);
grid().querySelector(`.sv-card[data-card-id="${def.id}"]`)?.remove();
if (def.decorative) {
layout.extras = (layout.extras || []).filter(e => e.id !== def.id);
const g = { ...layout.geom }; delete g[def.id]; layout.geom = g;
} else if (!(layout.hidden || []).includes(def.id)) {
layout.hidden = [...(layout.hidden || []), def.id];
}
renderTray(); fitBoard(); saveLayout();
}
function renderTray() {
const tray = document.getElementById('sv-tray');
if (!tray) return;
const hidden = layout.hidden.map(id => BY_ID.get(id)).filter(Boolean);
const hidden = (layout.hidden || []).map(id => BUILTIN_BY_ID.get(id)).filter(Boolean);
mount(tray,
hidden.length ? el('span', { class: 'sv-tray-label' }, 'Hidden:') : el('span', { class: 'muted' }, 'No hidden cards'),
...hidden.map(def => el('button', { class: 'sv-tray-chip', onclick: () => showCard(def.id) }, '+ ' + def.title)));
el('span', { class: 'sv-tray-label' }, 'Add card:'),
el('button', { class: 'sv-tray-chip', onclick: () => addDecor('blank') }, '+ Blank'),
el('button', { class: 'sv-tray-chip', onclick: () => addDecor('blackflame') }, '+ Blackflame'),
hidden.length ? el('span', { class: 'sv-tray-label', style: { marginLeft: '8px' } }, 'Restore:') : null,
...hidden.map(def => el('button', { class: 'sv-tray-chip', onclick: () => addBuiltin(def.id) }, '+ ' + def.title)),
el('span', { class: 'sv-tray-hint muted' }, 'drag ⠿ to move · corner to resize · ⤢ = free/overlap · Alt = no-snap'));
tray.style.display = editing ? 'flex' : 'none';
}
@@ -148,9 +249,8 @@ function toggleEdit() {
renderTray();
}
let mainEl;
async function resetLayout() {
layout = { card_order: [], hidden: [], sizes: {} };
layout = { hidden: [], geom: {}, extras: [] };
await saveLayout();
render(mainEl);
}
@@ -173,12 +273,21 @@ export async function render(main) {
el('div', { id: 'sv-devices' })
);
layout = { card_order: [], hidden: [], sizes: {} };
try { layout = await api.get('/api/dashboard/layout'); } catch { /* defaults */ }
layout = { hidden: [], geom: {}, extras: [] };
try {
const l = await api.get('/api/dashboard/layout');
layout = { hidden: l.hidden || [], geom: l.geom || {}, extras: l.extras || [] };
} catch { /* defaults */ }
if (myGen !== renderGen) return;
for (const def of orderCards(CARD_MODULES, layout)) mountOne(def);
wireDrag();
const defs = visibleDefs();
autoPlaceMissing(defs);
for (const def of defs) mountOne(def);
relayout();
renderTray();
window.removeEventListener('resize', relayout);
window.addEventListener('resize', relayout);
renderHealthBand(document.getElementById('sv-health'));
renderDevicesBand(document.getElementById('sv-devices'));
}

View File

@@ -1,6 +1,7 @@
// #/settings — API tokens, agents, and a placeholder for Orthos Mode.
import { el, mount } from '../dom.js';
import { api } from '../api.js';
import { iconSetsPanel } from './icon_sets_panel.js';
function section(title, sub, bodyEl) {
return el('div', { class: 'card settings-card' },
@@ -99,10 +100,29 @@ async function renderAgents(c) {
export async function render(main) {
const tokensBody = el('div', { class: 'settings-body' });
const agentsBody = el('div', { class: 'settings-body' });
// Icon sets — collapsible; panel is lazy-created on first expand so
// /api/icon-sets is not fetched while the section is collapsed.
let isPanel = null;
const iconSetsWrap = el('div', { class: 'settings-body' });
const isToggle = el('button', { class: 'ghost' }, '▸ Icon sets');
isToggle.addEventListener('click', () => {
if (!isPanel) {
// First expand: create and append the panel.
isPanel = iconSetsPanel();
iconSetsWrap.appendChild(isPanel);
}
const open = isPanel.style.display !== 'none';
isPanel.style.display = open ? 'none' : 'block';
isToggle.textContent = (open ? '▸' : '▾') + ' Icon sets';
});
iconSetsWrap.appendChild(isToggle);
mount(main,
el('h1', { class: 'view-h1' }, '◆ Settings'),
section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody),
section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody),
section('Icon Sets', 'Upload or delete custom icon packs for device and service icons.', iconSetsWrap),
section('Orthos Mode', 'Local-first answering — Orthos answers first, Claude escalates when needed.',
el('div', { class: 'muted' }, 'Paused for a future project (arrives with the local-agent layer).'))
);

View File

@@ -7,14 +7,19 @@ import * as queue from './lib/jobs/queue.js';
import { registerWorkers } from './lib/jobs/index.js';
import { router as ingestRouter } from './lib/api/routes/ingest.js';
import { router as iconsRouter } from './lib/api/routes/icons.js';
import { router as iconSetsRouter } from './lib/api/routes/icon_sets.js';
import { router as devicesRouter } from './lib/api/routes/devices.js';
import { startCron } from './lib/cron/index.js';
import { seedFromConfig } from './lib/health/registry.js';
import { mcpAuth } from './lib/api/middleware/mcp_auth.js';
import { handleMcp } from './lib/mcp/http.js';
import httpProxy from 'http-proxy';
import { readFileSync } from 'node:fs';
const VERSION = '2.3.0';
// Read the version from package.json so a deploy never serves a stale /health
// version (the old hardcoded const had to be bumped by hand and caused the
// health-gated deploy to roll back 3x when forgotten).
const VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url))).version;
// Proxy /terminal (+ its WebSocket) to ttyd on CT 300, so the embedded terminal
// works whether the Void is reached via Traefik (void2-app.hynesy.com) OR the
@@ -53,6 +58,10 @@ export function createApp() {
// slugs are sanitized to [a-z0-9-] to prevent path traversal.
app.use('/api/icons', iconsRouter);
// /api/icon-sets/* — GET routes are open (same <img> reason as above);
// POST/DELETE are protected by requireOwner inside the router.
app.use('/api/icon-sets', iconSetsRouter);
// /api/devices — band data is public (like the static devices.json it replaces);
// discovered/edit/scan sub-routes use requireOwner (401/403) internally.
app.use('/api/devices', devicesRouter);

21
tests/api/backups.test.js Normal file
View File

@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { ingest } from '../../lib/api/routes/backups.js';
describe('backups ingest schema', () => {
it('accepts a valid run', () => {
const r = ingest.safeParse({
ok: true, total_bytes: 2400000000, won_free_bytes: 33000000000,
guests: [{ vmid: 310, name: 'void-db', bytes: 518000000 }], duration_sec: 950
});
expect(r.success).toBe(true);
});
it('accepts an empty body (all fields optional)', () => {
expect(ingest.safeParse({}).success).toBe(true);
});
it('rejects negative bytes', () => {
expect(ingest.safeParse({ total_bytes: -5 }).success).toBe(false);
});
it('rejects malformed guests', () => {
expect(ingest.safeParse({ guests: [{ vmid: 1 }] }).success).toBe(false);
});
});

View File

@@ -0,0 +1,72 @@
// tests/api/icon_sets.test.js
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import request from 'supertest';
import { createApp } from '../../server.js';
import * as sets from '../../lib/icons/sets.js';
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
let app;
let setsDir;
let bundledDir;
beforeAll(() => {
process.env.OWNER_TOKEN = 'test-token';
app = createApp();
});
beforeEach(() => {
// Set up fresh temp dirs with a fake bundled device icon so the bundled set is non-empty.
setsDir = mkdtempSync(path.join(tmpdir(), 'iconsets-'));
bundledDir = path.join(setsDir, '__bundled');
mkdirSync(bundledDir, { recursive: true });
writeFileSync(path.join(bundledDir, 'router.svg'), '<svg><path/></svg>');
sets._setDirs({ setsDir, bundledDir });
});
const owner = r => r.set('Authorization', 'Bearer test-token');
describe('GET /api/icon-sets', () => {
it('returns 200 with an array including the bundled devices set', async () => {
const res = await request(app).get('/api/icon-sets');
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
const dev = res.body.find(s => s.set === 'devices');
expect(dev).toBeDefined();
expect(dev.readonly).toBe(true);
});
});
describe('POST /api/icon-sets/:set', () => {
it('returns 401 (not 500) without auth', async () => {
const res = await request(app)
.post('/api/icon-sets/mytest')
.attach('files', PNG, { filename: 'router.png', contentType: 'image/png' });
expect(res.status).toBe(401);
});
it('returns 200 and uploads the icon with owner auth', async () => {
const res = await owner(
request(app).post('/api/icon-sets/mytest')
).attach('files', PNG, { filename: 'router.png', contentType: 'image/png' });
expect(res.status).toBe(200);
expect(res.body.set).toBe('mytest');
expect(res.body.icons).toContain('router.png');
});
});
describe('GET /api/icon-sets/:set/:file', () => {
it('serves a previously uploaded icon with image/png content-type', async () => {
// Upload first
await owner(
request(app).post('/api/icon-sets/mytest')
).attach('files', PNG, { filename: 'router.png', contentType: 'image/png' });
const res = await request(app).get('/api/icon-sets/mytest/router.png');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('image/png');
});
});

View File

@@ -0,0 +1,15 @@
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);
});
});

View File

@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest';
import AdmZip from 'adm-zip';
import { processFile, unpackZip, fetchUrl, isBlockedAddress, 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 non-image junk', () => {
const z = new AdmZip();
z.addFile('a.png', PNG);
z.addFile('notes.txt', Buffer.from('hi'));
const out = unpackZip(z.toBuffer());
expect(out.map(f => f.name)).toEqual(['a.png']);
});
it('skips path-traversal entries', () => {
// adm-zip's addFile() sanitizes '../' at write time (zipnamefix), so it
// can't produce a real traversal entry. Build one by mutating the entry
// name at the raw level *after* addFile — this survives serialization and
// stores '../evil.png' verbatim in the zip bytes.
const z = new AdmZip();
z.addFile('a.png', PNG);
z.addFile('placeholder.png', PNG);
const entries = z.getEntries();
entries[1].entryName = '../evil.png';
const buf = z.toBuffer();
// Sanity check: the traversal entry name really is in the serialized bytes.
expect(buf.includes(Buffer.from('../evil.png'))).toBe(true);
const out = unpackZip(buf);
expect(out.map(f => f.name)).toEqual(['a.png']);
});
});
describe('isBlockedAddress', () => {
it('blocks loopback IPv4', () => { expect(isBlockedAddress('127.0.0.1')).toBe(true); });
it('blocks private 10/8', () => { expect(isBlockedAddress('10.1.2.3')).toBe(true); });
it('blocks private 192.168/16', () => { expect(isBlockedAddress('192.168.0.1')).toBe(true); });
it('blocks link-local 169.254/16', () => { expect(isBlockedAddress('169.254.1.1')).toBe(true); });
it('blocks 0.0.0.0', () => { expect(isBlockedAddress('0.0.0.0')).toBe(true); });
it('blocks IPv6 loopback ::1', () => { expect(isBlockedAddress('::1')).toBe(true); });
it('blocks IPv6 ULA fc00::1', () => { expect(isBlockedAddress('fc00::1')).toBe(true); });
it('allows public 8.8.8.8', () => { expect(isBlockedAddress('8.8.8.8')).toBe(false); });
it('allows public 1.1.1.1', () => { expect(isBlockedAddress('1.1.1.1')).toBe(false); });
});
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);
});
});

View File

@@ -0,0 +1,29 @@
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('strips unquoted on* handlers', () => {
const out = sanitizeSvg('<svg onload=alert(1)><rect onclick=go()/></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/);
});
});

37
tests/icons/sets.test.js Normal file
View File

@@ -0,0 +1,37 @@
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();
});
});

View File

@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { normalizeStorage, storageHealth } from '../../lib/proxmox/storage.js';
// Fixtures mirror real PVE payload shapes from this cluster.
const STORAGE = [
{ storage: 'localzfs', node: 'z', status: 'available', plugintype: 'zfspool', disk: 37e9, maxdisk: 516e9 },
{ storage: 'donatello-vm', node: 'z', status: 'unknown', plugintype: 'zfspool', disk: 0, maxdisk: 0 },
{ storage: 'leonardo-vm', node: 'z', status: 'unknown', plugintype: 'zfspool', disk: 0, maxdisk: 0 },
{ storage: 'local', node: 'z', status: 'available', plugintype: 'dir', disk: 1e9, maxdisk: 100e9 }
];
const VMS = [
{ vmid: 100, name: 'mediastack', type: 'lxc', node: 'z', disk: 60e9, maxdisk: 63e9, status: 'running' }, // 95%
{ vmid: 311, name: 'void-app', type: 'lxc', node: 'z', disk: 4e9, maxdisk: 16e9, status: 'running' }, // 25%
{ vmid: 200, name: 'OpenClaw', type: 'qemu', node: 'z', disk: 0, maxdisk: 32e9, status: 'running' } // skipped (qemu/0)
];
const ZFS = { z: [{ name: 'localzfs', health: 'ONLINE', alloc: 37e9, size: 516e9, frag: 6 }] };
describe('normalizeStorage', () => {
it('flags a dropped zfspool, a hot container, and rolls up worst=crit', () => {
const r = normalizeStorage(STORAGE, VMS, ZFS);
// dropped pools (donatello/leonardo) surface in `down`
expect(r.down.map(d => d.name).sort()).toEqual(['donatello-vm', 'leonardo-vm']);
expect(r.down.every(d => d.status === 'crit')).toBe(true);
// imported pool present + healthy
expect(r.pools).toHaveLength(1);
expect(r.pools[0].name).toBe('localzfs');
// guests: qemu/0 skipped, sorted desc, CT100 at 95% is crit
expect(r.guests.map(g => g.vmid)).toEqual([100, 311]);
expect(r.guests[0].pct).toBe(95);
expect(r.guests[0].status).toBe('crit');
expect(r.worst).toBe('crit');
expect(r.alerts.some(a => a.includes('donatello-vm'))).toBe(true);
expect(r.alerts.some(a => a.includes('CT 100'))).toBe(true);
});
it('all-healthy rolls up to ok', () => {
const r = normalizeStorage(
[{ storage: 'localzfs', node: 'z', status: 'available', plugintype: 'zfspool' }],
[{ vmid: 311, name: 'void-app', type: 'lxc', node: 'z', disk: 4e9, maxdisk: 16e9 }],
{ z: [{ name: 'localzfs', health: 'ONLINE', alloc: 37e9, size: 516e9 }] }
);
expect(r.worst).toBe('ok');
expect(r.down).toHaveLength(0);
expect(r.alerts).toHaveLength(0);
});
});
describe('storageHealth', () => {
it('returns proxmox_not_configured without a token', async () => {
const r = await storageHealth({ apiUrl: '', token: '' });
expect(r.error).toBe('proxmox_not_configured');
});
it('fetches + normalizes via injected fetch', async () => {
const fetchImpl = async (url) => ({
ok: true,
json: async () => {
if (url.includes('type=storage')) return { data: STORAGE };
if (url.includes('type=vm')) return { data: VMS };
if (url.includes('/nodes/z/disks/zfs')) return { data: ZFS.z };
if (url.endsWith('/nodes')) return { data: [{ node: 'z', status: 'online' }] };
return { data: [] };
}
});
const r = await storageHealth({ apiUrl: 'https://pve:8006', token: 'tok', fetchImpl });
expect(r.worst).toBe('crit');
expect(r.down).toHaveLength(2);
expect(typeof r.at).toBe('number');
});
});

View File

@@ -0,0 +1,29 @@
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');
});
});