38 Commits

Author SHA1 Message Date
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
root
91a45b4b6c feat(apps): MagicMirror as a Void app (#/mirror, mirror.hynesy.com)
Embed MagicMirror² (CT 111) via the shared embedView factory, exposed at
mirror.hynesy.com through Traefik + CF Access. Traefik mirror-frame middleware
swaps MM's X-Frame-Options for a CSP frame-ancestors allowing the Void origins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 00:42:40 +10:00
root
95fa0c1828 chore(release): 2.2.0 — Kutt Links app
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 00:10:50 +10:00
root
318492a078 feat(links): Links Apps view — embed + update-tracker + quick-add 2026-06-09 00:03:48 +10:00
root
cd5ca03d96 feat(links): /api/kutt proxy (version + create + recent) 2026-06-09 00:03:48 +10:00
root
c8b9dddd61 feat(links): Kutt API client + release version-compare 2026-06-09 00:03:48 +10:00
root
8f7331129f docs(kutt): implementation plan for Kutt URL shortener as a Void app
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 00:03:48 +10:00
root
b783c031b0 docs(kutt): spec for Kutt URL shortener as a Void app
Stock Kutt (bare-metal LXC, Postgres on void-db), blackflame via custom CSS (no
fork), private-first via CF Access with a public-later toggle. Hybrid Void
integration: embedded themed Kutt + a native card (update-tracker + quick-add).
Repos: Hynes/URLShortener-void-kutt + void-v2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 00:03:48 +10:00
root
26463b5eb6 feat(devices): Scan Now + Manual Add (IP option, MAC colon-mask) → 2.1.4
'Scan Now' triggers POST /api/devices/scan from the band header. '+ Add by MAC'
renamed '+ Manual Add' with an optional IP field (addBody/addManual accept ip)
and a MAC input that auto-inserts colons as you type. Frontend test 4/4; DB-backed
api/repo tests written (run with the suite — skipped locally to avoid colliding
with a concurrent test run on void_test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:58:19 +10:00
root
88ef5786ee feat(devices): manually add a device by MAC (offline pre-register) → 2.1.3
'+ Add by MAC' in the band header → POST /api/devices → lan_devices.addManual
(status=known, present=false; enriched on next scan). Repo + API + frontend tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:12:47 +10:00
root
7a5fd88c07 feat(devices): edit known devices (rename/regroup/delete) → 2.1.2
Known device tiles get a ✎ edit affordance using the existing PATCH/DELETE
/api/devices/:mac endpoints. Previously devices could only be named at promote time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:02:06 +10:00
root
2284a88bd2 docs: add awesome-selfhosted as the research starting point (AGENTS.md)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:16:38 +10:00
63 changed files with 3684 additions and 49 deletions

View File

@@ -9,6 +9,10 @@
Capture **verbose-first** — we consolidate/compress later. Losing information is the only failure mode; over-documenting is fine. Do this proactively as part of finishing a task, not only when asked. Capture **verbose-first** — we consolidate/compress later. Losing information is the only failure mode; over-documenting is fine. Do this proactively as part of finishing a task, not only when asked.
## Research convention
When researching tools/projects to recommend or self-host, **start from [awesome-selfhosted](https://github.com/awesome-selfhosted/awesome-selfhosted)** (browsable at awesome-selfhosted.net) — a trusted, comprehensive, category-organised list of open-source self-hostable software. Cite it (and the relevant category) alongside other sources.
### How — wiki ### How — wiki
Owner token: `OWNER_TOKEN` in `/opt/void-server/.env` on CT 311 (`void-app`, `192.168.1.216`). API on the LAN at `http://192.168.1.216:3000`: Owner token: `OWNER_TOKEN` in `/opt/void-server/.env` on CT 311 (`void-app`, `192.168.1.216`). API on the LAN at `http://192.168.1.216:3000`:
- Edit a page: `PATCH /api/pages/:id` with `{ "body_md": "…", "title": "…" }` - Edit a page: `PATCH /api/pages/:id` with `{ "body_md": "…", "title": "…" }`

View File

@@ -3,6 +3,30 @@
All notable changes to Void 2.0 are documented here. All notable changes to Void 2.0 are documented here.
Format: [Keep a Changelog](https://keepachangelog.com). 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.
- Unrelated to the Void code: CT 111 itself was updated **MagicMirror 2.25.0 → 2.36.0** on **Node 22**.
## 2.2.0 — Links: self-hosted URL shortener (Kutt) as a Void app
- **New "Links" Apps view** (`#/links`, `public/views/links.js`) — a Void-native card (Kutt **version / update tracker** + one-field **quick-add shortener**) on top of the blackflame-themed **Kutt** UI embedded via iframe (`link.hynesy.com`). Hybrid model: native convenience + the full Kutt UI in one tab.
- **`/api/kutt` proxy** (`lib/api/routes/kutt.js`, `lib/links/kutt.js`) — owner-gated server-side proxy that holds the Kutt API key (`GET /version` vs latest GitHub release, cached 6h; `POST /` create; `GET /recent`). The key never reaches the browser. *(Mounted at `/api/kutt`, not `/api/links` — the latter is the Void's existing internal cross-entity linking router.)*
- **Infra:** Kutt runs bare-metal in **LXC 113** (`192.168.1.226:3000`), sharing the **void-db** Postgres (own `kutt` DB/role), private-first behind CF Access at `link.hynesy.com`. Theme served as custom CSS; registration locked after admin creation. Env wired in void-app (`KUTT_API_URL`/`KUTT_API_KEY`/`KUTT_VERSION`).
## 2.1.4 — Devices band: Scan Now + richer Manual Add
- **"Scan Now" button** in the Network·Devices header — triggers the scheduled scan on demand (`POST /api/devices/scan`) and refreshes the band.
- **"+ Add by MAC" → "+ Manual Add"**, now with an optional **IP** field (`POST /api/devices` + `lan_devices.addManual` accept `ip`), and the **MAC field auto-inserts the colons** as you type.
## 2.1.3 — Manually add a device by MAC
- **"+ Add by MAC" in the Network·Devices band** (`POST /api/devices`, `lan_devices.addManual`, `devices_band.js`): pre-register an **offline** device by typing its MAC (+ optional name/group). Lands as `status='known'`, `present=false`; it gets enriched (IP/vendor/present) automatically the next time it's seen by the scan. Idempotent.
## 2.1.2 — Edit known network devices
- **Edit devices in the Network·Devices band** (`public/views/devices_band.js`): known tiles get a ✎ edit affordance — rename, re-group, or delete a device (PATCH/DELETE `/api/devices/:mac`, which already existed). Previously a device could only be named when first promoted from Discovered.
## 2.1.1 — OBD2 Apps rail placeholder ## 2.1.1 — OBD2 Apps rail placeholder
- **OBD2 Apps rail item** (`public/views/obd2.js`, router/app/sidebar): a placeholder launchpad under **Apps** for the parked OBD2 Telemetry project — links to the project + tasks and the research/wiki page. Swap to an `embedView` once a records UI (LubeLogger/Tracktor) is deployed. - **OBD2 Apps rail item** (`public/views/obd2.js`, router/app/sidebar): a placeholder launchpad under **Apps** for the parked OBD2 Telemetry project — links to the project + tasks and the research/wiki page. Swap to an `embedView` once a records UI (LubeLogger/Tracktor) is deployed.

View File

@@ -0,0 +1,587 @@
# Kutt URL Shortener as a Void App — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Self-host stock Kutt (bare-metal LXC, Postgres on void-db, blackflame via custom CSS) behind `link.hynesy.com` (private via CF Access), surfaced in the Void as a Hybrid Apps item (embedded themed Kutt + a native update-tracker/quick-add card).
**Architecture:** Kutt runs unmodified in CT 113; the Void embeds its themed UI and adds a native card backed by a `/api/links` server proxy that holds the Kutt API key. Theming + deploy live in `Hynes/URLShortener-void-kutt`; the Void integration lives in `Hynes/Void-Homelab` (void-v2).
**Tech Stack:** Kutt (Node/knex/Postgres), systemd, Traefik + Cloudflare Access, void-v2 (Express + vanilla-ESM SPA, vitest/supertest/jsdom).
**Spec:** `docs/superpowers/specs/2026-06-08-kutt-url-shortener-design.md`
**Branch:** `feat/kutt-url-shortener` (spec already committed there).
**Conventions to mirror (void-v2):** embed view = `public/views/timelapse.js` + `public/views/embed.js`; route = `lib/api/routes/health.js` (`Router`, `asyncWrap`, `requireOwner`, `validate`); api mount in `lib/api/index.js`; supertest = `tests/server.test.js`; frontend test = `tests/frontend/embed.test.js`.
---
## Phase A — Kutt service (infra + theme repo)
### Task 1: Create the `kutt` database + role on void-db
**Files:** none (live DB on CT 310).
- [ ] **Step 1: Create role + database (least-priv, NOSUPERUSER)**
```bash
ssh root@192.168.1.215 "su - postgres -c \"psql -v ON_ERROR_STOP=1 <<'SQL'
CREATE ROLE kutt LOGIN PASSWORD 'CHANGE_ME_STRONG' NOSUPERUSER NOCREATEDB NOCREATEROLE;
CREATE DATABASE kutt OWNER kutt;
SQL\""
```
(Replace `CHANGE_ME_STRONG` with a generated secret; reuse it in Task 2's `.env`.)
- [ ] **Step 2: Verify**
```bash
ssh root@192.168.1.215 "su - postgres -c \"psql -tAc \\\"SELECT datname FROM pg_database WHERE datname='kutt'\\\"\""
```
Expected: prints `kutt`.
- [ ] **Step 3: Allow LAN access from CT 113** — confirm void-db's `pg_hba.conf` / `listen_addresses` already accept the LAN subnet (the `void` app on CT 311 connects, so it does). Note the host/port for the DSN: `192.168.1.215:5432`.
(No commit — record the password in the homelab secrets store, not git.)
### Task 2: Create CT 113 + install/run Kutt (bare-metal, Postgres)
**Files:** none in-repo yet (the repeatable scripts are committed in Task 9).
- [ ] **Step 1: Create the LXC on Z**
```bash
ssh root@192.168.1.124 "pct create 113 <debian-12-template> \
--hostname kutt --cores 2 --memory 2048 --rootfs localzfs:8 \
--net0 name=eth0,bridge=vmbr0,gw=192.168.1.1,ip=192.168.1.226/24,hwaddr=<gen-mac> \
--onboot 1 --features nesting=1 --unprivileged 1 && pct start 113"
```
Pick the current Debian template (`pveam available | grep debian-12`); if `.226` is taken, use the next free infra IP. Add the MAC→`.226` reservation in the router. HA-tag via `ha-manager add ct:113 --state started` (matches the other guests).
- [ ] **Step 2: Install Node 20 + clone Kutt at a pinned tag**
```bash
ssh root@192.168.1.226 "
apt-get update -qq && apt-get install -y curl git build-essential
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs
useradd -r -m -d /opt/kutt -s /bin/bash kutt
sudo -u kutt git clone --depth 1 --branch v3.2.5 https://github.com/thedevs-network/kutt /opt/kutt
cd /opt/kutt && sudo -u kutt npm ci
"
```
- [ ] **Step 3: Write `/opt/kutt/.env`** (mode 600, owned by `kutt`)
```ini
PORT=3000
SITE_NAME=Hynesy Links
DEFAULT_DOMAIN=link.hynesy.com
JWT_SECRET=<generated-32+char-secret>
TRUST_PROXY=true
DB_CLIENT=pg
DB_HOST=192.168.1.215
DB_PORT=5432
DB_NAME=kutt
DB_USER=kutt
DB_PASSWORD=<the password from Task 1>
DB_SSL=false
REDIS_ENABLED=false
DISALLOW_REGISTRATION=true
DISALLOW_ANONYMOUS_LINKS=true
MAIL_ENABLED=false
ENABLE_RATE_LIMIT=true
```
- [ ] **Step 4: Migrate + create the admin (temporarily allow registration)**
```bash
ssh root@192.168.1.226 "cd /opt/kutt && sudo -u kutt env \$(grep -v '^#' .env | xargs) npm run migrate"
# Temporarily set DISALLOW_REGISTRATION=false, start, register your admin in the browser/API, then set it back to true.
```
Confirm against Kutt's README "first user / admin" flow during this step (Kutt makes the first registered user the admin). After creating the admin, set `DISALLOW_REGISTRATION=true`.
- [ ] **Step 5: systemd unit `kutt.service`**
```ini
[Unit]
Description=Kutt URL shortener
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=kutt
WorkingDirectory=/opt/kutt
EnvironmentFile=/opt/kutt/.env
ExecStart=/usr/bin/npm start
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
`systemctl daemon-reload && systemctl enable --now kutt`.
- [ ] **Step 6: Generate a Kutt API key** — log into the admin account → Settings → API → generate a key. Record it for Task 8 (`KUTT_API_KEY`).
- [ ] **Step 7: Verify Kutt runs + resolves on the LAN**
```bash
curl -fsS -m6 -o /dev/null -w "kutt root: %{http_code}\n" http://192.168.1.226:3000/
# create a test link via the API and resolve it:
curl -s -X POST http://192.168.1.226:3000/api/v2/links -H "X-API-KEY: <key>" -H "Content-Type: application/json" -d '{"target":"https://example.com"}'
curl -sI http://192.168.1.226:3000/<returned-slug> | grep -iE '^HTTP|^location'
```
Expected: root `200`; the slug returns a `302` to `https://example.com`.
### Task 3: Blackflame theme (custom CSS, no fork)
**Files:** Create `theme/css/blackflame.css` in `Hynes/URLShortener-void-kutt` (committed in Task 9); deployed into `/opt/kutt/custom/css/`.
- [ ] **Step 1: Write the blackflame CSS** — override Kutt's palette/typography to the Void tokens:
```css
/* blackflame.css — Void theme for stock Kutt (drop-in; no source changes) */
:root{
--bg:#0a0a0e; --panel:#14141c; --border:#2a2a36; --text:#e8e6ed; --muted:#888094;
--accent:#ff4f2e; --accent-dim:#7a2716;
}
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=Cormorant+Garamond:wght@400;600&family=JetBrains+Mono:wght@400;500&display=swap');
body{background:var(--bg);color:var(--text);font-family:'Cormorant Garamond',Georgia,serif}
a,.link{color:var(--accent)}
button,.button,[type=submit]{background:var(--accent-dim);border:1px solid var(--accent);color:var(--text);border-radius:3px}
button:hover,.button:hover{background:var(--accent);color:var(--bg)}
input,select,textarea{background:#1c1c26;border:1px solid var(--border);color:var(--text);border-radius:3px}
h1,h2,h3{font-family:'Cinzel',serif;letter-spacing:.04em}
code,.mono{font-family:'JetBrains Mono',monospace}
/* refine specific Kutt classes during the deploy task by inspecting the rendered DOM */
```
- [ ] **Step 2: Deploy + verify it's served**
```bash
ssh root@192.168.1.226 "mkdir -p /opt/kutt/custom/css && chown -R kutt: /opt/kutt/custom"
rsync theme/css/blackflame.css root@192.168.1.226:/opt/kutt/custom/css/
ssh root@192.168.1.226 "systemctl restart kutt"
curl -fsS -m6 http://192.168.1.226:3000/ | grep -o 'custom/css/blackflame.css' | head -1
```
Expected: the page references `custom/css/blackflame.css` (Kutt auto-includes files under `custom/css/` per its README). Eyeball via webapp-testing and tighten selectors as needed.
### Task 4: Domain + CF Access (private Phase 1)
**Files:** Traefik dynamic config on mediastack (mirror existing routers); CF Access via API.
- [ ] **Step 1: Traefik router `link.hynesy.com` → CT 113:3000**
On mediastack (`192.168.1.230`), in `/docker/proxy/dynamic.yml` (mirror the `aiusage` block added earlier): add a `link` router (`Host(\`link.hynesy.com\`)`, `websecure`, `certResolver: cloudflare`) + service → `http://192.168.1.226:3000`. Back up the file first. File-provider hot-reloads.
- [ ] **Step 2: CF Access app over the whole host (private)**
Clone the `aiusage` Access app (Google IdP + email allowlist) for domain `link.hynesy.com` via the CF API (creds in the `reference_cloudflare_api` memory). This gates **everything** on `link.hynesy.com` for Phase 1.
- [ ] **Step 3: Verify**
```bash
curl -sI -m8 https://link.hynesy.com | grep -iE '^HTTP|location' # expect 302 -> cloudflareaccess (gated)
```
In a browser authed to CF Access: `https://link.hynesy.com` loads the blackflame Kutt; a created slug `https://link.hynesy.com/<slug>` 302s to target.
---
## Phase B — Void integration (void-v2, TDD)
### Task 5: Kutt API client + version-compare (pure-ish, injectable fetch)
**Files:**
- Create: `lib/links/kutt.js`
- Test: `tests/links/kutt.test.js`
- [ ] **Step 1: Write the failing test**
```js
// tests/links/kutt.test.js
import { describe, it, expect } from 'vitest';
import { compareVersions, fetchLatestKuttRelease, createLink } from '../../lib/links/kutt.js';
describe('kutt helpers', () => {
it('compareVersions flags an available update (tolerates v-prefix)', () => {
expect(compareVersions('v3.2.5', 'v3.2.6')).toEqual({ running: 'v3.2.5', latest: 'v3.2.6', updateAvailable: true });
expect(compareVersions('3.2.6', 'v3.2.6')).toMatchObject({ updateAvailable: false });
});
it('fetchLatestKuttRelease returns tag + url from the GitHub API (injected fetch)', async () => {
const fakeFetch = async () => ({ ok: true, json: async () => ({ tag_name: 'v3.2.6', html_url: 'https://x/releases/v3.2.6' }) });
expect(await fetchLatestKuttRelease({ fetch: fakeFetch })).toEqual({ latest: 'v3.2.6', url: 'https://x/releases/v3.2.6' });
});
it('createLink POSTs to the Kutt API with the key and returns the short link', async () => {
let seen;
const fakeFetch = async (url, opts) => { seen = { url, opts }; return { ok: true, json: async () => ({ link: 'https://link.hynesy.com/abc', address: 'abc' }) }; };
const r = await createLink({ target: 'https://example.com' }, { base: 'http://10.0.0.1:3000', key: 'K', fetch: fakeFetch });
expect(seen.url).toBe('http://10.0.0.1:3000/api/v2/links');
expect(seen.opts.headers['X-API-KEY']).toBe('K');
expect(r.link).toBe('https://link.hynesy.com/abc');
});
});
```
- [ ] **Step 2: Run it; verify it fails**
Run: `npm test -- tests/links/kutt.test.js`
Expected: FAIL — module not found.
- [ ] **Step 3: Create `lib/links/kutt.js`**
```js
// Thin client for stock Kutt's REST API + release-version compare. fetch injected
// for tests; defaults to global fetch (Node 22). No Kutt source coupling.
const norm = v => String(v || '').replace(/^v/, '');
export function compareVersions(running, latest) {
return { running, latest, updateAvailable: norm(running) !== '' && norm(latest) !== '' && norm(running) !== norm(latest) };
}
export async function fetchLatestKuttRelease({ fetch = globalThis.fetch } = {}) {
const res = await fetch('https://api.github.com/repos/thedevs-network/kutt/releases/latest',
{ headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'void' } });
if (!res.ok) throw new Error(`github ${res.status}`);
const j = await res.json();
return { latest: j.tag_name, url: j.html_url };
}
export async function createLink(body, { base, key, fetch = globalThis.fetch }) {
const res = await fetch(`${base}/api/v2/links`, {
method: 'POST',
headers: { 'X-API-KEY': key, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`kutt ${res.status}`);
return res.json();
}
export async function recentLinks({ base, key, fetch = globalThis.fetch, limit = 5 }) {
const res = await fetch(`${base}/api/v2/links?limit=${limit}`, { headers: { 'X-API-KEY': key } });
if (!res.ok) throw new Error(`kutt ${res.status}`);
return res.json();
}
```
- [ ] **Step 4: Run it; verify it passes**
Run: `npm test -- tests/links/kutt.test.js`
Expected: PASS (3 passed).
- [ ] **Step 5: Commit**
```bash
git add lib/links/kutt.js tests/links/kutt.test.js
git commit -m "feat(links): Kutt API client + release version-compare"
```
### Task 6: `/api/links` proxy route
**Files:**
- Create: `lib/api/routes/links.js`
- Modify: `lib/api/index.js` (import + `api.use('/links', linksRouter)`)
- Test: `tests/api/links.test.js`
- [ ] **Step 1: Write the failing test**
```js
// tests/api/links.test.js
import { describe, it, expect, beforeAll, vi } from 'vitest';
import request from 'supertest';
vi.mock('../../lib/links/kutt.js', () => ({
compareVersions: (r, l) => ({ running: r, latest: l, updateAvailable: r !== l }),
fetchLatestKuttRelease: async () => ({ latest: 'v9.9.9', url: 'https://x' }),
createLink: async (b) => ({ link: 'https://link.hynesy.com/abc', address: 'abc', target: b.target }),
recentLinks: async () => ({ data: [] })
}));
let app;
const owner = r => r.set('Authorization', 'Bearer test-token');
beforeAll(async () => {
process.env.OWNER_TOKEN = 'test-token';
process.env.KUTT_API_URL = 'http://10.0.0.1:3000';
process.env.KUTT_API_KEY = 'K';
process.env.KUTT_VERSION = 'v3.2.5';
({ createApp } = await import('../../server.js'));
app = createApp();
});
let createApp;
describe('/api/links', () => {
it('GET /version returns running/latest/updateAvailable (owner)', async () => {
expect((await request(app).get('/api/links/version')).status).toBe(401);
const res = await owner(request(app).get('/api/links/version'));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ running: 'v3.2.5', latest: 'v9.9.9', updateAvailable: true });
});
it('POST / creates a link via Kutt (owner)', async () => {
const res = await owner(request(app).post('/api/links')).send({ target: 'https://example.com' });
expect(res.status).toBe(201);
expect(res.body.link).toBe('https://link.hynesy.com/abc');
});
it('POST / rejects a non-URL target', async () => {
expect((await owner(request(app).post('/api/links')).send({ target: 'not a url' })).status).toBe(400);
});
});
```
- [ ] **Step 2: Run it; verify it fails**
Run: `npm test -- tests/api/links.test.js`
Expected: FAIL — route not mounted.
- [ ] **Step 3: Create `lib/api/routes/links.js`**
```js
import { Router } from 'express';
import { z } from 'zod';
import { asyncWrap } from '../errors.js';
import { requireOwner } from '../cap.js';
import { validate } from '../validate.js';
import { compareVersions, fetchLatestKuttRelease, createLink, recentLinks } from '../../links/kutt.js';
export const router = Router();
const cfg = () => ({ base: process.env.KUTT_API_URL, key: process.env.KUTT_API_KEY });
// GET /links/version — running (pinned env) vs latest GitHub release (cached 6h).
let cache = { at: 0, val: null };
router.get('/version', requireOwner, asyncWrap(async (_req, res) => {
const running = process.env.KUTT_VERSION || 'unknown';
if (Date.now() - cache.at > 6 * 3600e3 || !cache.val) {
try { cache = { at: Date.now(), val: await fetchLatestKuttRelease({}) }; }
catch { return res.json({ running, latest: null, updateAvailable: false, error: 'version check unavailable' }); }
}
res.json({ ...compareVersions(running, cache.val.latest), url: cache.val.url });
}));
const linkBody = z.object({
target: z.string().url(),
customurl: z.string().max(64).optional(),
description: z.string().max(200).optional()
});
// POST /links — create via Kutt (owner). Key stays server-side.
router.post('/', requireOwner, validate({ body: linkBody }), asyncWrap(async (req, res) => {
if (!process.env.KUTT_API_KEY) return res.status(502).json({ error: { code: 'kutt_unconfigured' } });
res.status(201).json(await createLink(req.body, cfg()));
}));
// GET /links/recent — last few links (owner).
router.get('/recent', requireOwner, asyncWrap(async (_req, res) => {
res.json(await recentLinks(cfg()));
}));
```
- [ ] **Step 4: Mount in `lib/api/index.js`**
Add with the other imports + mounts:
```js
import { router as linksRouter } from './routes/links.js';
```
```js
api.use('/links', linksRouter);
```
- [ ] **Step 5: Run it; verify it passes**
Run: `npm test -- tests/api/links.test.js`
Expected: PASS (3 passed).
- [ ] **Step 6: Commit**
```bash
git add lib/api/routes/links.js lib/api/index.js tests/api/links.test.js
git commit -m "feat(links): /api/links proxy (version + create + recent)"
```
### Task 7: Front-end — "Links" Apps view (embed + native card)
**Files:**
- Create: `public/views/links.js`
- Modify: `public/router.js`, `public/app.js`, `public/components/sidebar.js`, `public/style.css`
- Test: `tests/frontend/links_view.test.js`
- [ ] **Step 1: Write the failing test**
```js
// tests/frontend/links_view.test.js
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { JSDOM } from 'jsdom';
vi.mock('../../public/api.js', () => ({ api: {
get: vi.fn(async (p) => p.endsWith('/version')
? { running: 'v3.2.5', latest: 'v3.2.6', updateAvailable: true, url: 'https://x' }
: { data: [] }),
post: vi.fn(async () => ({ link: 'https://link.hynesy.com/abc' }))
} }));
let render;
beforeAll(async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="main"></div></body></html>', { url: 'http://localhost/' });
global.window = dom.window; global.document = dom.window.document; global.Node = dom.window.Node;
({ render } = await import('../../public/views/links.js'));
});
afterAll(() => { delete global.window; delete global.document; delete global.Node; });
describe('links view', () => {
it('renders the update badge + quick-add + the Kutt iframe', async () => {
const main = document.getElementById('main');
await render(main);
await new Promise(r => setTimeout(r, 0));
expect(main.querySelector('iframe.term-frame').getAttribute('src')).toBe('https://link.hynesy.com/');
expect(main.textContent).toMatch(/update available/i);
expect(main.querySelector('.lk-quickadd')).not.toBeNull();
});
});
```
- [ ] **Step 2: Run it; verify it fails**
Run: `npm test -- tests/frontend/links_view.test.js`
Expected: FAIL — module not found.
- [ ] **Step 3: Create `public/views/links.js`**
```js
// #/links — Hybrid Apps view: a Void-native card (update-tracker + quick-add) on
// top of the embedded themed Kutt UI. Reuses the .term-bar/.term-frame embed classes.
import { el, mount } from '../dom.js';
import { api } from '../api.js';
const SRC = 'https://link.hynesy.com/';
export async function render(main) {
const badge = el('span', { class: 'lk-badge muted' }, 'checking…');
const out = el('span', { class: 'lk-out muted' }, '');
const input = el('input', { class: 'lk-url', placeholder: 'https://long-url-to-shorten…' });
const add = el('button', { class: 'primary' }, '◆ Shorten');
add.onclick = async () => {
const target = input.value.trim(); if (!target) return;
out.textContent = 'creating…';
try { const r = await api.post('/api/links', { target }); out.innerHTML = ''; out.appendChild(el('a', { href: r.link, target: '_blank', rel: 'noopener' }, r.link)); input.value = ''; }
catch { out.textContent = 'failed (is Kutt reachable / API key set?)'; }
};
mount(main,
el('div', { class: 'term-bar' },
el('span', { class: 'term-title' }, '◆ Links'),
el('a', { class: 'ghost', style: { marginLeft: 'auto' }, href: SRC, target: '_blank', rel: 'noopener' }, '↗ Open Kutt')
),
el('div', { class: 'card lk-card' },
el('div', { class: 'lk-row' }, el('span', { class: 'muted' }, 'Kutt version'), badge),
el('div', { class: 'lk-quickadd' }, input, add),
el('div', {}, out)
),
el('iframe', { id: 'embed-frame', src: SRC, class: 'term-frame' })
);
try {
const v = await api.get('/api/links/version');
badge.classList.remove('muted');
if (v.updateAvailable) { badge.classList.add('lk-update'); badge.innerHTML = ''; badge.appendChild(el('a', { href: v.url, target: '_blank', rel: 'noopener' }, `${v.running} → ${v.latest} · update available`)); }
else badge.textContent = `${v.running} · up to date`;
} catch { badge.textContent = 'version check unavailable'; }
}
```
- [ ] **Step 4: Wire route/dispatch/sidebar**
- `public/router.js` — after the `obd2` route: `{ name: 'links', re: /^\/links$/, keys: [] },`
- `public/app.js` — in `VIEWS`, after `obd2`: `links: () => import('./views/links.js'),`
- `public/components/sidebar.js` — in the Apps section, after the OBD2 item: `navItem('Links', '/links')`
- [ ] **Step 5: Add styles in `public/style.css`** (after the `.dv-mac` rule)
```css
.lk-card { max-width: 760px; }
.lk-row { display: flex; gap: 12px; align-items: center; margin-bottom: 10px; }
.lk-update a { color: var(--accent); }
.lk-quickadd { display: flex; gap: 8px; }
.lk-quickadd .lk-url { flex: 1; }
.lk-out { display: block; margin-top: 8px; font-family: var(--font-mono); font-size: 13px; }
```
- [ ] **Step 6: Run it; verify it passes**
Run: `npm test -- tests/frontend/links_view.test.js`
Expected: PASS. Then `node --check public/app.js` (clean).
- [ ] **Step 7: Commit**
```bash
git add public/views/links.js public/router.js public/app.js public/components/sidebar.js public/style.css tests/frontend/links_view.test.js
git commit -m "feat(links): Links Apps view — embed + update-tracker + quick-add"
```
### Task 8: Env wiring + version bump + deploy
**Files:** Modify `package.json`, `server.js`, `CHANGELOG.md`; void-app `.env` (live).
- [ ] **Step 1: Add Kutt env to void-app** — on CT 311, append to `/opt/void-server/.env`:
```
KUTT_API_URL=http://192.168.1.226:3000
KUTT_API_KEY=<the key from Task 2 Step 6>
KUTT_VERSION=v3.2.5
```
- [ ] **Step 2: Bump version + CHANGELOG** — `package.json` + `server.js` → `2.2.0`; prepend:
```markdown
## 2.2.0 — Links (Kutt) Apps item
- **Kutt URL shortener** folded into the Apps rail (`#/links`): embedded blackflame-themed
Kutt (link.hynesy.com) + a Void-native card with a release update-tracker and a quick-add
that proxies Kutt's REST API server-side (`/api/links`, key held in void-app env). Kutt runs
stock (CT 113, Postgres on void-db), private via CF Access.
```
- [ ] **Step 3: Full suite + deploy**
```bash
npm test
ssh root@192.168.1.124 "pct snapshot 311 pre_2_2_0 --description 'before Links/Kutt'"
./deploy/push.sh
curl -s https://void.hynesy.com/health # via LAN: http://192.168.1.216:3000/health → version 2.2.0
```
- [ ] **Step 4: Commit**
```bash
git add package.json server.js CHANGELOG.md
git commit -m "chore(release): 2.2.0 — Links (Kutt) Apps item"
```
---
## Phase C — repo + docs
### Task 9: `Hynes/URLShortener-void-kutt` repo (theme + deploy)
**Files:** new local repo `/project/src/urlshortener-void-kutt` (or your preferred path).
- [ ] **Step 1: Assemble + commit**
Create the repo with: `theme/css/blackflame.css` (Task 3), `deploy/create-ct.sh` + `deploy/bootstrap.sh` + `deploy/kutt.service` + `deploy/.env.example` (the exact steps/files from Task 2), and a `README.md` documenting the CT 113 deploy + how to update Kutt (bump tag → `git fetch && git checkout <tag> && npm ci && npm run migrate && systemctl restart kutt` → bump `KUTT_VERSION` in void-app). Then:
```bash
cd /project/src/urlshortener-void-kutt && git init -b main && git add -A
git commit -m "Kutt-on-Void: blackflame theme + bare-metal CT 113 deploy"
git remote add origin gitea@192.168.1.223:Hynes/URLShortener-void-kutt.git
git push -u origin main
```
### Task 10: Wiki page + push the void-v2 branch
- [ ] **Step 1: Create the wiki page** — `POST /api/spaces/2201a3dd-…/pages` (owner token) under **Hosts & Services** (`ab398d61-…`), slug `kutt-link-shortener-lxc-113`, title "Kutt / Link Shortener LXC (113)". Body: CT 113 as-deployed (Node, Postgres on void-db, link.hynesy.com, CF-Access-private), the blackflame-via-custom-CSS note, the Void Hybrid integration, and the update flow. Avoid contiguous `IP:port` for any non-live host.
- [ ] **Step 2: Push void-v2** — `git push origin feat/kutt-url-shortener`, then finish the branch (merge to `main`, tag `v2.2.0`) per `superpowers:finishing-a-development-branch`.
---
## Self-review notes
- **Spec coverage:** stock Kutt bare-metal LXC (T2) · Postgres on void-db (T1) · blackflame custom CSS no-fork (T3) · link.hynesy.com + CF-Access-private (T4) · `/api/links` proxy holding the key (T6) · update-tracker vs GitHub release (T5,T6,T7) · quick-add (T6,T7) · embedded themed Kutt in Apps rail (T7) · QR/geo deferred (out of scope, noted) · repos + wiki (T9,T10). All spec sections map to a task.
- **Type/name consistency:** `compareVersions/fetchLatestKuttRelease/createLink/recentLinks` defined in T5 are consumed identically in T6; `KUTT_API_URL/KUTT_API_KEY/KUTT_VERSION` env names match across T6/T8; `/api/links/version|/|recent` paths match between route (T6) and view (T7); embed uses the existing `.term-bar/.term-frame` classes.
- **Out of scope (not planned, per spec):** Phase-2 public access + per-link second domain; geo/tags upstream MRs; Redis; SMTP/registration.
- **Infra discovery flagged inline:** exact Debian template + free IP/MAC (T2), Kutt admin-creation flow (T2 S4), and CSS selector refinement (T3) are confirmed against the live system/Kutt docs during those tasks.
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,139 @@
# Design: Kutt URL shortener as a Void app
**Date:** 2026-06-08
**Status:** Approved (brainstorm), pending implementation plan
**Repos:** new **`Hynes/URLShortener-void-kutt`** (theme + deploy) + **`Hynes/Void-Homelab`** (void-v2 integration)
## Summary
Self-host **Kutt** (a modern Node URL shortener) **unmodified** in its own bare-metal
LXC behind `link.hynesy.com`, blackflame-themed via Kutt's **custom-CSS** support (no
fork → stays 100% upstream-clean). Surface it in the Void as a **Hybrid Apps item**:
an embedded themed Kutt UI for management, plus a small **Void-native card** for an
**update-tracker** and a **quick-add** box. Start **fully private** (CF Access over the
whole host) with a clean, no-rebuild path to **public-later** (and per-link
public/private via Kutt's multi-domain support).
## Background / constraints
- **Why Kutt** (vs Shlink): liked the UI, MIT, actively released (v3.2.5, 2026-05),
Node/Postgres bare-metal install, and **themeable via custom CSS without forking**
so we ride upstream `npm` updates with zero merge conflicts.
- **User preferences honoured:** bare-metal-in-LXC (no Docker for long-term personal
apps); blackflame styling; static IP + router MAC reservation per guest; HA-tag +
Z→Z3 replication; backup before changes; document everything to the wiki + git.
- **The redirect-vs-auth split:** a shortener's redirect endpoint normally must be
public, but the admin must be protected. We resolve this with a **CF Access toggle**
(private now) rather than baking a split in.
## Decisions
| Decision | Choice | Rationale |
|---|---|---|
| App | **Kutt, stock** (pinned release) | Liked UI; themeable via CSS so no fork; upstream-clean. |
| Deploy form | **Bare-metal LXC** (Node + systemd) | No-Docker-for-keepers preference. |
| Database | **Shared `void-db`** (CT 310, PG 16) — dedicated `kutt` DB + least-priv role | One Postgres to back up / HA; no new DB service. |
| Redis | **Skipped** | Optional cache; unnecessary single-user. |
| Domain | `link.hynesy.com` → Traefik → Kutt | Clear, readable. |
| Access (now) | **Private** — CF Access over the whole host | Locked down first. |
| Access (later) | Relax CF Access on redirects; add a public 2nd domain for per-link public/private | No rebuild — policy flip + Kutt multi-domain. |
| UI | **Hybrid** — embed themed Kutt + a Void-native card | Keep the UI you liked + Void extras. |
| Feature parity w/ Shlink | Stock + QR Void-side; richer gaps via **upstream MRs** (separate effort) | Never fork Kutt. |
## Architecture
```
Browser ──(CF Access, Phase 1)── link.hynesy.com ── Traefik(mediastack) ── CT 113 kutt (Node)
│ DATABASE_URL
void-db CT 310 (PG 16, db=kutt)
Void (#/links) ── iframe ── themed Kutt UI
└── Void-native card ── /api/links proxy (void-app, holds Kutt API key) ── CT 113 Kutt REST API
└── update-tracker ── GitHub releases API + running version
```
## Components
### 1. Kutt LXC (CT 113 `kutt`)
- New unprivileged LXC on **Z**, static IP + router MAC reservation, **HA-tagged**,
replicated Z→Z3, AppArmor `unconfined` (host requirement). 2 GB / 2 cores / small disk.
- **Node 20+**; Kutt checked out at a **pinned release tag** (e.g. `v3.2.5`) under
`/opt/kutt`, user `kutt`. Install: `npm ci` → (build step if the release needs one)
`npm run migrate``npm start`, managed by **`kutt.service`** (systemd).
- Config via `/opt/kutt/.env` (mode 600): `DEFAULT_DOMAIN=link.hynesy.com`,
`DB_*`/`DATABASE_URL` → void-db, `DISABLE_REGISTRATION=true`, trust-proxy / forwarded
settings so Kutt knows its public origin, **no SMTP** (registration off, admin
pre-seeded), one admin account + an API key.
### 2. Database (shared void-db)
- On CT 310 (`192.168.1.215:5432`, PG 16): create database **`kutt`** owned by a new
**`kutt`** role (NOSUPERUSER, owns its DB + `public` schema so Kutt migrations run).
Kutt manages its own schema via `npm run migrate`. Rides void-db's existing HA +
backups; no impact on the `void` database.
### 3. Domain + CF Access (private → public)
- Traefik (mediastack `/docker/proxy/dynamic.yml`): router `link.hynesy.com` → CT 113
Kutt port. Wildcard `*.hynesy.com` DNS already targets the tunnel.
- **Phase 1 (private):** CF Access app over **all of** `link.hynesy.com` (Google IdP,
email allowlist) — admin *and* redirects private. (Embed then works via
`void.hynesy.com`, not the raw LAN IP — same CF-cookie rule as Timelapse/AI-Usage.)
- **Phase 2 (public, later, separate effort):** remove CF Access from redirects; for
per-link public/private add a public 2nd domain (e.g. `go.hynesy.com`, no CF Access)
and use Kutt's multi-domain to choose a link's domain.
### 4. Theming (blackflame, no fork)
- Kutt's **custom CSS** hook: a blackflame stylesheet (palette `--accent #ff4f2e` etc.,
Cinzel/Cormorant/JetBrains fonts, surfaces) applied to stock Kutt. Lives in
`Hynes/URLShortener-void-kutt` and is dropped into Kutt's custom/override dir at deploy. **No Kutt
source changes** → `npm`/release updates never conflict.
### 5. Void integration (the Hybrid)
- **Apps rail "Links"** (`#/links`, `public/views/links.js`): a Void header bar + a
Void-native card (below) + an `<iframe src="https://link.hynesy.com">` (themed Kutt).
Mirrors the Timelapse/AI-Usage embed views; added to the **Apps** sidebar section.
- **Void server proxy** (`lib/api/routes/links.js`, mount `/api/links`, owner-gated):
forwards to Kutt's REST API over the LAN (CT 113 IP:port) with the **Kutt API key**
held in void-app's `.env` (key never reaches the browser; LAN call works regardless
of CF Access). Endpoints needed: create link (quick-add) + recent links + version.
- **Void-native card:**
- **Update-tracker** — the proxy fetches `api.github.com/repos/thedevs-network/kutt/
releases/latest` (cached ~6 h) and the running Kutt version; the card shows the
version, an **"update available"** badge when they differ, and a changelog link.
(Bare-metal = manual updates, so this is the "what's new / time to update" signal.)
- **Quick-add** — paste a URL → `POST /api/links` → Kutt creates the short link → show
+ copy. Optional **QR** rendered Void-side (client lib) for any link.
### 6. Feature parity with Shlink
Kutt stays **stock**. Two non-forking lanes: (a) **now**, presentation-only wins like
**QR** in the Void card; (b) **later**, richer gaps (geo analytics, tags) as **merge
requests to Kutt upstream** — tracked as a *separate* project, **out of scope here**.
## Data flow
Create: Void quick-add → `/api/links` proxy (+API key) → Kutt → row in `kutt` DB → short
URL returned. Resolve: `link.hynesy.com/<slug>` → Kutt → 302 (CF-gated in Phase 1).
Update check: card → proxy → GitHub releases + running version → badge.
## Error handling
- Kutt down / proxy error → the card shows "Kutt unreachable" + the `↗ Open` fallback;
the embed shows Kutt's own error. Void itself unaffected.
- GitHub API rate-limited/unreachable → update-tracker shows "version check unavailable"
(cached last-known if any); never blocks the card.
- Missing/invalid Kutt API key → proxy returns a clear 502; quick-add disabled with a hint.
## Testing
- **vitest:** the `/api/links` proxy (mock Kutt API — create/list) and the update-tracker
comparison logic (mock GitHub `releases/latest` + running version → badge true/false);
the `links.js` view renders the card + iframe (jsdom).
- **Deploy smoke:** create a link via Kutt's API → `curl` the slug → 302 to target;
confirm `link.hynesy.com` is CF-gated (302 to cloudflareaccess) in Phase 1.
## Out of scope (separate efforts)
- Phase-2 public access + per-link public/private second domain.
- Upstream MRs for geo/tags parity.
- Redis cache; SMTP/registration; multi-user.
## Repos & docs (standing rule)
- **`Hynes/URLShortener-void-kutt`** (created, `gitea@192.168.1.223:Hynes/URLShortener-void-kutt.git`): blackflame theme CSS, the
LXC create/bootstrap + `kutt.service`, `.env.example`, deploy notes.
- **`Hynes/Void-Homelab`** (void-v2): the `links.js` view + `/api/links` proxy + Apps
rail entry + CHANGELOG.
- **Wiki:** new "Kutt / Link Shortener LXC (113)" page under Hosts & Services.

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 aiUsageRouter } from './routes/ai_usage.js';
import { router as infraRouter } from './routes/infra.js'; import { router as infraRouter } from './routes/infra.js';
import { router as clusterRouter } from './routes/cluster.js'; import { router as clusterRouter } from './routes/cluster.js';
import { router as storageRouter } from './routes/storage.js';
import { router as kuttRouter } from './routes/kutt.js';
export function mountApi(app) { export function mountApi(app) {
const api = Router(); const api = Router();
@@ -49,6 +51,7 @@ export function mountApi(app) {
api.use('/actions', actionsRouter); api.use('/actions', actionsRouter);
api.use('/infra', infraRouter); api.use('/infra', infraRouter);
api.use('/cluster', clusterRouter); api.use('/cluster', clusterRouter);
api.use('/storage', storageRouter);
api.use('/little-blue', littleblueRouter); api.use('/little-blue', littleblueRouter);
api.use('/ai-usage', aiUsageRouter); api.use('/ai-usage', aiUsageRouter);
api.use('/projects', projectsRouter); api.use('/projects', projectsRouter);
@@ -65,6 +68,7 @@ export function mountApi(app) {
api.use('/conversations/:conversation_id/messages', messagesByConvRouter); api.use('/conversations/:conversation_id/messages', messagesByConvRouter);
api.use('/tags', tagsRouter); api.use('/tags', tagsRouter);
api.use('/links', linksRouter); api.use('/links', linksRouter);
api.use('/kutt', kuttRouter);
api.use('/pending-changes', pendingChangesRouter); api.use('/pending-changes', pendingChangesRouter);
api.use('/audit', auditRouter); api.use('/audit', auditRouter);
api.use('/search', searchRouter); api.use('/search', searchRouter);

View File

@@ -4,32 +4,10 @@ import { asyncWrap, errorMiddleware } from '../errors.js';
import { requireOwner } from '../cap.js'; import { requireOwner } from '../cap.js';
import { validate } from '../validate.js'; import { validate } from '../validate.js';
import * as devices from '../../db/repos/lan_devices.js'; import * as devices from '../../db/repos/lan_devices.js';
import * as agents from '../../db/repos/agents.js'; import { isRandomizedMac } from '../../infra/scan.js';
import { timingSafeStrEqual } from '../../auth/safe_compare.js'; import { softAuth } from '../soft_auth.js';
import { accessOwnerEmail } from '../../auth/cf_access.js';
export const router = Router(); 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']; const GROUP_ORDER = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
router.use(softAuth); router.use(softAuth);
@@ -52,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) }); 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({ const patchBody = z.object({
name: z.string().max(120).optional(), name: z.string().max(120).optional(),
grp: z.enum(['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']).optional(), grp: z.enum(['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']).optional(),
status: z.enum(['new', 'known', 'ignored']).optional(), status: z.enum(['new', 'known', 'ignored']).optional(),
note: z.string().max(500).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". // PATCH /devices/:mac — name / edit / promote (owner). This is "add from discovered".
@@ -67,6 +47,20 @@ router.patch('/:mac', requireOwner, validate({ params: macParam, body: patchBody
res.json(updated); res.json(updated);
})); }));
const addBody = z.object({
mac: z.string().regex(/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i),
ip: z.string().regex(/^\d{1,3}(\.\d{1,3}){3}$/).optional(),
name: z.string().max(120).optional(),
grp: z.enum(['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']).optional(),
vendor: z.string().max(120).optional()
});
// POST /devices — manually add a device by MAC (e.g. an offline device) (owner).
router.post('/', requireOwner, validate({ body: addBody }), asyncWrap(async (req, res) => {
const mac = req.body.mac.toLowerCase();
res.status(201).json(await devices.addManual({ ...req.body, mac, randomized: isRandomizedMac(mac) }));
}));
// DELETE /devices/:mac (owner). // DELETE /devices/:mac (owner).
router.delete('/:mac', requireOwner, validate({ params: macParam }), asyncWrap(async (req, res) => { router.delete('/:mac', requireOwner, validate({ params: macParam }), asyncWrap(async (req, res) => {
if (!(await devices.remove(req.params.mac.toLowerCase()))) return res.status(404).json({ error: { code: 'not_found' } }); if (!(await devices.remove(req.params.mac.toLowerCase()))) return res.status(404).json({ error: { code: 'not_found' } });

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);

37
lib/api/routes/kutt.js Normal file
View File

@@ -0,0 +1,37 @@
import { Router } from 'express';
import { z } from 'zod';
import { asyncWrap } from '../errors.js';
import { requireOwner } from '../cap.js';
import { validate } from '../validate.js';
import { compareVersions, fetchLatestKuttRelease, createLink, recentLinks } from '../../links/kutt.js';
export const router = Router();
const cfg = () => ({ base: process.env.KUTT_API_URL, key: process.env.KUTT_API_KEY });
// GET /kutt/version — running (pinned env) vs latest GitHub release (cached 6h).
let cache = { at: 0, val: null };
router.get('/version', requireOwner, asyncWrap(async (_req, res) => {
const running = process.env.KUTT_VERSION || 'unknown';
if (Date.now() - cache.at > 6 * 3600e3 || !cache.val) {
try { cache = { at: Date.now(), val: await fetchLatestKuttRelease({}) }; }
catch { return res.json({ running, latest: null, updateAvailable: false, error: 'version check unavailable' }); }
}
res.json({ ...compareVersions(running, cache.val.latest), url: cache.val.url });
}));
const linkBody = z.object({
target: z.string().url(),
customurl: z.string().max(64).optional(),
description: z.string().max(200).optional()
});
// POST /kutt — create via Kutt (owner). Key stays server-side.
router.post('/', requireOwner, validate({ body: linkBody }), asyncWrap(async (req, res) => {
if (!process.env.KUTT_API_KEY) return res.status(502).json({ error: { code: 'kutt_unconfigured' } });
res.status(201).json(await createLink(req.body, cfg()));
}));
// GET /kutt/recent — last few links (owner).
router.get('/recent', requireOwner, asyncWrap(async (_req, res) => {
res.json(await recentLinks(cfg()));
}));

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

@@ -1,6 +1,6 @@
import { pool } from '../pool.js'; 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() { export async function listKnown() {
const { rows } = await pool.query( const { rows } = await pool.query(
@@ -19,6 +19,22 @@ export async function get(mac) {
return r || null; return r || null;
} }
// Manually add a device by MAC (e.g. an offline device whose MAC you know). Lands
// as status='known', present=false. Idempotent — re-adding updates name/grp/vendor.
export async function addManual({ mac, ip = null, name = null, grp = 'Flagged', vendor = null, randomized = false }) {
const { rows: [r] } = await pool.query(
`INSERT INTO lan_devices (mac, ip, name, grp, vendor, randomized, status, present, first_seen, last_seen)
VALUES ($1,$2,$3,$4,$5,$6,'known',false,now(),now())
ON CONFLICT (mac) DO UPDATE SET
ip = COALESCE(NULLIF(EXCLUDED.ip,''), lan_devices.ip),
name = EXCLUDED.name, grp = EXCLUDED.grp,
vendor = COALESCE(NULLIF(EXCLUDED.vendor,''), lan_devices.vendor),
status = 'known'
RETURNING ${COLS}`,
[mac, ip, name, grp, vendor, !!randomized]);
return r;
}
// Insert unseen MACs as status='new'; for existing, refresh ip/vendor/last_seen/present // Insert unseen MACs as status='new'; for existing, refresh ip/vendor/last_seen/present
// WITHOUT touching owner-curated name/grp/status/flagged. // WITHOUT touching owner-curated name/grp/status/flagged.
export async function upsertScan(rows) { export async function upsertScan(rows) {
@@ -54,7 +70,7 @@ export async function prune() {
return rowCount; return rowCount;
} }
const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged']; const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged', 'icon'];
export async function update(mac, patch) { export async function update(mac, patch) {
const sets = [], vals = []; const sets = [], vals = [];
for (const k of PATCHABLE) { 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 });
}

31
lib/links/kutt.js Normal file
View File

@@ -0,0 +1,31 @@
// Thin client for stock Kutt's REST API + release-version compare. fetch injected
// for tests; defaults to global fetch (Node 22). No Kutt source coupling.
const norm = v => String(v || '').replace(/^v/, '');
export function compareVersions(running, latest) {
return { running, latest, updateAvailable: norm(running) !== '' && norm(latest) !== '' && norm(running) !== norm(latest) };
}
export async function fetchLatestKuttRelease({ fetch = globalThis.fetch } = {}) {
const res = await fetch('https://api.github.com/repos/thedevs-network/kutt/releases/latest',
{ headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'void' } });
if (!res.ok) throw new Error(`github ${res.status}`);
const j = await res.json();
return { latest: j.tag_name, url: j.html_url };
}
export async function createLink(body, { base, key, fetch = globalThis.fetch }) {
const res = await fetch(`${base}/api/v2/links`, {
method: 'POST',
headers: { 'X-API-KEY': key, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`kutt ${res.status}`);
return res.json();
}
export async function recentLinks({ base, key, fetch = globalThis.fetch, limit = 5 }) {
const res = await fetch(`${base}/api/v2/links?limit=${limit}`, { headers: { 'X-API-KEY': key } });
if (!res.ok) throw new Error(`kutt ${res.status}`);
return res.json();
}

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", "name": "void-server",
"version": "2.0.0-alpha.16", "version": "2.5.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "void-server", "name": "void-server",
"version": "2.0.0-alpha.16", "version": "2.5.2",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"adm-zip": "^0.5.17",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"dompurify": "^3.4.7", "dompurify": "^3.4.7",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
@@ -965,6 +966,15 @@
"node": ">= 0.6" "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": { "node_modules/ajv": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",

View File

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

View File

@@ -11,11 +11,14 @@ function token() { return localStorage.getItem(TOKEN_KEY) || ''; }
async function call(method, path, body) { async function call(method, path, body) {
const headers = { 'Authorization': 'Bearer ' + token() }; 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, { const res = await fetch(path, {
method, method,
headers, 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 === 401) { await promptForToken(); return call(method, path, body); }
if (res.status === 204) return null; if (res.status === 204) return null;
@@ -61,11 +64,14 @@ function promptForToken() {
} }
export const api = { export const api = {
get: (p) => call('GET', p), get: (p) => call('GET', p),
post: (p, body) => call('POST', p, body ?? {}), post: (p, body) => call('POST', p, body ?? {}),
put: (p, body) => call('PUT', p, body ?? {}), put: (p, body) => call('PUT', p, body ?? {}),
patch: (p, body) => call('PATCH', p, body ?? {}), patch: (p, body) => call('PATCH', p, body ?? {}),
del: (p) => call('DELETE', p), 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), setToken: (v) => localStorage.setItem(TOKEN_KEY, v),
hasToken: () => !!token() hasToken: () => !!token()
}; };

View File

@@ -28,6 +28,8 @@ const VIEWS = {
timelapse: () => import('./views/timelapse.js'), timelapse: () => import('./views/timelapse.js'),
'ai-usage': () => import('./views/aiusage.js'), 'ai-usage': () => import('./views/aiusage.js'),
obd2: () => import('./views/obd2.js'), obd2: () => import('./views/obd2.js'),
links: () => import('./views/links.js'),
mirror: () => import('./views/mirror.js'),
settings: () => import('./views/settings.js'), settings: () => import('./views/settings.js'),
jobs: () => import('./views/jobs.js') jobs: () => import('./views/jobs.js')
}; };

View File

@@ -132,7 +132,9 @@ export function renderSidebar(root) {
el('div', { class: 'sb-title' }, 'Apps'), el('div', { class: 'sb-title' }, 'Apps'),
navItem('Timelapse', '/timelapse'), navItem('Timelapse', '/timelapse'),
navItem('AI Usage', '/ai-usage'), navItem('AI Usage', '/ai-usage'),
navItem('OBD2', '/obd2') navItem('OBD2', '/obd2'),
navItem('Links', '/links'),
navItem('MagicMirror', '/mirror')
) )
); );

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

@@ -29,6 +29,8 @@ const ROUTES = [
{ name: 'timelapse', re: /^\/timelapse$/, keys: [] }, { name: 'timelapse', re: /^\/timelapse$/, keys: [] },
{ name: 'ai-usage', re: /^\/ai-usage$/, keys: [] }, { name: 'ai-usage', re: /^\/ai-usage$/, keys: [] },
{ name: 'obd2', re: /^\/obd2$/, keys: [] }, { name: 'obd2', re: /^\/obd2$/, keys: [] },
{ name: 'links', re: /^\/links$/, keys: [] },
{ name: 'mirror', re: /^\/mirror$/, keys: [] },
{ name: 'settings', re: /^\/settings$/, keys: [] }, { name: 'settings', re: /^\/settings$/, keys: [] },
{ name: 'jobs', re: /^\/jobs$/, keys: [] }, { name: 'jobs', re: /^\/jobs$/, keys: [] },
{ name: 'home', re: /^\/?$/, keys: [] } { name: 'home', re: /^\/?$/, keys: [] }

View File

@@ -563,6 +563,23 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
.dv-tile .dv-nm { font-family: var(--font-ui); font-size: 13px; color: var(--text); } .dv-tile .dv-nm { font-family: var(--font-ui); font-size: 13px; color: var(--text); }
.dv-tile .dv-ip { font-family: var(--font-mono); font-size: 12px; color: var(--muted); } .dv-tile .dv-ip { font-family: var(--font-mono); font-size: 12px; color: var(--muted); }
.dv-tile .dv-mac { font-family: var(--font-mono); font-size: 10px; color: var(--muted); opacity: .6; letter-spacing: .02em; } .dv-tile .dv-mac { font-family: var(--font-mono); font-size: 10px; color: var(--muted); opacity: .6; letter-spacing: .02em; }
.lk-card { max-width: 760px; }
.lk-row { display: flex; gap: 12px; align-items: center; margin-bottom: 10px; }
.lk-update a { color: var(--accent); }
.lk-quickadd { display: flex; gap: 8px; }
.lk-quickadd .lk-url { flex: 1; }
.lk-out { display: block; margin-top: 8px; font-family: var(--font-mono); font-size: 13px; }
.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; }
.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; }
.dv-addtoggle { margin-left: auto; font-size: 11px; padding: 2px 8px; white-space: nowrap; }
.dv-scanbtn { font-size: 11px; padding: 2px 8px; white-space: nowrap; margin-left: 6px; }
.dv-scanbtn:disabled { opacity: .6; cursor: default; }
.dv-addform { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 8px 0; padding: 8px 10px; border: 1px solid var(--accent-dim); border-radius: 6px; background: var(--accent-soft); }
.dv-addform .dv-edit-name { flex: 1 1 9rem; }
.dv-tile .dv-vendor { font-family: var(--font-ui); font-size: 11px; color: var(--muted); opacity: .7; } .dv-tile .dv-vendor { font-family: var(--font-ui); font-size: 11px; color: var(--muted); opacity: .7; }
.dv-tile.flag { border-color: var(--bad); background: #1a1012; } .dv-tile.flag { border-color: var(--bad); background: #1a1012; }
.dv-tile.flag .dv-nm { color: var(--bad); } .dv-tile.flag .dv-nm { color: var(--bad); }
@@ -618,3 +635,26 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
.sv-tray-chip:hover { border-color: var(--accent); color: var(--accent); } .sv-tray-chip:hover { border-color: var(--accent); color: var(--accent); }
.hidden { display: none !important; } .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,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,17 +3,65 @@
// newly-seen devices. Kept SEPARATE from Little Blue's homelab-service band. // newly-seen devices. Kept SEPARATE from Little Blue's homelab-service band.
import { el, mount, clear } from '../dom.js'; import { el, mount, clear } from '../dom.js';
import { api } from '../api.js'; import { api } from '../api.js';
import { resolveIcon, relativeTime, autoDefaultIcon } from './icon_util.js';
import { iconPicker } from './icon_picker.js';
let host; let host;
const GROUPS = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']; const GROUPS = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
function tile(d) { function tile(d) {
return el('div', { class: 'dv-tile' + (d.flagged ? ' flag' : '') + (d.present === false ? ' absent' : '') }, const t = el('div', { class: 'dv-tile' + (d.flagged ? ' flag' : '') + (d.present === false ? ' absent' : '') });
el('span', { class: 'dv-nm' }, d.name || 'Unknown'), function view() {
el('span', { class: 'dv-ip' }, d.ip || ''), clear(t);
d.mac ? el('span', { class: 'dv-mac' }, d.mac) : null, const edit = el('button', { class: 'dv-edit-btn', title: 'Edit device' }, '✎');
el('span', { class: 'dv-vendor' }, edit.onclick = editMode;
(d.vendor || '') + (d.randomized ? ' · randomized' : '') + (d.present === false ? ' · absent' : ''))); 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, icon: chosenIcon });
load();
};
const del = el('button', { class: 'ghost dv-ignore' }, 'Delete');
del.onclick = async () => { await api.del('/api/devices/' + d.mac); load(); };
const cancel = el('button', { class: 'ghost' }, 'Cancel');
cancel.onclick = view;
mount(t, el('span', { class: 'dv-mac' }, d.mac), nameI, grpS, iconBtn, save, del, cancel, pickerWrap);
}
view();
return t;
} }
function discoveredRow(d, onDone) { function discoveredRow(d, onDone) {
@@ -33,6 +81,30 @@ function discoveredRow(d, onDone) {
nameI, grpS, add, ignore); nameI, grpS, add, ignore);
} }
// Manual add form — for offline devices (MAC required; IP optional). The MAC
// field auto-inserts the colons as you type.
function manualAddForm() {
const macI = el('input', { class: 'dv-edit-name', placeholder: 'aa:bb:cc:dd:ee:ff' });
macI.oninput = () => {
const v = macI.value.replace(/[^0-9a-fA-F]/g, '').slice(0, 12).toLowerCase();
macI.value = v.match(/.{1,2}/g)?.join(':') ?? v;
};
const ipI = el('input', { class: 'dv-edit-name', placeholder: 'IP (optional)' });
const nameI = el('input', { class: 'dv-edit-name', placeholder: 'name (optional)' });
const grpS = el('select', { class: 'dv-edit-grp' }, ...GROUPS.map(g => el('option', { value: g }, g)));
const err = el('span', { class: 'muted', style: { fontSize: '11px' } }, '');
const add = el('button', { class: 'dv-add' }, 'Add');
add.onclick = async () => {
const mac = macI.value.trim().toLowerCase();
if (!/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/.test(mac)) { err.textContent = 'MAC must look like aa:bb:cc:dd:ee:ff'; return; }
const ip = ipI.value.trim();
if (ip && !/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) { err.textContent = 'IP must look like 192.168.1.x'; return; }
try { await api.post('/api/devices', { mac, ip: ip || undefined, name: nameI.value.trim() || undefined, grp: grpS.value }); load(); }
catch { err.textContent = 'add failed'; }
};
return el('div', { class: 'dv-addform' }, macI, ipI, nameI, grpS, add, err);
}
async function load() { async function load() {
if (!host) return; if (!host) return;
let data, discovered = []; let data, discovered = [];
@@ -54,11 +126,24 @@ async function load() {
...discovered.map(d => discoveredRow(d, load))) ...discovered.map(d => discoveredRow(d, load)))
: null; : null;
const addForm = manualAddForm();
addForm.style.display = 'none';
const addToggle = el('button', { class: 'ghost dv-addtoggle' }, '+ Manual Add');
addToggle.onclick = () => { addForm.style.display = addForm.style.display === 'none' ? 'flex' : 'none'; };
const scanBtn = el('button', { class: 'ghost dv-scanbtn' }, 'Scan Now');
scanBtn.onclick = async () => {
scanBtn.textContent = 'Scanning…'; scanBtn.disabled = true;
try { await api.post('/api/devices/scan'); } catch { /* ignore */ }
load();
};
clear(host); clear(host);
mount(host, mount(host,
el('div', { class: 'dv-hd' }, el('div', { class: 'dv-hd' },
el('div', { class: 'dv-title' }, 'Network · Devices'), el('div', { class: 'dv-title' }, 'Network · Devices'),
el('span', { class: 'dv-count' }, `${total} known${discovered.length ? ` · ${discovered.length} new` : ''}`)), el('span', { class: 'dv-count' }, `${total} known${discovered.length ? ` · ${discovered.length} new` : ''}`),
addToggle, scanBtn),
addForm,
...sections, ...sections,
discPanel); discPanel);
} }

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`;
}

39
public/views/links.js Normal file
View File

@@ -0,0 +1,39 @@
// #/links — Hybrid Apps view: a Void-native card (update-tracker + quick-add) on
// top of the embedded themed Kutt UI. Reuses the .term-bar/.term-frame embed classes.
import { el, mount } from '../dom.js';
import { api } from '../api.js';
const SRC = 'https://link.hynesy.com/';
export async function render(main) {
const badge = el('span', { class: 'lk-badge muted' }, 'checking…');
const out = el('span', { class: 'lk-out muted' }, '');
const input = el('input', { class: 'lk-url', placeholder: 'https://long-url-to-shorten…' });
const add = el('button', { class: 'primary' }, '◆ Shorten');
add.onclick = async () => {
const target = input.value.trim(); if (!target) return;
out.textContent = 'creating…';
try { const r = await api.post('/api/kutt', { target }); out.innerHTML = ''; out.appendChild(el('a', { href: r.link, target: '_blank', rel: 'noopener' }, r.link)); input.value = ''; }
catch { out.textContent = 'failed (is Kutt reachable / API key set?)'; }
};
mount(main,
el('div', { class: 'term-bar' },
el('span', { class: 'term-title' }, '◆ Links'),
el('a', { class: 'ghost', style: { marginLeft: 'auto' }, href: SRC, target: '_blank', rel: 'noopener' }, '↗ Open Kutt')
),
el('div', { class: 'card lk-card' },
el('div', { class: 'lk-row' }, el('span', { class: 'muted' }, 'Kutt version'), badge),
el('div', { class: 'lk-quickadd' }, input, add),
el('div', {}, out)
),
el('iframe', { id: 'embed-frame', src: SRC, class: 'term-frame' })
);
try {
const v = await api.get('/api/kutt/version');
badge.classList.remove('muted');
if (v.updateAvailable) { badge.classList.add('lk-update'); badge.innerHTML = ''; badge.appendChild(el('a', { href: v.url, target: '_blank', rel: 'noopener' }, `${v.running}${v.latest} · update available`)); }
else badge.textContent = `${v.running} · up to date`;
} catch { badge.textContent = 'version check unavailable'; }
}

6
public/views/mirror.js Normal file
View File

@@ -0,0 +1,6 @@
// public/views/mirror.js — #/mirror (MagicMirror² on CT 111)
import { embedView } from './embed.js';
export const render = embedView({
title: 'MagicMirror', sub: 'smart mirror dashboard',
src: 'https://mirror.hynesy.com/'
});

View File

@@ -14,8 +14,9 @@ import search from './cards/search.js';
import speedtest from './cards/speedtest.js'; import speedtest from './cards/speedtest.js';
import aiUsage from './cards/ai_usage.js'; import aiUsage from './cards/ai_usage.js';
import cluster from './cards/cluster.js'; import cluster from './cards/cluster.js';
import storage from './cards/storage.js';
const CARD_MODULES = [clock, weather, hostPerf, cluster, jobs, inbox, search, speedtest, aiUsage]; const CARD_MODULES = [clock, weather, hostPerf, cluster, storage, jobs, inbox, search, speedtest, aiUsage];
const BY_ID = new Map(CARD_MODULES.map(d => [d.id, d])); const BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
let active = []; // mounted cards needing stop() let active = []; // mounted cards needing stop()

View File

@@ -1,6 +1,7 @@
// #/settings — API tokens, agents, and a placeholder for Orthos Mode. // #/settings — API tokens, agents, and a placeholder for Orthos Mode.
import { el, mount } from '../dom.js'; import { el, mount } from '../dom.js';
import { api } from '../api.js'; import { api } from '../api.js';
import { iconSetsPanel } from './icon_sets_panel.js';
function section(title, sub, bodyEl) { function section(title, sub, bodyEl) {
return el('div', { class: 'card settings-card' }, return el('div', { class: 'card settings-card' },
@@ -99,10 +100,29 @@ async function renderAgents(c) {
export async function render(main) { export async function render(main) {
const tokensBody = el('div', { class: 'settings-body' }); const tokensBody = el('div', { class: 'settings-body' });
const agentsBody = 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, mount(main,
el('h1', { class: 'view-h1' }, '◆ Settings'), 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('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('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.', 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).')) el('div', { class: 'muted' }, 'Paused for a future project (arrives with the local-agent layer).'))
); );

View File

@@ -7,6 +7,7 @@ import * as queue from './lib/jobs/queue.js';
import { registerWorkers } from './lib/jobs/index.js'; import { registerWorkers } from './lib/jobs/index.js';
import { router as ingestRouter } from './lib/api/routes/ingest.js'; import { router as ingestRouter } from './lib/api/routes/ingest.js';
import { router as iconsRouter } from './lib/api/routes/icons.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 { router as devicesRouter } from './lib/api/routes/devices.js';
import { startCron } from './lib/cron/index.js'; import { startCron } from './lib/cron/index.js';
import { seedFromConfig } from './lib/health/registry.js'; import { seedFromConfig } from './lib/health/registry.js';
@@ -14,7 +15,7 @@ import { mcpAuth } from './lib/api/middleware/mcp_auth.js';
import { handleMcp } from './lib/mcp/http.js'; import { handleMcp } from './lib/mcp/http.js';
import httpProxy from 'http-proxy'; import httpProxy from 'http-proxy';
const VERSION = '2.1.1'; const VERSION = '2.5.2';
// Proxy /terminal (+ its WebSocket) to ttyd on CT 300, so the embedded terminal // 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 // works whether the Void is reached via Traefik (void2-app.hynesy.com) OR the
@@ -53,6 +54,10 @@ export function createApp() {
// slugs are sanitized to [a-z0-9-] to prevent path traversal. // slugs are sanitized to [a-z0-9-] to prevent path traversal.
app.use('/api/icons', iconsRouter); 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); // /api/devices — band data is public (like the static devices.json it replaces);
// discovered/edit/scan sub-routes use requireOwner (401/403) internally. // discovered/edit/scan sub-routes use requireOwner (401/403) internally.
app.use('/api/devices', devicesRouter); app.use('/api/devices', devicesRouter);

View File

@@ -38,4 +38,21 @@ describe('/api/devices', () => {
it('PATCH rejects a bad MAC', async () => { it('PATCH rejects a bad MAC', async () => {
expect((await owner(request(app).patch('/api/devices/not-a-mac')).send({ name: 'x' })).status).toBe(400); expect((await owner(request(app).patch('/api/devices/not-a-mac')).send({ name: 'x' })).status).toBe(400);
}); });
it('POST / manually adds an offline device by MAC (owner, lowercased, status=known, absent)', async () => {
expect((await request(app).post('/api/devices').send({ mac: 'aa:bb:cc:dd:ee:ff' })).status).toBe(401);
const res = await owner(request(app).post('/api/devices')).send({ mac: 'AA:BB:CC:DD:EE:FF', ip: '192.168.1.77', name: 'Garage door', grp: 'Smart Home' });
expect(res.status).toBe(201);
expect(res.body.mac).toBe('aa:bb:cc:dd:ee:ff');
expect(res.body.ip).toBe('192.168.1.77');
expect(res.body.status).toBe('known');
expect(res.body.present).toBe(false);
const band = await request(app).get('/api/devices');
expect(band.body.groups.find(g => g.name === 'Smart Home').devices.some(d => d.name === 'Garage door')).toBe(true);
});
it('POST / rejects a bad MAC and a bad IP', async () => {
expect((await owner(request(app).post('/api/devices')).send({ mac: 'nope' })).status).toBe(400);
expect((await owner(request(app).post('/api/devices')).send({ mac: 'aa:bb:cc:dd:ee:ff', ip: 'not-an-ip' })).status).toBe(400);
});
}); });

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');
});
});

40
tests/api/kutt.test.js Normal file
View File

@@ -0,0 +1,40 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import request from 'supertest';
vi.mock('../../lib/links/kutt.js', () => ({
compareVersions: (r, l) => ({ running: r, latest: l, updateAvailable: r !== l }),
fetchLatestKuttRelease: async () => ({ latest: 'v9.9.9', url: 'https://x' }),
createLink: async (b) => ({ link: 'https://link.hynesy.com/abc', address: 'abc', target: b.target }),
recentLinks: async () => ({ data: [] })
}));
let app;
const owner = r => r.set('Authorization', 'Bearer test-token');
beforeAll(async () => {
process.env.OWNER_TOKEN = 'test-token';
process.env.KUTT_API_URL = 'http://10.0.0.1:3000';
process.env.KUTT_API_KEY = 'K';
process.env.KUTT_VERSION = 'v3.2.5';
({ createApp } = await import('../../server.js'));
app = createApp();
});
let createApp;
describe('/api/kutt', () => {
it('GET /version returns running/latest/updateAvailable (owner)', async () => {
expect((await request(app).get('/api/kutt/version')).status).toBe(401);
const res = await owner(request(app).get('/api/kutt/version'));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ running: 'v3.2.5', latest: 'v9.9.9', updateAvailable: true });
});
it('POST / creates a link via Kutt (owner)', async () => {
const res = await owner(request(app).post('/api/kutt')).send({ target: 'https://example.com' });
expect(res.status).toBe(201);
expect(res.body.link).toBe('https://link.hynesy.com/abc');
});
it('POST / rejects a non-URL target', async () => {
expect((await owner(request(app).post('/api/kutt')).send({ target: 'not a url' })).status).toBe(400);
});
});

View File

@@ -6,15 +6,19 @@ vi.mock('../../public/api.js', () => ({
api: { api: {
get: vi.fn(async (p) => { get: vi.fn(async (p) => {
if (p === '/api/devices') return { groups: [ { name: 'Network', devices: [ if (p === '/api/devices') return { groups: [ { name: 'Network', devices: [
{ mac: 'bc:a5:11:3e:06:88', ip: '192.168.1.13', name: 'Orbi Satellite', vendor: 'Netgear', randomized: false, present: true } ] } ] }; { mac: 'bc:a5:11:3e:06:88', ip: '192.168.1.13', name: 'Orbi Satellite', grp: 'Network', vendor: 'Netgear', randomized: false, present: true } ] } ] };
if (p === '/api/devices/discovered') return [ if (p === '/api/devices/discovered') return [
{ mac: '24:4b:fe:8e:09:a4', ip: '192.168.1.15', vendor: 'ASUSTek', randomized: false, present: true } ]; { mac: '24:4b:fe:8e:09:a4', ip: '192.168.1.15', vendor: 'ASUSTek', randomized: false, present: true } ];
return {}; return {};
}), }),
patch: vi.fn(async () => ({})) patch: vi.fn(async () => ({})),
post: vi.fn(async () => ({})),
del: vi.fn(async () => ({}))
} }
})); }));
import { api } from '../../public/api.js';
let renderDevicesBand; let renderDevicesBand;
beforeAll(async () => { beforeAll(async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="h"></div></body></html>', { url: 'http://localhost/' }); const dom = new JSDOM('<!doctype html><html><body><div id="h"></div></body></html>', { url: 'http://localhost/' });
@@ -33,4 +37,44 @@ describe('devices band', () => {
expect(host.querySelector('.dv-discovered')).not.toBeNull(); // review affordance present expect(host.querySelector('.dv-discovered')).not.toBeNull(); // review affordance present
expect(host.textContent).toMatch(/Discovered/i); expect(host.textContent).toMatch(/Discovered/i);
}); });
it('lets you edit a known device (✎ → name/group → Save patches)', async () => {
const host = document.getElementById('h');
await renderDevicesBand(host);
await new Promise(r => setTimeout(r, 0));
const t = host.querySelector('.dv-tile');
t.querySelector('.dv-edit-btn').click();
const nameI = t.querySelector('.dv-edit-name');
expect(nameI.value).toBe('Orbi Satellite');
nameI.value = 'Orbi RBS50';
t.querySelector('.dv-add').click(); // Save
await new Promise(r => setTimeout(r, 0));
expect(api.patch).toHaveBeenCalledWith('/api/devices/bc:a5:11:3e:06:88',
expect.objectContaining({ name: 'Orbi RBS50', grp: 'Network' }));
});
it('Manual Add reveals a form (with IP) and POSTs the new device; MAC field auto-inserts colons', async () => {
const host = document.getElementById('h');
await renderDevicesBand(host);
await new Promise(r => setTimeout(r, 0));
expect(host.querySelector('.dv-addtoggle').textContent).toBe('+ Manual Add');
host.querySelector('.dv-addtoggle').click(); // reveal the form
const [macI, ipI] = host.querySelectorAll('.dv-addform .dv-edit-name');
macI.value = 'aabbccddeeff';
macI.dispatchEvent(new window.Event('input')); // colon-mask
expect(macI.value).toBe('aa:bb:cc:dd:ee:ff');
ipI.value = '192.168.1.50';
host.querySelector('.dv-addform .dv-add').click();
await new Promise(r => setTimeout(r, 0));
expect(api.post).toHaveBeenCalledWith('/api/devices', expect.objectContaining({ mac: 'aa:bb:cc:dd:ee:ff', ip: '192.168.1.50' }));
});
it('Scan Now triggers the scheduled scan', async () => {
const host = document.getElementById('h');
await renderDevicesBand(host);
await new Promise(r => setTimeout(r, 0));
host.querySelector('.dv-scanbtn').click();
await new Promise(r => setTimeout(r, 0));
expect(api.post).toHaveBeenCalledWith('/api/devices/scan');
});
}); });

View File

@@ -0,0 +1,28 @@
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { JSDOM } from 'jsdom';
vi.mock('../../public/api.js', () => ({ api: {
get: vi.fn(async (p) => p.endsWith('/version')
? { running: 'v3.2.5', latest: 'v3.2.6', updateAvailable: true, url: 'https://x' }
: { data: [] }),
post: vi.fn(async () => ({ link: 'https://link.hynesy.com/abc' }))
} }));
let render;
beforeAll(async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="main"></div></body></html>', { url: 'http://localhost/' });
global.window = dom.window; global.document = dom.window.document; global.Node = dom.window.Node;
({ render } = await import('../../public/views/links.js'));
});
afterAll(() => { delete global.window; delete global.document; delete global.Node; });
describe('links view', () => {
it('renders the update badge + quick-add + the Kutt iframe', async () => {
const main = document.getElementById('main');
await render(main);
await new Promise(r => setTimeout(r, 0));
expect(main.querySelector('iframe.term-frame').getAttribute('src')).toBe('https://link.hynesy.com/');
expect(main.textContent).toMatch(/update available/i);
expect(main.querySelector('.lk-quickadd')).not.toBeNull();
});
});

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();
});
});

23
tests/links/kutt.test.js Normal file
View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { compareVersions, fetchLatestKuttRelease, createLink } from '../../lib/links/kutt.js';
describe('kutt helpers', () => {
it('compareVersions flags an available update (tolerates v-prefix)', () => {
expect(compareVersions('v3.2.5', 'v3.2.6')).toEqual({ running: 'v3.2.5', latest: 'v3.2.6', updateAvailable: true });
expect(compareVersions('3.2.6', 'v3.2.6')).toMatchObject({ updateAvailable: false });
});
it('fetchLatestKuttRelease returns tag + url from the GitHub API (injected fetch)', async () => {
const fakeFetch = async () => ({ ok: true, json: async () => ({ tag_name: 'v3.2.6', html_url: 'https://x/releases/v3.2.6' }) });
expect(await fetchLatestKuttRelease({ fetch: fakeFetch })).toEqual({ latest: 'v3.2.6', url: 'https://x/releases/v3.2.6' });
});
it('createLink POSTs to the Kutt API with the key and returns the short link', async () => {
let seen;
const fakeFetch = async (url, opts) => { seen = { url, opts }; return { ok: true, json: async () => ({ link: 'https://link.hynesy.com/abc', address: 'abc' }) }; };
const r = await createLink({ target: 'https://example.com' }, { base: 'http://10.0.0.1:3000', key: 'K', fetch: fakeFetch });
expect(seen.url).toBe('http://10.0.0.1:3000/api/v2/links');
expect(seen.opts.headers['X-API-KEY']).toBe('K');
expect(r.link).toBe('https://link.hynesy.com/abc');
});
});

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');
});
});