Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b960ec52b | ||
|
|
91a45b4b6c | ||
|
|
95fa0c1828 | ||
|
|
318492a078 | ||
|
|
cd5ca03d96 | ||
|
|
c8b9dddd61 | ||
|
|
8f7331129f | ||
|
|
b783c031b0 | ||
|
|
26463b5eb6 | ||
|
|
88ef5786ee | ||
|
|
7a5fd88c07 | ||
|
|
2284a88bd2 | ||
|
|
607b76ff82 | ||
|
|
555a4c652c | ||
|
|
a042cbaaa5 | ||
|
|
ca186d41ba | ||
|
|
5f1b789250 | ||
|
|
056e6a099b | ||
|
|
0fe25d96ec | ||
|
|
e9c1fb17ac | ||
|
|
2ca2adc485 | ||
|
|
0083e80dc7 | ||
|
|
e3b482624d | ||
|
|
26eeb2c100 | ||
|
|
b9b94c9777 | ||
|
|
d513ca8fa4 | ||
|
|
0866459b23 | ||
|
|
d69d605108 |
28
AGENTS.md
Normal file
28
AGENTS.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Agent working agreement — Void / homelab
|
||||
|
||||
## Documentation policy (standing rule — do not skip)
|
||||
|
||||
**Every change, decision, fix, or incident must end up documented in BOTH places before the work is considered done:**
|
||||
|
||||
1. **The Void wiki** — the `wiki` space served by the Void. Update the relevant existing page, or create one. This is the human-facing source of truth and what `infra_audit` reads.
|
||||
2. **Git** — the code plus an appropriate written artifact (a spec/plan under `docs/superpowers/`, a `CHANGELOG.md` entry, and/or a doc page), committed and **pushed** to Gitea.
|
||||
|
||||
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
|
||||
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": "…" }`
|
||||
- Create a page: `POST /api/spaces/2201a3dd-2d40-425c-a4cf-7f18882a9146/pages` with `{ "slug", "title", "body_md", "parent_id" }`
|
||||
- Per-LXC / per-service pages parent under **Hosts & Services** (`ab398d61-805a-46dd-b1ba-6f09374bd7aa`).
|
||||
- **Do not** write a contiguous `IP:port` for a remote-site or inactive host — `infra_audit` probes those and will false-flag them.
|
||||
|
||||
### How — git
|
||||
Commit code + docs together and push to the project's Gitea repo:
|
||||
- `void-v2` → `Hynes/Void-Homelab`
|
||||
- `farm-timelapse` → `Hynes/farm-timelapse`
|
||||
|
||||
Specs/plans live in `docs/superpowers/{specs,plans}/`; user-facing changes get a `CHANGELOG.md` entry.
|
||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -3,6 +3,42 @@
|
||||
All notable changes to Void 2.0 are documented here.
|
||||
Format: [Keep a Changelog](https://keepachangelog.com).
|
||||
|
||||
## 2.4.0 — Storage · capacity card (Sacred Valley)
|
||||
- **New "Storage · capacity" card** (`public/views/cards/storage.js`, `/api/storage`, `lib/proxmox/storage.js`) — read-only Proxmox health via the same `PROXMOX_RO_TOKEN` as the cluster card. Shows: **ZFS pools** (health + usage meter), **dropped pools** (a configured zfspool storage that's no longer `available` — the donatello/leonardo SATA-bus signal, rendered red), and **per-container disk fill** (top LXC by rootfs %), with a HEALTHY/WATCH/ATTENTION roll-up badge. Thresholds: 80% warn, 90% crit; a non-ONLINE or dropped pool is always crit.
|
||||
- Closes the monitoring gap from the 2026-06-09 audit (the Void couldn't previously see C1 = pools offline or H2 = a container at 95%). Pure `normalizeStorage()` is unit-tested.
|
||||
|
||||
## 2.3.0 — MagicMirror² as a Void app
|
||||
- **New "MagicMirror" Apps view** (`#/mirror`, `public/views/mirror.js`) — embeds the smart-mirror dashboard (CT 111) via the shared `embedView` factory, like Timelapse / AI Usage.
|
||||
- **Exposure:** MagicMirror (LAN-only `192.168.1.224:8080`) is now published at **mirror.hynesy.com** through Traefik + the `*.hynesy.com` tunnel, private behind **CF Access** (Farm policy / Google IdP). A Traefik `mirror-frame` middleware replaces MM's `X-Frame-Options: SAMEORIGIN` with a CSP `frame-ancestors` allowing the Void origins so the iframe renders.
|
||||
- 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
|
||||
- **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.
|
||||
|
||||
## 2.1.0 — LAN device discovery
|
||||
- **`lan_devices` store + hourly `arp-scan`** (`migration 024`, `lib/infra/scan.js`,
|
||||
`lib/db/repos/lan_devices.js`, `lib/cron`): the Devices band is now DB-backed and
|
||||
self-updating. New MACs land in a **Discovered** review queue; the owner names/
|
||||
groups/promotes them (`/api/devices`). Devices are keyed by MAC (IP is mutable);
|
||||
unreviewed + absent rows auto-prune (randomized >24h, others >14d) so randomized
|
||||
MACs can't bloat the table. Replaces the static `public/devices.json` (now seeded
|
||||
into the table by the migration).
|
||||
|
||||
## 2.0.0 — General availability (Void 1 retired)
|
||||
- **Dropped the `-alpha` tag.** Void 2 is *the* homelab dashboard; `void.hynesy.com` has served it since alpha-18.
|
||||
- **Void 1 retired.** CT 301 stopped, backed up (vzdump archive + off-CT data tarball), and destroyed 2026-06-08.
|
||||
|
||||
@@ -127,6 +127,25 @@ re-initdb the cluster, use `--encoding=UTF8 --locale=C.UTF-8`.
|
||||
mkdir -p /var/lib/void/icons
|
||||
chown void: /var/lib/void/icons
|
||||
```
|
||||
|
||||
## LAN device discovery (2.1.0)
|
||||
|
||||
The hourly device scan (`lib/cron` → `runDeviceScanCycle`) shells `arp-scan`. The
|
||||
service runs as the non-root `void` user, so `arp-scan` needs a raw-socket
|
||||
capability:
|
||||
|
||||
```bash
|
||||
apt-get install -y arp-scan
|
||||
setcap cap_net_raw,cap_net_admin+eip "$(readlink -f "$(command -v arp-scan)")"
|
||||
# verify as the service user (run from the service WorkingDirectory so the
|
||||
# OUI vendor files resolve):
|
||||
runuser -u void -- sh -c 'cd /opt/void-server && arp-scan --localnet --plain | head'
|
||||
```
|
||||
|
||||
**⚠ Re-apply the `setcap` after any `arp-scan` package upgrade** — the upgrade
|
||||
replaces the binary and drops the capability, after which scans silently find
|
||||
nothing. `migration 024` creates `lan_devices` and seeds it from the old
|
||||
`devices.json`, so the band still renders even before the first scan runs.
|
||||
- **Service registry** — edit `config/services.json` to the real homelab service URLs and CT numbers. The committed seed values are best-guess placeholders and should be updated before the health band is meaningful.
|
||||
|
||||
## Deploy safety (push.sh, hardened)
|
||||
|
||||
587
docs/superpowers/plans/2026-06-08-kutt-url-shortener.md
Normal file
587
docs/superpowers/plans/2026-06-08-kutt-url-shortener.md
Normal 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.
|
||||
```
|
||||
847
docs/superpowers/plans/2026-06-08-lan-device-discovery.md
Normal file
847
docs/superpowers/plans/2026-06-08-lan-device-discovery.md
Normal file
@@ -0,0 +1,847 @@
|
||||
# LAN Device Discovery — 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:** Replace the static `devices.json` with a persistent, MAC-keyed `lan_devices` store fed by an hourly `arp-scan`, with a "discovered → name/edit → promote" review flow and randomized-MAC auto-pruning.
|
||||
|
||||
**Architecture:** A decoupled scanner (`lib/infra/scan.js`, pure parser + injected exec) feeds a repo (`lib/db/repos/lan_devices.js`) keyed by MAC. An hourly cron runs scan→upsert→mark-absent→prune. An owner API (`/api/devices`) exposes the band + review/edit. The front-end band reads the DB and offers an add/edit panel.
|
||||
|
||||
**Tech Stack:** Node/Express, Postgres (`pg`), vanilla-ESM SPA, vitest+supertest+jsdom, `arp-scan`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-08-lan-device-discovery-design.md`
|
||||
**Branch:** `feat/lan-device-discovery` (spec already committed there).
|
||||
|
||||
**Conventions to mirror:** repo = `lib/db/repos/monitored_services.js`; route = `lib/api/routes/health.js` (`Router`, `asyncWrap`, `requireOwner` from `../cap.js`, `validate` from `../validate.js`); repo test = `tests/repos/monitored_services.test.js` (`resetDb()`+`migrateUp()`); route test = `tests/server.test.js` (supertest, `OWNER_TOKEN='test-token'`, `Authorization: Bearer test-token`); frontend test = `tests/frontend/service_tile.test.js` (jsdom). Run a single file: `npm test -- <path>`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Scanner module (pure parse + randomized detection + runScan)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/infra/scan.js`
|
||||
- Test: `tests/infra/scan.test.js`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
// tests/infra/scan.test.js
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isRandomizedMac, parseArpScan, runScan } from '../../lib/infra/scan.js';
|
||||
|
||||
const SAMPLE = [
|
||||
'Interface: eth0, type: EN10MB, MAC: bc:24:11:9b:b7:3a, IPv4: 192.168.1.216',
|
||||
'Starting arp-scan 1.10.0',
|
||||
'192.168.1.13\tbc:a5:11:3e:06:88\tNetgear',
|
||||
'192.168.1.171\t5a:da:61:7a:0f:12\t(Unknown)',
|
||||
'192.168.1.1\t44:A5:6E:68:D0:E9\tNetgear Inc.',
|
||||
'garbage line that is not a host',
|
||||
'',
|
||||
'3 packets received by filter, 0 packets dropped'
|
||||
].join('\n');
|
||||
|
||||
describe('scan parsing', () => {
|
||||
it('isRandomizedMac flags locally-administered MACs', () => {
|
||||
expect(isRandomizedMac('5a:da:61:7a:0f:12')).toBe(true); // 0x5a & 0x02
|
||||
expect(isRandomizedMac('bc:a5:11:3e:06:88')).toBe(false); // 0xbc & 0x02 == 0
|
||||
expect(isRandomizedMac('44:A5:6E:68:D0:E9')).toBe(false);
|
||||
});
|
||||
|
||||
it('parseArpScan keeps only host lines, lowercases MAC, flags randomized', () => {
|
||||
const rows = parseArpScan(SAMPLE);
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows[0]).toEqual({ ip: '192.168.1.13', mac: 'bc:a5:11:3e:06:88', vendor: 'Netgear', randomized: false });
|
||||
expect(rows[1]).toEqual({ ip: '192.168.1.171', mac: '5a:da:61:7a:0f:12', vendor: '(Unknown)', randomized: true });
|
||||
expect(rows[2].mac).toBe('44:a5:6e:68:d0:e9'); // lowercased
|
||||
});
|
||||
|
||||
it('runScan parses the injected exec stdout', async () => {
|
||||
const rows = await runScan({ exec: async () => ({ stdout: SAMPLE }) });
|
||||
expect(rows.map(r => r.ip)).toEqual(['192.168.1.13', '192.168.1.171', '192.168.1.1']);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it; verify it fails**
|
||||
|
||||
Run: `npm test -- tests/infra/scan.test.js`
|
||||
Expected: FAIL — `Cannot find module '../../lib/infra/scan.js'`.
|
||||
|
||||
- [ ] **Step 3: Create `lib/infra/scan.js`**
|
||||
|
||||
```js
|
||||
// Decoupled LAN scanner: pure parser + a thin arp-scan runner (exec injected
|
||||
// for tests). The repo/cron own persistence — this module only produces rows.
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const pexec = promisify(execFile);
|
||||
|
||||
// A locally-administered (randomized) MAC has bit 0x02 set in its first octet.
|
||||
export function isRandomizedMac(mac) {
|
||||
const first = parseInt(String(mac).split(':')[0], 16);
|
||||
return Number.isFinite(first) && (first & 0x02) === 0x02;
|
||||
}
|
||||
|
||||
// Keep only "IP<ws>MAC<ws>[vendor]" lines; ignore banner/footer/garbage.
|
||||
export function parseArpScan(text) {
|
||||
const re = /^(\d{1,3}(?:\.\d{1,3}){3})\s+([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})\s*(.*)$/;
|
||||
const out = [];
|
||||
for (const line of String(text).split('\n')) {
|
||||
const m = line.match(re);
|
||||
if (!m) continue;
|
||||
const mac = m[2].toLowerCase();
|
||||
out.push({ ip: m[1], mac, vendor: m[3].trim(), randomized: isRandomizedMac(mac) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Run arp-scan on the local /24. `exec(file, args) -> {stdout}` injected for tests.
|
||||
export async function runScan({ exec = pexec } = {}) {
|
||||
const { stdout } = await exec('arp-scan', ['--localnet', '--plain', '--retry=2']);
|
||||
return parseArpScan(stdout);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run it; verify it passes**
|
||||
|
||||
Run: `npm test -- tests/infra/scan.test.js`
|
||||
Expected: PASS (3 passed).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/infra/scan.js tests/infra/scan.test.js
|
||||
git commit -m "feat(devices): arp-scan parser + randomized-MAC detection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Migration `024_lan_devices` (table + seed)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/db/migrations/024_lan_devices.sql`
|
||||
- Test: covered by the repo test in Task 3 (seed assertions). This task is verified by running migrations.
|
||||
|
||||
- [ ] **Step 1: Create the migration**
|
||||
|
||||
```sql
|
||||
-- 024_lan_devices.sql
|
||||
-- LAN device inventory keyed by MAC, fed by the hourly arp-scan. Separate from
|
||||
-- network_hosts (homelab guests). New MACs land status='new' for owner review.
|
||||
CREATE TABLE IF NOT EXISTS lan_devices (
|
||||
mac text PRIMARY KEY,
|
||||
ip text,
|
||||
vendor text,
|
||||
name text,
|
||||
grp text,
|
||||
note text,
|
||||
status text NOT NULL DEFAULT 'new', -- new | known | ignored
|
||||
randomized boolean NOT NULL DEFAULT false,
|
||||
flagged boolean NOT NULL DEFAULT false,
|
||||
first_seen timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen timestamptz NOT NULL DEFAULT now(),
|
||||
present boolean NOT NULL DEFAULT true
|
||||
);
|
||||
|
||||
-- Seed from the curated devices.json (MACs lowercased). Named devices -> 'known';
|
||||
-- the unidentified ASUS box -> 'new'. present=false until the first live scan.
|
||||
INSERT INTO lan_devices (mac, ip, vendor, name, grp, status, flagged, randomized, present) VALUES
|
||||
('48:43:dd:fc:2f:84','192.168.1.3','Amazon','Amazon Echo','Smart Home','known',false,false,false),
|
||||
('14:0a:c5:6d:15:6e','192.168.1.4','Amazon','Amazon Echo','Smart Home','known',false,false,false),
|
||||
('c8:47:8c:01:17:70','192.168.1.6','Beken','Smart device','Smart Home','known',false,false,false),
|
||||
('d4:a6:51:12:36:92','192.168.1.23','Tuya','Smart device','Smart Home','known',false,false,false),
|
||||
('ec:4d:3e:36:ef:e1','192.168.1.20','Xiaomi','Xiaomi device','Smart Home','known',false,false,false),
|
||||
('1c:53:f9:bb:32:24','192.168.1.12','Google','Google / Nest','Entertainment','known',false,false,false),
|
||||
('d4:f5:47:95:33:93','192.168.1.14','Google','Google Nest Mini','Entertainment','known',false,false,false),
|
||||
('ec:4d:3e:37:38:8f','192.168.1.18','Google','Google / Nest','Entertainment','known',false,false,false),
|
||||
('48:70:1e:01:4f:7b','192.168.1.29','StreamMagic','Cambridge Audio','Entertainment','known',false,false,false),
|
||||
('08:66:98:b9:cf:f2','192.168.1.43','Apple','Apple TV / HomePod','Entertainment','known',false,false,false),
|
||||
('1c:86:9a:4c:f0:ec','192.168.1.24','Samsung','Samsung TV','Entertainment','known',false,false,false),
|
||||
('5a:da:61:7a:0f:12','192.168.1.171','Samsung','Galaxy Tab S4','Personal','known',false,true,false),
|
||||
('1c:57:dc:70:e8:2d','192.168.1.133','Apple','Apple device','Personal','known',false,false,false),
|
||||
('a0:d0:5b:04:70:96','192.168.1.61','Samsung','Samsung device','Personal','known',false,false,false),
|
||||
('14:eb:b6:40:7e:93','192.168.1.10','TP-Link','TP-Link device','Personal','known',false,false,false),
|
||||
('44:a5:6e:68:d0:e9','192.168.1.1','Netgear','Gateway / Router','Network','known',false,false,false),
|
||||
('bc:a5:11:3e:06:88','192.168.1.13','Netgear (Orbi mesh)','Orbi Satellite','Network','known',false,false,false),
|
||||
('24:4b:fe:8e:09:a4','192.168.1.15','ASUSTek','ASUS device','Flagged','new',true,false,false)
|
||||
ON CONFLICT (mac) DO NOTHING;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run migrations against the test DB to verify the SQL is valid**
|
||||
|
||||
Run: `node -e "import('./lib/db/migrate.js').then(m=>m.migrateUp()).then(()=>{console.log('migrated');process.exit(0)}).catch(e=>{console.error(e);process.exit(1)})"`
|
||||
Expected: prints `migrated` (no SQL error). (Uses the env `DATABASE_URL`; in dev this is the test/dev DB.)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/db/migrations/024_lan_devices.sql
|
||||
git commit -m "feat(devices): lan_devices table + seed from curated devices.json"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `lan_devices` repo (upsert / absent / prune / promote)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/db/repos/lan_devices.js`
|
||||
- Test: `tests/repos/lan_devices.test.js`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
// tests/repos/lan_devices.test.js
|
||||
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
import { pool } from '../../lib/db/pool.js';
|
||||
import * as repo from '../../lib/db/repos/lan_devices.js';
|
||||
|
||||
beforeAll(async () => { await resetDb(); await migrateUp(); });
|
||||
beforeEach(async () => { await resetDb(); await migrateUp(); });
|
||||
|
||||
describe('lan_devices repo', () => {
|
||||
it('seed: 17 known, 1 discovered (ASUS)', async () => {
|
||||
expect(await repo.listKnown()).toHaveLength(17);
|
||||
const disc = await repo.listDiscovered();
|
||||
expect(disc).toHaveLength(1);
|
||||
expect(disc[0].mac).toBe('24:4b:fe:8e:09:a4');
|
||||
expect(disc[0].flagged).toBe(true);
|
||||
});
|
||||
|
||||
it('upsertScan inserts unseen as new, updates known IP without clobbering name', async () => {
|
||||
await repo.upsertScan([
|
||||
{ mac: 'aa:bb:cc:dd:ee:ff', ip: '192.168.1.99', vendor: 'NewCo', randomized: false }, // new
|
||||
{ mac: 'bc:a5:11:3e:06:88', ip: '192.168.1.77', vendor: 'Netgear', randomized: false } // known Orbi, IP changed
|
||||
]);
|
||||
const orbi = await repo.get('bc:a5:11:3e:06:88');
|
||||
expect(orbi.ip).toBe('192.168.1.77'); // ip updated
|
||||
expect(orbi.name).toBe('Orbi Satellite'); // name preserved
|
||||
expect(orbi.status).toBe('known'); // status preserved
|
||||
expect(orbi.present).toBe(true);
|
||||
const fresh = await repo.get('aa:bb:cc:dd:ee:ff');
|
||||
expect(fresh.status).toBe('new');
|
||||
});
|
||||
|
||||
it('markAbsent flips present for unseen; empty list is a no-op', async () => {
|
||||
await repo.upsertScan([{ mac: 'aa:bb:cc:dd:ee:ff', ip: '192.168.1.99', vendor: '', randomized: false }]);
|
||||
await repo.markAbsent(['aa:bb:cc:dd:ee:ff']); // only this one seen
|
||||
expect((await repo.get('bc:a5:11:3e:06:88')).present).toBe(false); // seeded device now absent
|
||||
expect((await repo.get('aa:bb:cc:dd:ee:ff')).present).toBe(true);
|
||||
const before = (await repo.get('aa:bb:cc:dd:ee:ff')).present;
|
||||
expect(await repo.markAbsent([])).toBe(0); // guard: no-op
|
||||
expect((await repo.get('aa:bb:cc:dd:ee:ff')).present).toBe(before);
|
||||
});
|
||||
|
||||
it('prune deletes stale new+absent (randomized >24h, others >14d); keeps known', async () => {
|
||||
await pool.query(`INSERT INTO lan_devices (mac, status, randomized, present, last_seen)
|
||||
VALUES ('11:11:11:11:11:11','new',true,false, now()-interval '2 days'),
|
||||
('22:22:22:22:22:22','new',false,false, now()-interval '20 days'),
|
||||
('33:33:33:33:33:33','new',true,false, now()-interval '1 hour'),
|
||||
('44:44:44:44:44:44','known',true,false, now()-interval '99 days')`);
|
||||
const n = await repo.prune();
|
||||
expect(n).toBe(2); // the two stale 'new'
|
||||
expect(await repo.get('33:33:33:33:33:33')).not.toBeNull(); // recent kept
|
||||
expect(await repo.get('44:44:44:44:44:44')).not.toBeNull(); // known kept
|
||||
});
|
||||
|
||||
it('update promotes + names a discovered device', async () => {
|
||||
await repo.update('24:4b:fe:8e:09:a4', { name: 'ASUS RT-AX88U', grp: 'Network', status: 'known', flagged: false });
|
||||
expect(await repo.listDiscovered()).toHaveLength(0);
|
||||
const d = await repo.get('24:4b:fe:8e:09:a4');
|
||||
expect(d.name).toBe('ASUS RT-AX88U');
|
||||
expect(d.status).toBe('known');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it; verify it fails**
|
||||
|
||||
Run: `npm test -- tests/repos/lan_devices.test.js`
|
||||
Expected: FAIL — `Cannot find module '../../lib/db/repos/lan_devices.js'`.
|
||||
|
||||
- [ ] **Step 3: Create `lib/db/repos/lan_devices.js`**
|
||||
|
||||
```js
|
||||
import { pool } from '../pool.js';
|
||||
|
||||
const COLS = 'mac, ip, vendor, name, grp, note, status, randomized, flagged, first_seen, last_seen, present';
|
||||
|
||||
export async function listKnown() {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ${COLS} FROM lan_devices WHERE status='known' ORDER BY grp, name NULLS LAST, ip`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function listDiscovered() {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ${COLS} FROM lan_devices WHERE status='new' ORDER BY last_seen DESC`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function get(mac) {
|
||||
const { rows: [r] } = await pool.query(`SELECT ${COLS} FROM lan_devices WHERE mac=$1`, [mac]);
|
||||
return r || null;
|
||||
}
|
||||
|
||||
// Insert unseen MACs as status='new'; for existing, refresh ip/vendor/last_seen/present
|
||||
// WITHOUT touching owner-curated name/grp/status/flagged.
|
||||
export async function upsertScan(rows) {
|
||||
for (const r of rows) {
|
||||
await pool.query(
|
||||
`INSERT INTO lan_devices (mac, ip, vendor, randomized, status, present, first_seen, last_seen)
|
||||
VALUES ($1,$2,$3,$4,'new',true,now(),now())
|
||||
ON CONFLICT (mac) DO UPDATE SET
|
||||
ip = EXCLUDED.ip,
|
||||
vendor = COALESCE(NULLIF(EXCLUDED.vendor,''), lan_devices.vendor),
|
||||
last_seen = now(), present = true`,
|
||||
[r.mac, r.ip ?? null, r.vendor ?? null, !!r.randomized]);
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
// Mark devices not in the latest scan as absent. Empty input is a no-op so a
|
||||
// failed/empty scan can never blanket-mark everything offline.
|
||||
export async function markAbsent(seenMacs) {
|
||||
if (!seenMacs || !seenMacs.length) return 0;
|
||||
const { rowCount } = await pool.query(
|
||||
`UPDATE lan_devices SET present=false WHERE present=true AND NOT (mac = ANY($1::text[]))`,
|
||||
[seenMacs]);
|
||||
return rowCount;
|
||||
}
|
||||
|
||||
// Reap unreviewed + absent rows past their TTL. Never touches known/ignored.
|
||||
export async function prune() {
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM lan_devices WHERE status='new' AND present=false AND (
|
||||
(randomized AND last_seen < now() - interval '24 hours') OR
|
||||
(NOT randomized AND last_seen < now() - interval '14 days'))`);
|
||||
return rowCount;
|
||||
}
|
||||
|
||||
const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged'];
|
||||
export async function update(mac, patch) {
|
||||
const sets = [], vals = [];
|
||||
for (const k of PATCHABLE) {
|
||||
if (patch[k] !== undefined) { vals.push(patch[k]); sets.push(`${k}=$${vals.length}`); }
|
||||
}
|
||||
if (!sets.length) return get(mac);
|
||||
vals.push(mac);
|
||||
const { rows: [r] } = await pool.query(
|
||||
`UPDATE lan_devices SET ${sets.join(', ')} WHERE mac=$${vals.length} RETURNING ${COLS}`, vals);
|
||||
return r || null;
|
||||
}
|
||||
|
||||
export async function remove(mac) {
|
||||
const { rowCount } = await pool.query(`DELETE FROM lan_devices WHERE mac=$1`, [mac]);
|
||||
return rowCount > 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run it; verify it passes**
|
||||
|
||||
Run: `npm test -- tests/repos/lan_devices.test.js`
|
||||
Expected: PASS (6 passed).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/db/repos/lan_devices.js tests/repos/lan_devices.test.js
|
||||
git commit -m "feat(devices): lan_devices repo (upsert/absent/prune/promote)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Scan-cycle orchestration + cron wiring
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/infra/scan_cycle.js`
|
||||
- Modify: `lib/cron/index.js`
|
||||
- Test: `tests/infra/scan_cycle.test.js`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
// tests/infra/scan_cycle.test.js
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { runDeviceScanCycle } from '../../lib/infra/scan_cycle.js';
|
||||
|
||||
function fakeRepo() {
|
||||
return {
|
||||
calls: [],
|
||||
upsertScan: vi.fn(async r => r.length),
|
||||
markAbsent: vi.fn(async () => 1),
|
||||
prune: vi.fn(async () => 2)
|
||||
};
|
||||
}
|
||||
|
||||
describe('runDeviceScanCycle', () => {
|
||||
it('scan→upsert→markAbsent→prune on a non-empty scan', async () => {
|
||||
const repo = fakeRepo();
|
||||
const scan = vi.fn(async () => [{ mac: 'aa:bb:cc:dd:ee:ff', ip: '1.2.3.4', vendor: 'x', randomized: false }]);
|
||||
const res = await runDeviceScanCycle({ scan, repo });
|
||||
expect(repo.upsertScan).toHaveBeenCalledOnce();
|
||||
expect(repo.markAbsent).toHaveBeenCalledWith(['aa:bb:cc:dd:ee:ff']);
|
||||
expect(repo.prune).toHaveBeenCalledOnce();
|
||||
expect(res).toEqual({ seen: 1, pruned: 2 });
|
||||
});
|
||||
|
||||
it('skips upsert/prune when the scan returns nothing', async () => {
|
||||
const repo = fakeRepo();
|
||||
const res = await runDeviceScanCycle({ scan: async () => [], repo });
|
||||
expect(repo.upsertScan).not.toHaveBeenCalled();
|
||||
expect(repo.prune).not.toHaveBeenCalled();
|
||||
expect(res).toEqual({ seen: 0 });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it; verify it fails**
|
||||
|
||||
Run: `npm test -- tests/infra/scan_cycle.test.js`
|
||||
Expected: FAIL — module not found.
|
||||
|
||||
- [ ] **Step 3: Create `lib/infra/scan_cycle.js`**
|
||||
|
||||
```js
|
||||
// One discovery cycle: scan → upsert → mark-absent → prune. Deps injected for
|
||||
// tests. Prune only runs after a successful, non-empty scan, so a failed scan
|
||||
// can never reap rows.
|
||||
import { runScan } from './scan.js';
|
||||
import * as devices from '../db/repos/lan_devices.js';
|
||||
import { log } from '../log.js';
|
||||
|
||||
export async function runDeviceScanCycle({ scan = runScan, repo = devices } = {}) {
|
||||
const rows = await scan();
|
||||
if (!rows.length) {
|
||||
log.warn('device scan returned no hosts; skipping upsert/prune');
|
||||
return { seen: 0 };
|
||||
}
|
||||
await repo.upsertScan(rows);
|
||||
await repo.markAbsent(rows.map(r => r.mac));
|
||||
const pruned = await repo.prune();
|
||||
log.info({ seen: rows.length, pruned }, 'device scan cycle complete');
|
||||
return { seen: rows.length, pruned };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run it; verify it passes**
|
||||
|
||||
Run: `npm test -- tests/infra/scan_cycle.test.js`
|
||||
Expected: PASS (2 passed).
|
||||
|
||||
- [ ] **Step 5: Wire the hourly cron**
|
||||
|
||||
In `lib/cron/index.js`, add the import near the other imports:
|
||||
```js
|
||||
import { runDeviceScanCycle } from '../infra/scan_cycle.js';
|
||||
```
|
||||
Then inside `startCron()`, before the final `log.info('cron started')`, add:
|
||||
```js
|
||||
// Hourly LAN device scan (staggered off the :00 speedtest)
|
||||
cron.schedule('7 * * * *', async () => {
|
||||
try { await runDeviceScanCycle(); }
|
||||
catch (e) { log.error({ err: e }, 'device scan cycle failed'); }
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Sanity-check + commit**
|
||||
|
||||
Run: `node --check lib/cron/index.js`
|
||||
Expected: clean (exit 0).
|
||||
```bash
|
||||
git add lib/infra/scan_cycle.js lib/cron/index.js tests/infra/scan_cycle.test.js
|
||||
git commit -m "feat(devices): hourly scan-cycle orchestration + cron"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: API route `/api/devices`
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/api/routes/devices.js`
|
||||
- Modify: `lib/api/index.js` (import + mount)
|
||||
- Test: `tests/api/devices.test.js`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
// tests/api/devices.test.js
|
||||
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../server.js';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
|
||||
let app;
|
||||
const owner = r => r.set('Authorization', 'Bearer test-token');
|
||||
beforeAll(async () => { process.env.OWNER_TOKEN = 'test-token'; app = createApp(); });
|
||||
beforeEach(async () => { await resetDb(); await migrateUp(); });
|
||||
|
||||
describe('/api/devices', () => {
|
||||
it('GET / returns known devices grouped', async () => {
|
||||
const res = await request(app).get('/api/devices');
|
||||
expect(res.status).toBe(200);
|
||||
const names = res.body.groups.map(g => g.name);
|
||||
expect(names).toContain('Network');
|
||||
const net = res.body.groups.find(g => g.name === 'Network');
|
||||
expect(net.devices.some(d => d.name === 'Orbi Satellite')).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /discovered requires owner and lists new devices', async () => {
|
||||
expect((await request(app).get('/api/devices/discovered')).status).toBe(401);
|
||||
const res = await owner(request(app).get('/api/devices/discovered'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.some(d => d.mac === '24:4b:fe:8e:09:a4')).toBe(true);
|
||||
});
|
||||
|
||||
it('PATCH /:mac promotes + names (owner)', async () => {
|
||||
const res = await owner(request(app).patch('/api/devices/24:4b:fe:8e:09:a4'))
|
||||
.send({ name: 'ASUS Router', grp: 'Network', status: 'known' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('ASUS Router');
|
||||
expect((await owner(request(app).get('/api/devices/discovered'))).body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('PATCH rejects a bad MAC', async () => {
|
||||
expect((await owner(request(app).patch('/api/devices/not-a-mac')).send({ name: 'x' })).status).toBe(400);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it; verify it fails**
|
||||
|
||||
Run: `npm test -- tests/api/devices.test.js`
|
||||
Expected: FAIL — route not mounted (404s / module missing).
|
||||
|
||||
- [ ] **Step 3: Create `lib/api/routes/devices.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 * as devices from '../../db/repos/lan_devices.js';
|
||||
|
||||
export const router = Router();
|
||||
const GROUP_ORDER = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
|
||||
|
||||
// GET /devices — known devices grouped for the band (open within the app, like /services).
|
||||
router.get('/', asyncWrap(async (_req, res) => {
|
||||
const byGrp = new Map();
|
||||
for (const d of await devices.listKnown()) {
|
||||
const g = d.grp || 'Flagged';
|
||||
if (!byGrp.has(g)) byGrp.set(g, []);
|
||||
byGrp.get(g).push(d);
|
||||
}
|
||||
const order = [...GROUP_ORDER, ...[...byGrp.keys()].filter(g => !GROUP_ORDER.includes(g))];
|
||||
res.json({ groups: order.filter(g => byGrp.has(g)).map(name => ({ name, devices: byGrp.get(name) })) });
|
||||
}));
|
||||
|
||||
// GET /devices/discovered — review queue (owner).
|
||||
router.get('/discovered', requireOwner, asyncWrap(async (_req, res) => {
|
||||
res.json(await devices.listDiscovered());
|
||||
}));
|
||||
|
||||
const macParam = z.object({ mac: z.string().regex(/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i) });
|
||||
const patchBody = z.object({
|
||||
name: z.string().max(120).optional(),
|
||||
grp: z.enum(['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']).optional(),
|
||||
status: z.enum(['new', 'known', 'ignored']).optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
flagged: z.boolean().optional()
|
||||
});
|
||||
|
||||
// PATCH /devices/:mac — name / edit / promote (owner). This is "add from discovered".
|
||||
router.patch('/:mac', requireOwner, validate({ params: macParam, body: patchBody }), asyncWrap(async (req, res) => {
|
||||
const updated = await devices.update(req.params.mac.toLowerCase(), req.body);
|
||||
if (!updated) return res.status(404).json({ error: { code: 'not_found' } });
|
||||
res.json(updated);
|
||||
}));
|
||||
|
||||
// DELETE /devices/:mac (owner).
|
||||
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' } });
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
// POST /devices/scan — run a scan now (owner).
|
||||
router.post('/scan', requireOwner, asyncWrap(async (_req, res) => {
|
||||
const { runDeviceScanCycle } = await import('../../infra/scan_cycle.js');
|
||||
res.json(await runDeviceScanCycle());
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Mount it in `lib/api/index.js`**
|
||||
|
||||
Add with the other route imports:
|
||||
```js
|
||||
import { router as devicesRouter } from './routes/devices.js';
|
||||
```
|
||||
Add with the other `api.use(...)` mounts:
|
||||
```js
|
||||
api.use('/devices', devicesRouter);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run it; verify it passes**
|
||||
|
||||
Run: `npm test -- tests/api/devices.test.js`
|
||||
Expected: PASS (4 passed).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/api/routes/devices.js lib/api/index.js tests/api/devices.test.js
|
||||
git commit -m "feat(devices): /api/devices band + discovered review/edit endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Front-end — DB-backed band + discovered review/add/edit
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/views/devices_band.js` (rewrite the data source + add review panel)
|
||||
- Modify: `public/style.css` (badges/panel styles)
|
||||
- Test: `tests/frontend/devices_band.test.js` (create)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
// tests/frontend/devices_band.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) => {
|
||||
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 } ] } ] };
|
||||
if (p === '/api/devices/discovered') return [
|
||||
{ mac: '24:4b:fe:8e:09:a4', ip: '192.168.1.15', vendor: 'ASUSTek', randomized: false, present: true } ];
|
||||
return {};
|
||||
}),
|
||||
patch: vi.fn(async () => ({}))
|
||||
}
|
||||
}));
|
||||
|
||||
let renderDevicesBand;
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="h"></div></body></html>', { url: 'http://localhost/' });
|
||||
global.window = dom.window; global.document = dom.window.document; global.Node = dom.window.Node;
|
||||
({ renderDevicesBand } = await import('../../public/views/devices_band.js'));
|
||||
});
|
||||
afterAll(() => { delete global.window; delete global.document; delete global.Node; });
|
||||
|
||||
describe('devices band', () => {
|
||||
it('renders known devices from the API with MAC, and a discovered count', async () => {
|
||||
const host = document.getElementById('h');
|
||||
await renderDevicesBand(host);
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
expect(host.textContent).toContain('Orbi Satellite');
|
||||
expect(host.querySelector('.dv-mac').textContent).toBe('bc:a5:11:3e:06:88');
|
||||
expect(host.querySelector('.dv-discovered')).not.toBeNull(); // review affordance present
|
||||
expect(host.textContent).toMatch(/Discovered/i);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it; verify it fails**
|
||||
|
||||
Run: `npm test -- tests/frontend/devices_band.test.js`
|
||||
Expected: FAIL — band still fetches `/devices.json` (uses `fetch`, not `api`) and has no `.dv-discovered`.
|
||||
|
||||
- [ ] **Step 3: Rewrite `public/views/devices_band.js`**
|
||||
|
||||
```js
|
||||
// Network Devices band — DB-backed (GET /api/devices). Shows IP+MAC+vendor,
|
||||
// a randomized-MAC badge, and an owner "Discovered" review panel to name/promote
|
||||
// newly-seen devices. Kept SEPARATE from Little Blue's homelab-service band.
|
||||
import { el, mount, clear } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
let host;
|
||||
const GROUPS = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
|
||||
|
||||
function tile(d) {
|
||||
return el('div', { class: 'dv-tile' + (d.flagged ? ' flag' : '') + (d.present === false ? ' absent' : '') },
|
||||
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' : '')));
|
||||
}
|
||||
|
||||
function discoveredRow(d, onDone) {
|
||||
const nameI = el('input', { class: 'dv-edit-name', placeholder: d.vendor || 'name', value: d.name || '' });
|
||||
const grpS = el('select', { class: 'dv-edit-grp' }, ...GROUPS.map(g => el('option', { value: g }, g)));
|
||||
const add = el('button', { class: 'dv-add' }, 'Add');
|
||||
add.onclick = async () => {
|
||||
await api.patch('/api/devices/' + d.mac, { name: nameI.value.trim() || null, grp: grpS.value, status: 'known', flagged: false });
|
||||
onDone();
|
||||
};
|
||||
const ignore = el('button', { class: 'ghost dv-ignore' }, 'Ignore');
|
||||
ignore.onclick = async () => { await api.patch('/api/devices/' + d.mac, { status: 'ignored' }); onDone(); };
|
||||
return el('div', { class: 'dv-disc-row' },
|
||||
el('span', { class: 'dv-ip' }, d.ip || ''),
|
||||
el('span', { class: 'dv-mac' }, d.mac + (d.randomized ? ' · randomized' : '')),
|
||||
el('span', { class: 'dv-vendor' }, d.vendor || ''),
|
||||
nameI, grpS, add, ignore);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!host) return;
|
||||
let data, discovered = [];
|
||||
try { data = await api.get('/api/devices'); } catch { mount(host, el('div', { class: 'dv-note' }, 'Devices unavailable')); return; }
|
||||
try { discovered = await api.get('/api/devices/discovered'); } catch { /* owner-only; ignore for non-owner */ }
|
||||
|
||||
const total = data.groups.reduce((n, g) => n + g.devices.length, 0);
|
||||
const sections = data.groups.map(g =>
|
||||
el('div', { class: 'dv-section' },
|
||||
el('div', { class: 'dv-group' },
|
||||
el('span', { class: 'gname' }, g.name),
|
||||
el('span', { class: 'gcount' }, String(g.devices.length)),
|
||||
el('span', { class: 'line' })),
|
||||
el('div', { class: 'dv-tiles' }, g.devices.map(tile))));
|
||||
|
||||
const discPanel = discovered.length
|
||||
? el('div', { class: 'dv-discovered' },
|
||||
el('div', { class: 'dv-disc-hd' }, `Discovered · ${discovered.length} awaiting review`),
|
||||
...discovered.map(d => discoveredRow(d, load)))
|
||||
: null;
|
||||
|
||||
clear(host);
|
||||
mount(host,
|
||||
el('div', { class: 'dv-hd' },
|
||||
el('div', { class: 'dv-title' }, 'Network · Devices'),
|
||||
el('span', { class: 'dv-count' }, `${total} known${discovered.length ? ` · ${discovered.length} new` : ''}`)),
|
||||
discPanel,
|
||||
...sections);
|
||||
}
|
||||
|
||||
export function renderDevicesBand(root) { host = root; return load(); }
|
||||
export function stopDevicesBand() { host = null; }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add styles in `public/style.css`**
|
||||
|
||||
After the existing `.dv-tile .dv-mac { … }` line (added earlier), add:
|
||||
```css
|
||||
.dv-tile.absent { opacity: .5; }
|
||||
.dv-discovered { border: 1px solid var(--accent-dim); border-radius: 6px; padding: 10px 12px; margin: 10px 0; background: var(--accent-soft); }
|
||||
.dv-disc-hd { font-family: var(--font-display); font-size: 12px; text-transform: uppercase; letter-spacing: .1em; color: var(--accent); margin-bottom: 8px; }
|
||||
.dv-disc-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 5px 0; }
|
||||
.dv-disc-row .dv-edit-name { flex: 1 1 120px; }
|
||||
.dv-disc-row .dv-add { background: var(--accent-dim); color: var(--text); border: 1px solid var(--accent); border-radius: 3px; padding: 4px 10px; cursor: pointer; font-family: var(--font-ui); font-size: 12px; }
|
||||
.dv-disc-row .dv-add:hover { background: var(--accent); color: var(--bg); }
|
||||
.dv-disc-row .ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); border-radius: 3px; padding: 4px 10px; cursor: pointer; font-size: 12px; }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run it; verify it passes**
|
||||
|
||||
Run: `npm test -- tests/frontend/devices_band.test.js`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add public/views/devices_band.js public/style.css tests/frontend/devices_band.test.js
|
||||
git commit -m "feat(devices): DB-backed devices band + discovered review/add/edit UI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Retire `devices.json`, version bump, CHANGELOG
|
||||
|
||||
**Files:**
|
||||
- Delete: `public/devices.json`
|
||||
- Modify: `package.json`, `server.js`, `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1: Remove the static file**
|
||||
|
||||
```bash
|
||||
git rm public/devices.json
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Bump version to 2.1.0**
|
||||
|
||||
- `package.json`: `"version": "2.1.0"`
|
||||
- `server.js`: `const VERSION = '2.1.0';`
|
||||
|
||||
- [ ] **Step 3: CHANGELOG entry**
|
||||
|
||||
Prepend under the `Format:` line in `CHANGELOG.md`:
|
||||
```markdown
|
||||
## 2.1.0 — LAN device discovery
|
||||
- **`lan_devices` store + hourly `arp-scan`** (`migration 024`, `lib/infra/scan.js`,
|
||||
`lib/db/repos/lan_devices.js`, `lib/cron`): the Devices band is now DB-backed and
|
||||
self-updating. New MACs land in a **Discovered** review queue; the owner names/
|
||||
groups/promotes them (`/api/devices`). Devices are keyed by MAC (IP is mutable);
|
||||
unreviewed + absent rows auto-prune (randomized >24h, others >14d) so randomized
|
||||
MACs can't bloat the table. Replaces the static `public/devices.json` (now seeded
|
||||
into the table by the migration).
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the full suite**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: all green (existing + the new device tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore(release): 2.1.0 — LAN device discovery; retire static devices.json"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Infra setup + deploy
|
||||
|
||||
**Files:** none in-repo beyond `deploy/README.md`. Live infra on CT 311 (`void-app`, `192.168.1.216`).
|
||||
|
||||
- [ ] **Step 1: Install arp-scan + grant raw-socket capability (so the `void` user can scan)**
|
||||
|
||||
```bash
|
||||
ssh root@192.168.1.216 "apt-get update -qq && apt-get install -y arp-scan && \
|
||||
setcap cap_net_raw,cap_net_admin+eip \$(readlink -f \$(command -v arp-scan)) && \
|
||||
echo '--- verify as void user ---' && sudo -u void arp-scan --localnet --plain --retry=2 | head -5"
|
||||
```
|
||||
Expected: a few `IP<tab>MAC<tab>vendor` lines printed as the `void` user (proves the capability works without root).
|
||||
|
||||
- [ ] **Step 2: Document it in `deploy/README.md`**
|
||||
|
||||
Append a short "LAN device scan" section noting the `apt install arp-scan` + `setcap cap_net_raw,cap_net_admin+eip /usr/sbin/arp-scan` requirement (re-apply after an arp-scan package upgrade), then:
|
||||
```bash
|
||||
git add deploy/README.md && git commit -m "docs(deploy): arp-scan + setcap for device discovery"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Snapshot + deploy**
|
||||
|
||||
```bash
|
||||
ssh root@192.168.1.124 "pct snapshot 311 pre_2_1_0 --description 'before LAN device discovery'"
|
||||
cd /project/src/void-v2 && ./deploy/push.sh
|
||||
```
|
||||
Expected: `Deployed 2.1.0 — healthy. ✓` (migration 024 runs as part of deploy).
|
||||
|
||||
- [ ] **Step 4: Trigger a scan and verify**
|
||||
|
||||
```bash
|
||||
TOK=$(ssh root@192.168.1.216 "grep -m1 '^OWNER_TOKEN=' /opt/void-server/.env | cut -d= -f2- | tr -d '\"'")
|
||||
curl -s -X POST -H "Authorization: Bearer $TOK" http://192.168.1.216:3000/api/devices/scan
|
||||
curl -s http://192.168.1.216:3000/api/devices | python3 -c "import sys,json;d=json.load(sys.stdin);print('groups:',[g['name'] for g in d['groups']])"
|
||||
curl -s -H "Authorization: Bearer $TOK" http://192.168.1.216:3000/api/devices/discovered | python3 -c "import sys,json;print('discovered:',len(json.load(sys.stdin)))"
|
||||
```
|
||||
Expected: scan returns `{"seen":N,...}`; band shows the seeded groups; discovered count ≥ 0 (new MACs found on the live LAN beyond the seed appear here).
|
||||
|
||||
- [ ] **Step 5: Browser check (webapp-testing)**
|
||||
|
||||
Open `void.hynesy.com`, find the Devices band (Sacred Valley): confirm devices show IP+MAC, randomized devices show the badge, and the **Discovered** panel lets you name + Add a device (it then moves into a group).
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Spec coverage:** MAC-keyed store + seed (T2,T3) · decoupled arp-scan + randomized flag (T1) · hourly cron scan→upsert→mark-absent→prune (T4) · discovered/review/name/edit/promote API (T5) + UI (T6) · prune/retention for randomized bloat (T3 `prune`, T4 wiring) · DB-backed band replacing devices.json (T6,T7) · setcap/arp-scan infra (T8). All spec sections map to a task.
|
||||
- **Type/name consistency:** `lan_devices` columns (`mac,ip,vendor,name,grp,note,status,randomized,flagged,first_seen,last_seen,present`) are identical across migration (T2), repo (T3), API (T5), and UI (T6). Repo methods `upsertScan/markAbsent/prune/listKnown/listDiscovered/get/update/remove` match their callers in `scan_cycle.js` (T4) and the route (T5). `runScan({exec})` / `runDeviceScanCycle({scan,repo})` injection shapes match their tests.
|
||||
- **Out of scope (not planned, per spec):** port/service fingerprinting, SNMP/LLDP, multi-VLAN, push notifications, stable identity across MAC rotations.
|
||||
139
docs/superpowers/specs/2026-06-08-kutt-url-shortener-design.md
Normal file
139
docs/superpowers/specs/2026-06-08-kutt-url-shortener-design.md
Normal 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.
|
||||
166
docs/superpowers/specs/2026-06-08-lan-device-discovery-design.md
Normal file
166
docs/superpowers/specs/2026-06-08-lan-device-discovery-design.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Design: LAN Device Discovery (MAC inventory + review/name)
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Status:** Approved (brainstorm), pending implementation plan
|
||||
**Repo:** void-v2 (CT 311 / `void-app`)
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the static, hand-maintained `public/devices.json` with a **persistent,
|
||||
MAC-keyed device store** fed by a recurring ARP scan. Each scan **logs MACs to
|
||||
the DB and diffs against what's known** — new devices land in a review queue;
|
||||
known devices just get their IP / `last_seen` / presence updated. The owner can
|
||||
**add a discovered device, edit it, and give it a name** for reference (mirrors
|
||||
the Void's existing services "discovered → promote" pattern).
|
||||
|
||||
## Background
|
||||
|
||||
- **Static today:** `public/views/devices_band.js` does `fetch('/devices.json')`
|
||||
— a curated, manually-edited list (IP/MAC/vendor/group/flag). It re-reads a
|
||||
static file; nothing is persisted or diffed.
|
||||
- **Existing precedent to mirror:** `monitored_services` uses
|
||||
`source='discovered' AND NOT enabled` as a review queue; `PATCH /services/:id`
|
||||
promotes + edits. We reproduce this shape for devices.
|
||||
- **Separate from `network_hosts`:** that table is the homelab-guest inventory
|
||||
(Proxmox `BC:24:11:*` LXCs, infra_audit). The devices band is IoT / personal /
|
||||
unknown LAN gear — kept separate.
|
||||
- **Scan engine:** the Void host (CT 311) has `ip`/`arp` but **not**
|
||||
`nmap`/`arp-scan`. We add `arp-scan` (chosen for reliable L2 ARP sweeps that
|
||||
ICMP-blocking devices can't dodge, plus a built-in OUI vendor DB).
|
||||
|
||||
### Lessons borrowed from Scanopy (self-hosted discovery tool)
|
||||
|
||||
- **Decouple scanner from storage/UI** — the scanner just scans and reports; the
|
||||
server owns dedup + persistence. → isolated `lib/infra/scan.js`.
|
||||
- **MAC is the identity, IP is a mutable attribute** — key on MAC, update IP each
|
||||
scan (handles DHCP churn). → `mac` primary key.
|
||||
- **Scheduled rescans + timestamp inventory** — periodic batch with
|
||||
`first_seen`/`last_seen`/`present`, diff by "MAC seen before?". → hourly cron.
|
||||
- **Vendor via OUI** — `arp-scan` ships an OUI database; vendor is free.
|
||||
- **Randomized MACs are an open problem** even for Scanopy — so we at least
|
||||
**flag** locally-administered MACs so the user knows OUI can't ID them.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Scan engine | **`arp-scan --localnet` on CT 311**, hourly cron | Reliable L2 sweep + built-in OUI; self-contained (no external scanner dep). |
|
||||
| Cadence | **Hourly** (staggered, e.g. `7 * * * *`) | "No rush"; device drift is slow. |
|
||||
| DB growth | **Upsert by MAC — one row per device, no per-scan history** | Table is bounded by distinct devices ever seen (dozens–hundreds), not scan count → no bloat. |
|
||||
| Identity | **MAC primary key**; IP a mutable column | Survives DHCP IP changes. |
|
||||
| Review flow | Mirror services `discovered → promote` | New MAC → `status='new'`; owner names/edits → `status='known'`. |
|
||||
| Source of truth | **DB** (`lan_devices`); `devices.json` becomes the one-time migration seed, then removed | Single source of truth. |
|
||||
| Randomized-MAC bloat | **Auto-prune unreviewed + absent rows** (randomized >24h, others >14d); keep `known`/`ignored` forever | Rotated randomized MACs never accumulate; the table stays bounded. |
|
||||
|
||||
## Architecture
|
||||
|
||||
scan (arp-scan) → `parseArpScan` (+randomized flag) → `upsertScan` by MAC →
|
||||
`markAbsent` for unseen → review queue (`status='new'`) → owner names/groups/promotes
|
||||
→ known devices render in the band.
|
||||
|
||||
## Components
|
||||
|
||||
### Migration `024_lan_devices.sql`
|
||||
Table `lan_devices`:
|
||||
- `mac text PRIMARY KEY`
|
||||
- `ip text`, `vendor text`
|
||||
- `name text` (owner-given reference name, null until named)
|
||||
- `grp text` (Smart Home | Entertainment | Personal | Network | Flagged)
|
||||
- `note text`
|
||||
- `status text NOT NULL DEFAULT 'new'` (`new` | `known` | `ignored`)
|
||||
- `randomized boolean NOT NULL DEFAULT false` (locally-administered MAC)
|
||||
- `flagged boolean NOT NULL DEFAULT false`
|
||||
- `first_seen timestamptz NOT NULL DEFAULT now()`
|
||||
- `last_seen timestamptz NOT NULL DEFAULT now()`
|
||||
- `present boolean NOT NULL DEFAULT true`
|
||||
|
||||
**Seed (embedded SQL, from the current curated `devices.json`):**
|
||||
- Devices **with a MAC**: non-flagged → `status='known'` with their name/group;
|
||||
flagged (e.g. `.15` ASUS) → `status='new'`, `flagged=true`.
|
||||
- The `.13` Orbi satellite and `.171` Galaxy Tab S4 fixes carry over as `known`.
|
||||
- MAC-less curated entries (`.21/.22/.34/.35/.51`, currently offline) are **not
|
||||
seeded** — they reappear as `new` (with a real MAC) the first time they're seen
|
||||
online. (Documented so it's expected, not a gap.)
|
||||
|
||||
### `lib/infra/scan.js` (decoupled scanner)
|
||||
- `parseArpScan(text) -> [{ ip, mac, vendor, randomized }]` — **pure** parser of
|
||||
`arp-scan` tab-separated output (skips banner/footer); `randomized` = first
|
||||
octet has the locally-administered bit (`& 0x02`).
|
||||
- `isRandomizedMac(mac) -> boolean` — pure helper.
|
||||
- `runScan({ exec }) -> rows` — shells `arp-scan --localnet -x` (interface
|
||||
auto/`-I eth0`), returns `parseArpScan(stdout)`. `exec` injected for tests.
|
||||
|
||||
### `lib/db/repos/lan_devices.js`
|
||||
- `upsertScan(rows)` — insert unseen MACs as `status='new'`; for existing, update
|
||||
`ip`, `vendor`, `last_seen=now()`, `present=true` (never overwrite owner
|
||||
`name`/`grp`/`status`).
|
||||
- `markAbsent(seenMacs)` — `present=false` for MACs not in the latest scan.
|
||||
- `listKnown()` (`status='known'`, grouped by `grp`), `listDiscovered()`
|
||||
(`status='new'`), `get(mac)`, `update(mac, {name, grp, status, note, flagged})`,
|
||||
`remove(mac)`. (`ignored` devices show in neither.)
|
||||
- `prune()` — delete unreviewed + absent rows past their TTL: `status='new' AND
|
||||
present=false AND ((randomized AND last_seen < now()-'24h') OR (NOT randomized
|
||||
AND last_seen < now()-'14d'))`. Never touches `known`/`ignored`.
|
||||
|
||||
### Cron (`lib/cron/index.js`)
|
||||
Add hourly (`7 * * * *`): `runScan()` → `upsertScan` → `markAbsent` → `prune()`.
|
||||
Wrapped in try/catch — a scan failure logs and never crashes the cron, and
|
||||
`prune()` only runs after a *successful* scan (so a failed scan can't reap rows).
|
||||
|
||||
### API `lib/api/routes/devices.js` (mount `/api/devices`, owner-gated)
|
||||
- `GET /` — known devices grouped for the band.
|
||||
- `GET /discovered` — `status='new'` review queue.
|
||||
- `PATCH /:mac` — set `name`/`grp`/`status`/`note`/`flagged` (this is "add from
|
||||
discovered" + "edit" + "name"); promoting = `status:'known'`.
|
||||
- `DELETE /:mac` — remove.
|
||||
- `POST /scan` — run a scan immediately (owner).
|
||||
- `:mac` param validated against a MAC regex.
|
||||
|
||||
### Frontend
|
||||
- `public/views/devices_band.js` — fetch `/api/devices` (grouped) instead of the
|
||||
static file; render the MAC (existing `.dv-mac` style from today's change).
|
||||
- **Discovered review** — a section/panel listing `/api/devices/discovered`, each
|
||||
with an **Add / Edit** form (name + group select + notes) that `PATCH`es to
|
||||
promote; plus inline edit for known devices and an Ignore/Delete action.
|
||||
- **Randomized devices** get a small "randomized MAC" badge (with a tooltip:
|
||||
naming pins it only until the MAC rotates; disable SSID randomization for
|
||||
stable tracking). A `known` device that's been `present=false` for ≥30d shows
|
||||
an "absent Nd" marker for easy manual cleanup (never auto-deleted).
|
||||
- Remove `public/devices.json` (superseded by the DB).
|
||||
|
||||
## Infra setup (one-time, on CT 311)
|
||||
`apt install arp-scan` + grant the binary raw-socket capability so the non-root
|
||||
`void` service user can run it:
|
||||
`setcap cap_net_raw,cap_net_admin+eip /usr/sbin/arp-scan`. Captured in
|
||||
`deploy/README.md`. If the capability/tool is missing, the scan logs a clear
|
||||
error and the feature degrades to "no new discoveries" (existing data still shows).
|
||||
|
||||
## Error handling
|
||||
- `arp-scan` missing / unprivileged / non-zero exit → `runScan` throws; cron
|
||||
catches, logs, leaves the DB untouched (known devices still render).
|
||||
- Empty/garbled scan output → `parseArpScan` returns `[]`; `markAbsent([])` is a
|
||||
no-op guard (never blanket-marks everything absent on a failed scan).
|
||||
- Bad MAC in PATCH → 400 via zod.
|
||||
|
||||
## Testing
|
||||
- **`parseArpScan` / `isRandomizedMac`** — pure unit tests (sample arp-scan
|
||||
output incl. a randomized MAC, banner/footer lines, a malformed line).
|
||||
- **`lan_devices` repo** (vitest + test DB) — `upsertScan` inserts new vs updates
|
||||
existing without clobbering owner fields; `markAbsent` flips presence; promote
|
||||
via `update`.
|
||||
- **API** (supertest) — `/discovered` lists only `new`; `PATCH` promotes/edits;
|
||||
owner-gated.
|
||||
- **Frontend** (jsdom) — band renders groups + MAC from `/api/devices`;
|
||||
discovered panel renders the add/edit form.
|
||||
- **Manual** — `POST /api/devices/scan`, confirm new devices appear, name one,
|
||||
see it move to the band.
|
||||
|
||||
## Out of scope (YAGNI)
|
||||
- Service/port fingerprinting, SNMP/LLDP topology (that's Scanopy's job).
|
||||
- Multi-subnet/VLAN scanning (single `/24`).
|
||||
- Push notifications on new-device discovery.
|
||||
- Stable identity for randomized-MAC devices across rotations (not solvable from
|
||||
L2 alone; the user-side fix is disabling MAC randomization for the SSID).
|
||||
|
||||
## References
|
||||
- Scanopy — github.com/scanopy/scanopy ; scanopy.net (self-hosted discovery/topology, AGPL-3.0).
|
||||
@@ -1,6 +1,6 @@
|
||||
import { canAct } from '../auth/capability.js';
|
||||
import * as pendingChanges from '../db/repos/pending_changes.js';
|
||||
import { ForbiddenError } from './errors.js';
|
||||
import { ForbiddenError, UnauthorizedError } from './errors.js';
|
||||
|
||||
const METHOD_TO_ACTION = { POST: 'create', PATCH: 'update', PUT: 'update', DELETE: 'delete' };
|
||||
|
||||
@@ -15,9 +15,8 @@ export function requireWrite(entity_type) {
|
||||
}
|
||||
|
||||
export function requireOwner(req, _res, next) {
|
||||
if (req.actor?.kind !== 'user') {
|
||||
return next(new ForbiddenError('owner-only endpoint'));
|
||||
}
|
||||
if (!req.actor) return next(new UnauthorizedError('owner-only endpoint'));
|
||||
if (req.actor.kind !== 'user') return next(new ForbiddenError('owner-only endpoint'));
|
||||
next();
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ export class ForbiddenError extends ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends ApiError {
|
||||
constructor(message = 'unauthorized', details) {
|
||||
super('unauthorized', message, 401, details);
|
||||
}
|
||||
}
|
||||
|
||||
export function asyncWrap(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ import { router as littleblueRouter } from './routes/littleblue.js';
|
||||
import { router as aiUsageRouter } from './routes/ai_usage.js';
|
||||
import { router as infraRouter } from './routes/infra.js';
|
||||
import { router as clusterRouter } from './routes/cluster.js';
|
||||
import { router as storageRouter } from './routes/storage.js';
|
||||
import { router as kuttRouter } from './routes/kutt.js';
|
||||
|
||||
export function mountApi(app) {
|
||||
const api = Router();
|
||||
@@ -49,6 +51,7 @@ export function mountApi(app) {
|
||||
api.use('/actions', actionsRouter);
|
||||
api.use('/infra', infraRouter);
|
||||
api.use('/cluster', clusterRouter);
|
||||
api.use('/storage', storageRouter);
|
||||
api.use('/little-blue', littleblueRouter);
|
||||
api.use('/ai-usage', aiUsageRouter);
|
||||
api.use('/projects', projectsRouter);
|
||||
@@ -65,6 +68,7 @@ export function mountApi(app) {
|
||||
api.use('/conversations/:conversation_id/messages', messagesByConvRouter);
|
||||
api.use('/tags', tagsRouter);
|
||||
api.use('/links', linksRouter);
|
||||
api.use('/kutt', kuttRouter);
|
||||
api.use('/pending-changes', pendingChangesRouter);
|
||||
api.use('/audit', auditRouter);
|
||||
api.use('/search', searchRouter);
|
||||
|
||||
97
lib/api/routes/devices.js
Normal file
97
lib/api/routes/devices.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { asyncWrap, errorMiddleware } from '../errors.js';
|
||||
import { requireOwner } from '../cap.js';
|
||||
import { validate } from '../validate.js';
|
||||
import * as devices from '../../db/repos/lan_devices.js';
|
||||
import { isRandomizedMac } from '../../infra/scan.js';
|
||||
import * as agents from '../../db/repos/agents.js';
|
||||
import { timingSafeStrEqual } from '../../auth/safe_compare.js';
|
||||
import { accessOwnerEmail } from '../../auth/cf_access.js';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
// Soft auth: identifies the actor if auth is present but never blocks the request.
|
||||
// Owner-only sub-routes enforce 401/403 via requireOwner.
|
||||
async function softAuth(req, _res, next) {
|
||||
try {
|
||||
const cfEmail = await accessOwnerEmail(req);
|
||||
if (cfEmail) { req.actor = { kind: 'user', id: null }; return next(); }
|
||||
const auth = req.headers.authorization || '';
|
||||
const [scheme, token] = auth.split(' ');
|
||||
if (scheme === 'Bearer' && token) {
|
||||
if (process.env.OWNER_TOKEN && timingSafeStrEqual(token, process.env.OWNER_TOKEN)) {
|
||||
req.actor = { kind: 'user', id: null }; return next();
|
||||
}
|
||||
try {
|
||||
const agent = await agents.verifyToken(token);
|
||||
if (agent) req.actor = { kind: 'agent', id: agent.id, capabilities: agent.capabilities || {}, scopes: agent.scopes || {} };
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
next();
|
||||
}
|
||||
const GROUP_ORDER = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
|
||||
|
||||
router.use(softAuth);
|
||||
|
||||
// GET /devices — known devices grouped for the band (open within the app, like /services).
|
||||
router.get('/', asyncWrap(async (_req, res) => {
|
||||
const byGrp = new Map();
|
||||
for (const d of await devices.listKnown()) {
|
||||
const g = d.grp || 'Flagged';
|
||||
if (!byGrp.has(g)) byGrp.set(g, []);
|
||||
byGrp.get(g).push(d);
|
||||
}
|
||||
const order = [...GROUP_ORDER, ...[...byGrp.keys()].filter(g => !GROUP_ORDER.includes(g))];
|
||||
res.json({ groups: order.filter(g => byGrp.has(g)).map(name => ({ name, devices: byGrp.get(name) })) });
|
||||
}));
|
||||
|
||||
// GET /devices/discovered — review queue (owner).
|
||||
router.get('/discovered', requireOwner, asyncWrap(async (_req, res) => {
|
||||
res.json(await devices.listDiscovered());
|
||||
}));
|
||||
|
||||
const macParam = z.object({ mac: z.string().regex(/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i) });
|
||||
const patchBody = z.object({
|
||||
name: z.string().max(120).optional(),
|
||||
grp: z.enum(['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']).optional(),
|
||||
status: z.enum(['new', 'known', 'ignored']).optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
flagged: z.boolean().optional()
|
||||
});
|
||||
|
||||
// PATCH /devices/:mac — name / edit / promote (owner). This is "add from discovered".
|
||||
router.patch('/:mac', requireOwner, validate({ params: macParam, body: patchBody }), asyncWrap(async (req, res) => {
|
||||
const updated = await devices.update(req.params.mac.toLowerCase(), req.body);
|
||||
if (!updated) return res.status(404).json({ error: { code: 'not_found' } });
|
||||
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).
|
||||
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' } });
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
// POST /devices/scan — run a scan now (owner).
|
||||
router.post('/scan', requireOwner, asyncWrap(async (_req, res) => {
|
||||
const { runDeviceScanCycle } = await import('../../infra/scan_cycle.js');
|
||||
res.json(await runDeviceScanCycle());
|
||||
}));
|
||||
|
||||
router.use(errorMiddleware);
|
||||
37
lib/api/routes/kutt.js
Normal file
37
lib/api/routes/kutt.js
Normal 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
17
lib/api/routes/storage.js
Normal 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);
|
||||
}));
|
||||
@@ -5,6 +5,7 @@ import { enqueue } from '../jobs/queue.js';
|
||||
import { checkAll } from '../health/checker.js';
|
||||
import * as statusRepo from '../db/repos/service_status.js';
|
||||
import * as services from '../db/repos/monitored_services.js';
|
||||
import { runDeviceScanCycle } from '../infra/scan_cycle.js';
|
||||
|
||||
export function startCron() {
|
||||
// Daily at 03:00 local time
|
||||
@@ -35,5 +36,11 @@ export function startCron() {
|
||||
} catch (e) { log.error({ err: e }, 'health check failed'); }
|
||||
});
|
||||
|
||||
// Hourly LAN device scan (staggered off the :00 speedtest)
|
||||
cron.schedule('7 * * * *', async () => {
|
||||
try { await runDeviceScanCycle(); }
|
||||
catch (e) { log.error({ err: e }, 'device scan cycle failed'); }
|
||||
});
|
||||
|
||||
log.info('cron started');
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ INSERT INTO network_hosts (id, kind, name, node, ip, mac, note) VALUES
|
||||
('ct111','lxc','magicmirror','z','192.168.1.224','BC:24:11:6C:D4:E6','MagicMirror (static, was DHCP .27)'),
|
||||
('ct112','lxc','obd2','z','192.168.1.225','BC:24:11:E7:D8:BF','OBD2 telemetry (static, was DHCP .28)'),
|
||||
('ct300','lxc','claude','z','192.168.1.212','BC:24:11:9E:AA:73','Claude Code workspace'),
|
||||
('ct301','lxc','void1','z','192.168.1.11','BC:24:11:4D:B7:CC','Void 1.x legacy'),
|
||||
('ct310','lxc','void2-db','z','192.168.1.215','BC:24:11:49:C6:29','Void 2.0 Postgres'),
|
||||
('ct311','lxc','void2-app','z','192.168.1.216','BC:24:11:9B:B7:3A','Void 2.0 app'),
|
||||
('vm117','vm','Pterodactyl-Deb','z','192.168.1.247','BC:24:11:37:C1:F7','Game panel (static, in-guest)'),
|
||||
|
||||
40
lib/db/migrations/024_lan_devices.sql
Normal file
40
lib/db/migrations/024_lan_devices.sql
Normal file
@@ -0,0 +1,40 @@
|
||||
-- 024_lan_devices.sql
|
||||
-- LAN device inventory keyed by MAC, fed by the hourly arp-scan. Separate from
|
||||
-- network_hosts (homelab guests). New MACs land status='new' for owner review.
|
||||
CREATE TABLE IF NOT EXISTS lan_devices (
|
||||
mac text PRIMARY KEY,
|
||||
ip text,
|
||||
vendor text,
|
||||
name text,
|
||||
grp text,
|
||||
note text,
|
||||
status text NOT NULL DEFAULT 'new', -- new | known | ignored
|
||||
randomized boolean NOT NULL DEFAULT false,
|
||||
flagged boolean NOT NULL DEFAULT false,
|
||||
first_seen timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen timestamptz NOT NULL DEFAULT now(),
|
||||
present boolean NOT NULL DEFAULT true
|
||||
);
|
||||
|
||||
-- Seed from the curated devices.json (MACs lowercased). Named devices -> 'known';
|
||||
-- the unidentified ASUS box -> 'new'. present=false until the first live scan.
|
||||
INSERT INTO lan_devices (mac, ip, vendor, name, grp, status, flagged, randomized, present) VALUES
|
||||
('48:43:dd:fc:2f:84','192.168.1.3','Amazon','Amazon Echo','Smart Home','known',false,false,false),
|
||||
('14:0a:c5:6d:15:6e','192.168.1.4','Amazon','Amazon Echo','Smart Home','known',false,false,false),
|
||||
('c8:47:8c:01:17:70','192.168.1.6','Beken','Smart device','Smart Home','known',false,false,false),
|
||||
('d4:a6:51:12:36:92','192.168.1.23','Tuya','Smart device','Smart Home','known',false,false,false),
|
||||
('ec:4d:3e:36:ef:e1','192.168.1.20','Xiaomi','Xiaomi device','Smart Home','known',false,false,false),
|
||||
('1c:53:f9:bb:32:24','192.168.1.12','Google','Google / Nest','Entertainment','known',false,false,false),
|
||||
('d4:f5:47:95:33:93','192.168.1.14','Google','Google Nest Mini','Entertainment','known',false,false,false),
|
||||
('ec:4d:3e:37:38:8f','192.168.1.18','Google','Google / Nest','Entertainment','known',false,false,false),
|
||||
('48:70:1e:01:4f:7b','192.168.1.29','StreamMagic','Cambridge Audio','Entertainment','known',false,false,false),
|
||||
('08:66:98:b9:cf:f2','192.168.1.43','Apple','Apple TV / HomePod','Entertainment','known',false,false,false),
|
||||
('1c:86:9a:4c:f0:ec','192.168.1.24','Samsung','Samsung TV','Entertainment','known',false,false,false),
|
||||
('5a:da:61:7a:0f:12','192.168.1.171','Samsung','Galaxy Tab S4','Personal','known',false,true,false),
|
||||
('1c:57:dc:70:e8:2d','192.168.1.133','Apple','Apple device','Personal','known',false,false,false),
|
||||
('a0:d0:5b:04:70:96','192.168.1.61','Samsung','Samsung device','Personal','known',false,false,false),
|
||||
('14:eb:b6:40:7e:93','192.168.1.10','TP-Link','TP-Link device','Personal','known',false,false,false),
|
||||
('44:a5:6e:68:d0:e9','192.168.1.1','Netgear','Gateway / Router','Network','known',false,false,false),
|
||||
('bc:a5:11:3e:06:88','192.168.1.13','Netgear (Orbi mesh)','Orbi Satellite','Network','known',false,false,false),
|
||||
('24:4b:fe:8e:09:a4','192.168.1.15','ASUSTek','ASUS device','Flagged','new',true,false,false)
|
||||
ON CONFLICT (mac) DO NOTHING;
|
||||
89
lib/db/repos/lan_devices.js
Normal file
89
lib/db/repos/lan_devices.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { pool } from '../pool.js';
|
||||
|
||||
const COLS = 'mac, ip, vendor, name, grp, note, status, randomized, flagged, first_seen, last_seen, present';
|
||||
|
||||
export async function listKnown() {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ${COLS} FROM lan_devices WHERE status='known' ORDER BY grp, name NULLS LAST, ip`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function listDiscovered() {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ${COLS} FROM lan_devices WHERE status='new' ORDER BY last_seen DESC`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function get(mac) {
|
||||
const { rows: [r] } = await pool.query(`SELECT ${COLS} FROM lan_devices WHERE mac=$1`, [mac]);
|
||||
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
|
||||
// WITHOUT touching owner-curated name/grp/status/flagged.
|
||||
export async function upsertScan(rows) {
|
||||
for (const r of rows) {
|
||||
await pool.query(
|
||||
`INSERT INTO lan_devices (mac, ip, vendor, randomized, status, present, first_seen, last_seen)
|
||||
VALUES ($1,$2,$3,$4,'new',true,now(),now())
|
||||
ON CONFLICT (mac) DO UPDATE SET
|
||||
ip = EXCLUDED.ip,
|
||||
vendor = COALESCE(NULLIF(EXCLUDED.vendor,''), lan_devices.vendor),
|
||||
last_seen = now(), present = true`,
|
||||
[r.mac, r.ip ?? null, r.vendor ?? null, !!r.randomized]);
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
// Mark devices not in the latest scan as absent. Empty input is a no-op so a
|
||||
// failed/empty scan can never blanket-mark everything offline.
|
||||
export async function markAbsent(seenMacs) {
|
||||
if (!seenMacs || !seenMacs.length) return 0;
|
||||
const { rowCount } = await pool.query(
|
||||
`UPDATE lan_devices SET present=false WHERE present=true AND NOT (mac = ANY($1::text[]))`,
|
||||
[seenMacs]);
|
||||
return rowCount;
|
||||
}
|
||||
|
||||
// Reap unreviewed + absent rows past their TTL. Never touches known/ignored.
|
||||
export async function prune() {
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM lan_devices WHERE status='new' AND present=false AND (
|
||||
(randomized AND last_seen < now() - interval '24 hours') OR
|
||||
(NOT randomized AND last_seen < now() - interval '14 days'))`);
|
||||
return rowCount;
|
||||
}
|
||||
|
||||
const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged'];
|
||||
export async function update(mac, patch) {
|
||||
const sets = [], vals = [];
|
||||
for (const k of PATCHABLE) {
|
||||
if (patch[k] !== undefined) { vals.push(patch[k]); sets.push(`${k}=$${vals.length}`); }
|
||||
}
|
||||
if (!sets.length) return get(mac);
|
||||
vals.push(mac);
|
||||
const { rows: [r] } = await pool.query(
|
||||
`UPDATE lan_devices SET ${sets.join(', ')} WHERE mac=$${vals.length} RETURNING ${COLS}`, vals);
|
||||
return r || null;
|
||||
}
|
||||
|
||||
export async function remove(mac) {
|
||||
const { rowCount } = await pool.query(`DELETE FROM lan_devices WHERE mac=$1`, [mac]);
|
||||
return rowCount > 0;
|
||||
}
|
||||
31
lib/infra/scan.js
Normal file
31
lib/infra/scan.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Decoupled LAN scanner: pure parser + a thin arp-scan runner (exec injected
|
||||
// for tests). The repo/cron own persistence — this module only produces rows.
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const pexec = promisify(execFile);
|
||||
|
||||
// A locally-administered (randomized) MAC has bit 0x02 set in its first octet.
|
||||
export function isRandomizedMac(mac) {
|
||||
const first = parseInt(String(mac).split(':')[0], 16);
|
||||
return Number.isFinite(first) && (first & 0x02) === 0x02;
|
||||
}
|
||||
|
||||
// Keep only "IP<ws>MAC<ws>[vendor]" lines; ignore banner/footer/garbage.
|
||||
export function parseArpScan(text) {
|
||||
const re = /^(\d{1,3}(?:\.\d{1,3}){3})\s+([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})\s*(.*)$/;
|
||||
const out = [];
|
||||
for (const line of String(text).split('\n')) {
|
||||
const m = line.match(re);
|
||||
if (!m) continue;
|
||||
const mac = m[2].toLowerCase();
|
||||
out.push({ ip: m[1], mac, vendor: m[3].trim(), randomized: isRandomizedMac(mac) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Run arp-scan on the local /24. `exec(file, args) -> {stdout}` injected for tests.
|
||||
export async function runScan({ exec = pexec } = {}) {
|
||||
const { stdout } = await exec('arp-scan', ['--localnet', '--plain', '--retry=2']);
|
||||
return parseArpScan(stdout);
|
||||
}
|
||||
25
lib/infra/scan_cycle.js
Normal file
25
lib/infra/scan_cycle.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// One discovery cycle: scan → drop homelab guests → upsert → mark-absent → prune.
|
||||
// Homelab containers/hosts are excluded from the IoT/personal devices band — they
|
||||
// live in the network_hosts inventory, not here. We drop any MAC that's in
|
||||
// network_hosts OR carries the Proxmox guest OUI (bc:24:11). Deps injected for
|
||||
// tests. Prune only runs after a successful, non-empty scan.
|
||||
import { runScan } from './scan.js';
|
||||
import * as devices from '../db/repos/lan_devices.js';
|
||||
import * as netHosts from '../db/repos/network_hosts.js';
|
||||
import { log } from '../log.js';
|
||||
|
||||
const HOMELAB_OUI = 'bc:24:11'; // Proxmox auto-generated guest MAC prefix
|
||||
|
||||
export async function runDeviceScanCycle({ scan = runScan, repo = devices, hosts = netHosts } = {}) {
|
||||
const inventory = new Set((await hosts.all()).map(h => String(h.mac || '').toLowerCase()));
|
||||
const rows = (await scan()).filter(r => !inventory.has(r.mac) && !r.mac.startsWith(HOMELAB_OUI));
|
||||
if (!rows.length) {
|
||||
log.warn('device scan found no non-homelab hosts; skipping upsert/prune');
|
||||
return { seen: 0 };
|
||||
}
|
||||
await repo.upsertScan(rows);
|
||||
await repo.markAbsent(rows.map(r => r.mac));
|
||||
const pruned = await repo.prune();
|
||||
log.info({ seen: rows.length, pruned }, 'device scan cycle complete');
|
||||
return { seen: rows.length, pruned };
|
||||
}
|
||||
31
lib/links/kutt.js
Normal file
31
lib/links/kutt.js
Normal 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
94
lib/proxmox/storage.js
Normal 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() };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.0.0",
|
||||
"version": "2.4.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -27,6 +27,9 @@ const VIEWS = {
|
||||
terminal: () => import('./views/terminal.js'),
|
||||
timelapse: () => import('./views/timelapse.js'),
|
||||
'ai-usage': () => import('./views/aiusage.js'),
|
||||
obd2: () => import('./views/obd2.js'),
|
||||
links: () => import('./views/links.js'),
|
||||
mirror: () => import('./views/mirror.js'),
|
||||
settings: () => import('./views/settings.js'),
|
||||
jobs: () => import('./views/jobs.js')
|
||||
};
|
||||
|
||||
@@ -131,7 +131,10 @@ export function renderSidebar(root) {
|
||||
el('div', { class: 'sb-section' },
|
||||
el('div', { class: 'sb-title' }, 'Apps'),
|
||||
navItem('Timelapse', '/timelapse'),
|
||||
navItem('AI Usage', '/ai-usage')
|
||||
navItem('AI Usage', '/ai-usage'),
|
||||
navItem('OBD2', '/obd2'),
|
||||
navItem('Links', '/links'),
|
||||
navItem('MagicMirror', '/mirror')
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"note": "Auto-scanned LAN devices (ARP/nmap 2026-06-02). Separate from Little Blue's homelab services. Vendor-guessed; identification + live discovery to come.",
|
||||
"groups": [
|
||||
{ "name": "Smart Home", "devices": [
|
||||
{ "name": "Amazon Echo", "ip": "192.168.1.3", "vendor": "Amazon" },
|
||||
{ "name": "Amazon Echo", "ip": "192.168.1.4", "vendor": "Amazon" },
|
||||
{ "name": "Smart device", "ip": "192.168.1.6", "vendor": "Beken" },
|
||||
{ "name": "Smart device", "ip": "192.168.1.23", "vendor": "Tuya" },
|
||||
{ "name": "Xiaomi device", "ip": "192.168.1.20", "vendor": "Xiaomi" }
|
||||
]},
|
||||
{ "name": "Entertainment", "devices": [
|
||||
{ "name": "Google / Nest", "ip": "192.168.1.12", "vendor": "Google" },
|
||||
{ "name": "Google / Nest", "ip": "192.168.1.14", "vendor": "Google" },
|
||||
{ "name": "Google / Nest", "ip": "192.168.1.18", "vendor": "Google" },
|
||||
{ "name": "Google / Nest", "ip": "192.168.1.21", "vendor": "Google" },
|
||||
{ "name": "Google / Nest", "ip": "192.168.1.22", "vendor": "Google" },
|
||||
{ "name": "Cambridge Audio", "ip": "192.168.1.29", "vendor": "StreamMagic" },
|
||||
{ "name": "Apple TV / HomePod", "ip": "192.168.1.43", "vendor": "Apple" },
|
||||
{ "name": "Samsung TV", "ip": "192.168.1.24", "vendor": "Samsung" }
|
||||
]},
|
||||
{ "name": "Personal", "devices": [
|
||||
{ "name": "Apple device", "ip": "192.168.1.133", "vendor": "Apple" },
|
||||
{ "name": "Samsung device", "ip": "192.168.1.61", "vendor": "Samsung" },
|
||||
{ "name": "TP-Link device", "ip": "192.168.1.10", "vendor": "TP-Link" }
|
||||
]},
|
||||
{ "name": "Network", "devices": [
|
||||
{ "name": "Gateway / Router", "ip": "192.168.1.1", "vendor": "Netgear" }
|
||||
]},
|
||||
{ "name": "Flagged / Unknown", "devices": [
|
||||
{ "name": "Rogue OpenWrt", "ip": "192.168.1.13", "vendor": "Netgear · uhttpd", "flag": true },
|
||||
{ "name": "ASUS device", "ip": "192.168.1.15", "vendor": "ASUSTek", "flag": true },
|
||||
{ "name": "Unknown", "ip": "192.168.1.34", "vendor": "randomized MAC", "flag": true },
|
||||
{ "name": "Unknown", "ip": "192.168.1.35", "vendor": "unknown", "flag": true },
|
||||
{ "name": "Unknown", "ip": "192.168.1.51", "vendor": "randomized MAC", "flag": true },
|
||||
{ "name": "Unknown", "ip": "192.168.1.171", "vendor": "randomized MAC", "flag": true }
|
||||
]}
|
||||
]
|
||||
}
|
||||
@@ -28,6 +28,9 @@ const ROUTES = [
|
||||
{ name: 'terminal', re: /^\/terminal$/, keys: [] },
|
||||
{ name: 'timelapse', re: /^\/timelapse$/, keys: [] },
|
||||
{ name: 'ai-usage', re: /^\/ai-usage$/, keys: [] },
|
||||
{ name: 'obd2', re: /^\/obd2$/, keys: [] },
|
||||
{ name: 'links', re: /^\/links$/, keys: [] },
|
||||
{ name: 'mirror', re: /^\/mirror$/, keys: [] },
|
||||
{ name: 'settings', re: /^\/settings$/, keys: [] },
|
||||
{ name: 'jobs', re: /^\/jobs$/, keys: [] },
|
||||
{ name: 'home', re: /^\/?$/, keys: [] }
|
||||
|
||||
@@ -562,9 +562,35 @@ 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-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; }
|
||||
.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.flag { border-color: var(--bad); background: #1a1012; }
|
||||
.dv-tile.flag .dv-nm { color: var(--bad); }
|
||||
.dv-tile.absent { opacity: .5; }
|
||||
.dv-discovered { border: 1px solid var(--accent-dim); border-radius: 6px; padding: 10px 12px; margin: 10px 0; background: var(--accent-soft); }
|
||||
.dv-disc-hd { font-family: var(--font-display); font-size: 12px; text-transform: uppercase; letter-spacing: .1em; color: var(--accent); margin-bottom: 8px; }
|
||||
.dv-disc-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 5px 0; }
|
||||
.dv-disc-row .dv-edit-name { flex: 1 1 120px; }
|
||||
.dv-disc-row .dv-add { background: var(--accent-dim); color: var(--text); border: 1px solid var(--accent); border-radius: 3px; padding: 4px 10px; cursor: pointer; font-family: var(--font-ui); font-size: 12px; }
|
||||
.dv-disc-row .dv-add:hover { background: var(--accent); color: var(--bg); }
|
||||
.dv-disc-row .ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); border-radius: 3px; padding: 4px 10px; cursor: pointer; font-size: 12px; }
|
||||
|
||||
/* ===== Discovered services + scan (Plan: DB-backed registry) ===== */
|
||||
.lb-scan { background: var(--accent-soft); border: 1px solid var(--accent-dim); color: var(--accent);
|
||||
@@ -609,3 +635,14 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
||||
.sv-tray-chip:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Storage card (sv-cluster container) — warn dot + capacity meter + subheader */
|
||||
.sv-cluster .status-warn .dot { background: var(--warn); box-shadow: 0 0 7px var(--warn); }
|
||||
.sv-cluster .ok { color: var(--ok); }
|
||||
.sv-cluster .bad { color: var(--bad); }
|
||||
.sv-cluster .st-meter { height: 3px; background: var(--accent-soft); border-radius: 2px; margin: 3px 0 9px; overflow: hidden; }
|
||||
.sv-cluster .st-fill { height: 100%; border-radius: 2px; }
|
||||
.sv-cluster .st-fill.ok { background: var(--ok); }
|
||||
.sv-cluster .st-fill.warn { background: var(--warn); }
|
||||
.sv-cluster .st-fill.bad { background: var(--bad); }
|
||||
.sv-cluster .sv-subhdr { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; margin: 11px 0 5px; font-family: var(--font-mono); }
|
||||
|
||||
62
public/views/cards/storage.js
Normal file
62
public/views/cards/storage.js
Normal 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; }
|
||||
};
|
||||
@@ -1,35 +1,126 @@
|
||||
// Network Devices band — IoT / personal / unknown LAN devices, kept SEPARATE
|
||||
// from Little Blue's homelab-service health band. Read-only, static source
|
||||
// (public/devices.json), no health probing. Live discovery comes later.
|
||||
import { el, mount } from '../dom.js';
|
||||
// Network Devices band — DB-backed (GET /api/devices). Shows IP+MAC+vendor,
|
||||
// a randomized-MAC badge, and an owner "Discovered" review panel to name/promote
|
||||
// newly-seen devices. Kept SEPARATE from Little Blue's homelab-service band.
|
||||
import { el, mount, clear } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
let host;
|
||||
const GROUPS = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
|
||||
|
||||
function tile(d) {
|
||||
const t = el('div', { class: 'dv-tile' + (d.flagged ? ' flag' : '') + (d.present === false ? ' absent' : '') });
|
||||
function view() {
|
||||
clear(t);
|
||||
const edit = el('button', { class: 'dv-edit-btn', title: 'Edit device' }, '✎');
|
||||
edit.onclick = editMode;
|
||||
mount(t,
|
||||
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' : '')),
|
||||
d.mac ? edit : null);
|
||||
}
|
||||
function editMode() {
|
||||
clear(t);
|
||||
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 save = el('button', { class: 'dv-add' }, 'Save');
|
||||
save.onclick = async () => { await api.patch('/api/devices/' + d.mac, { name: nameI.value.trim() || null, grp: grpS.value }); load(); };
|
||||
const del = el('button', { class: 'ghost dv-ignore' }, 'Delete');
|
||||
del.onclick = async () => { await api.del('/api/devices/' + d.mac); load(); };
|
||||
const cancel = el('button', { class: 'ghost' }, 'Cancel');
|
||||
cancel.onclick = view;
|
||||
mount(t, el('span', { class: 'dv-mac' }, d.mac), nameI, grpS, save, del, cancel);
|
||||
}
|
||||
view();
|
||||
return t;
|
||||
}
|
||||
|
||||
function discoveredRow(d, onDone) {
|
||||
const nameI = el('input', { class: 'dv-edit-name', placeholder: d.vendor || 'name', value: d.name || '' });
|
||||
const grpS = el('select', { class: 'dv-edit-grp' }, ...GROUPS.map(g => el('option', { value: g }, g)));
|
||||
const add = el('button', { class: 'dv-add' }, 'Add');
|
||||
add.onclick = async () => {
|
||||
await api.patch('/api/devices/' + d.mac, { name: nameI.value.trim() || null, grp: grpS.value, status: 'known', flagged: false });
|
||||
onDone();
|
||||
};
|
||||
const ignore = el('button', { class: 'ghost dv-ignore' }, 'Ignore');
|
||||
ignore.onclick = async () => { await api.patch('/api/devices/' + d.mac, { status: 'ignored' }); onDone(); };
|
||||
return el('div', { class: 'dv-disc-row' },
|
||||
el('span', { class: 'dv-ip' }, d.ip || ''),
|
||||
el('span', { class: 'dv-mac' }, d.mac + (d.randomized ? ' · randomized' : '')),
|
||||
el('span', { class: 'dv-vendor' }, d.vendor || ''),
|
||||
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() {
|
||||
if (!host) return;
|
||||
try {
|
||||
const res = await fetch('/devices.json');
|
||||
const data = await res.json();
|
||||
const total = data.groups.reduce((n, g) => n + g.devices.length, 0);
|
||||
const sections = data.groups.map(g =>
|
||||
el('div', { class: 'dv-section' },
|
||||
el('div', { class: 'dv-group' },
|
||||
el('span', { class: 'gname' }, g.name),
|
||||
el('span', { class: 'gcount' }, String(g.devices.length)),
|
||||
el('span', { class: 'line' })),
|
||||
el('div', { class: 'dv-tiles' }, g.devices.map(d =>
|
||||
el('div', { class: 'dv-tile' + (d.flag ? ' flag' : '') },
|
||||
el('span', { class: 'dv-nm' }, d.name),
|
||||
el('span', { class: 'dv-ip' }, d.ip),
|
||||
el('span', { class: 'dv-vendor' }, d.vendor || ''))))));
|
||||
mount(host,
|
||||
el('div', { class: 'dv-hd' },
|
||||
el('div', { class: 'dv-title' }, 'Network · Devices'),
|
||||
el('span', { class: 'dv-count' }, `${total} on the LAN`)),
|
||||
el('div', { class: 'dv-note' }, data.note || ''),
|
||||
sections);
|
||||
} catch {
|
||||
mount(host, el('span', { class: 'muted' }, 'Device list unavailable'));
|
||||
}
|
||||
let data, discovered = [];
|
||||
try { data = await api.get('/api/devices'); } catch { mount(host, el('div', { class: 'dv-note' }, 'Devices unavailable')); return; }
|
||||
try { discovered = await api.get('/api/devices/discovered'); } catch { /* owner-only; ignore for non-owner */ }
|
||||
|
||||
const total = data.groups.reduce((n, g) => n + g.devices.length, 0);
|
||||
const sections = data.groups.map(g =>
|
||||
el('div', { class: 'dv-section' },
|
||||
el('div', { class: 'dv-group' },
|
||||
el('span', { class: 'gname' }, g.name),
|
||||
el('span', { class: 'gcount' }, String(g.devices.length)),
|
||||
el('span', { class: 'line' })),
|
||||
el('div', { class: 'dv-tiles' }, g.devices.map(tile))));
|
||||
|
||||
const discPanel = discovered.length
|
||||
? el('div', { class: 'dv-discovered' },
|
||||
el('div', { class: 'dv-disc-hd' }, `Discovered · ${discovered.length} awaiting review`),
|
||||
...discovered.map(d => discoveredRow(d, load)))
|
||||
: 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);
|
||||
mount(host,
|
||||
el('div', { class: 'dv-hd' },
|
||||
el('div', { class: 'dv-title' }, 'Network · Devices'),
|
||||
el('span', { class: 'dv-count' }, `${total} known${discovered.length ? ` · ${discovered.length} new` : ''}`),
|
||||
addToggle, scanBtn),
|
||||
addForm,
|
||||
...sections,
|
||||
discPanel);
|
||||
}
|
||||
export function renderDevicesBand(el_) { host = el_; load(); }
|
||||
|
||||
export function renderDevicesBand(root) { host = root; return load(); }
|
||||
export function stopDevicesBand() { host = null; }
|
||||
|
||||
39
public/views/links.js
Normal file
39
public/views/links.js
Normal 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
6
public/views/mirror.js
Normal 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/'
|
||||
});
|
||||
27
public/views/obd2.js
Normal file
27
public/views/obd2.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// #/obd2 — Apps rail placeholder for the OBD2 Telemetry project (parked).
|
||||
// No records UI is deployed yet, so this links into the project + wiki instead of
|
||||
// embedding. Swap to embedView({ src: 'https://obd2.hynesy.com/' }) once the
|
||||
// LubeLogger/Tracktor dashboard is up.
|
||||
import { el, mount } from '../dom.js';
|
||||
import { navigate } from '../router.js';
|
||||
|
||||
const WIKI = '/page/bea9d582-44a2-4eec-a1ba-69ade15d3a73';
|
||||
const PROJECT = '/project/02fc5b4c-12f4-4d0c-8220-6b053da71c46';
|
||||
|
||||
export async function render(main) {
|
||||
mount(main,
|
||||
el('div', { class: 'term-bar' },
|
||||
el('span', { class: 'term-title' }, '◆ OBD2 Telemetry'),
|
||||
el('span', { class: 'muted', style: { fontSize: '11px' } }, 'project · parked, being set up')
|
||||
),
|
||||
el('div', { class: 'card', style: { maxWidth: '760px' } },
|
||||
el('h3', {}, 'OBD2 Telemetry — being set up'),
|
||||
el('p', { class: 'muted' }, 'Capture vehicle records from the car’s OBD2 port into the homelab (CT 112 · Postgres + TimescaleDB) with a maintenance/records UI. The capture pipeline is being rebuilt and the records UI isn’t deployed yet — nothing to embed here yet.'),
|
||||
el('p', {}, 'Plan: AndrOBD (F-Droid) + the BT ELM327 → CSV/MQTT → Timescale; WiCAN hardware later; LubeLogger / Tracktor for the UI (this tile will then embed it).'),
|
||||
el('div', { style: { display: 'flex', gap: '8px', marginTop: '14px' } },
|
||||
el('button', { class: 'primary', onclick: () => navigate(PROJECT) }, 'Project + tasks'),
|
||||
el('button', { class: 'ghost', onclick: () => navigate(WIKI) }, 'Research / wiki')
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,9 @@ import search from './cards/search.js';
|
||||
import speedtest from './cards/speedtest.js';
|
||||
import aiUsage from './cards/ai_usage.js';
|
||||
import cluster from './cards/cluster.js';
|
||||
import storage from './cards/storage.js';
|
||||
|
||||
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]));
|
||||
|
||||
let active = []; // mounted cards needing stop()
|
||||
|
||||
@@ -7,13 +7,14 @@ import * as queue from './lib/jobs/queue.js';
|
||||
import { registerWorkers } from './lib/jobs/index.js';
|
||||
import { router as ingestRouter } from './lib/api/routes/ingest.js';
|
||||
import { router as iconsRouter } from './lib/api/routes/icons.js';
|
||||
import { router as devicesRouter } from './lib/api/routes/devices.js';
|
||||
import { startCron } from './lib/cron/index.js';
|
||||
import { seedFromConfig } from './lib/health/registry.js';
|
||||
import { mcpAuth } from './lib/api/middleware/mcp_auth.js';
|
||||
import { handleMcp } from './lib/mcp/http.js';
|
||||
import httpProxy from 'http-proxy';
|
||||
|
||||
const VERSION = '2.0.0';
|
||||
const VERSION = '2.4.0';
|
||||
|
||||
// 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
|
||||
@@ -52,6 +53,10 @@ export function createApp() {
|
||||
// slugs are sanitized to [a-z0-9-] to prevent path traversal.
|
||||
app.use('/api/icons', iconsRouter);
|
||||
|
||||
// /api/devices — band data is public (like the static devices.json it replaces);
|
||||
// discovered/edit/scan sub-routes use requireOwner (401/403) internally.
|
||||
app.use('/api/devices', devicesRouter);
|
||||
|
||||
app.get('/health', async (_req, res) => {
|
||||
let db_ok = false;
|
||||
try {
|
||||
|
||||
58
tests/api/devices.test.js
Normal file
58
tests/api/devices.test.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// tests/api/devices.test.js
|
||||
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../server.js';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
|
||||
let app;
|
||||
const owner = r => r.set('Authorization', 'Bearer test-token');
|
||||
beforeAll(async () => { process.env.OWNER_TOKEN = 'test-token'; app = createApp(); });
|
||||
beforeEach(async () => { await resetDb(); await migrateUp(); });
|
||||
|
||||
describe('/api/devices', () => {
|
||||
it('GET / returns known devices grouped', async () => {
|
||||
const res = await request(app).get('/api/devices');
|
||||
expect(res.status).toBe(200);
|
||||
const names = res.body.groups.map(g => g.name);
|
||||
expect(names).toContain('Network');
|
||||
const net = res.body.groups.find(g => g.name === 'Network');
|
||||
expect(net.devices.some(d => d.name === 'Orbi Satellite')).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /discovered requires owner and lists new devices', async () => {
|
||||
expect((await request(app).get('/api/devices/discovered')).status).toBe(401);
|
||||
const res = await owner(request(app).get('/api/devices/discovered'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.some(d => d.mac === '24:4b:fe:8e:09:a4')).toBe(true);
|
||||
});
|
||||
|
||||
it('PATCH /:mac promotes + names (owner)', async () => {
|
||||
const res = await owner(request(app).patch('/api/devices/24:4b:fe:8e:09:a4'))
|
||||
.send({ name: 'ASUS Router', grp: 'Network', status: 'known' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('ASUS Router');
|
||||
expect((await owner(request(app).get('/api/devices/discovered'))).body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('PATCH rejects a bad MAC', async () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
40
tests/api/kutt.test.js
Normal file
40
tests/api/kutt.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
80
tests/frontend/devices_band.test.js
Normal file
80
tests/frontend/devices_band.test.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// tests/frontend/devices_band.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) => {
|
||||
if (p === '/api/devices') return { groups: [ { name: 'Network', devices: [
|
||||
{ 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 [
|
||||
{ mac: '24:4b:fe:8e:09:a4', ip: '192.168.1.15', vendor: 'ASUSTek', randomized: false, present: true } ];
|
||||
return {};
|
||||
}),
|
||||
patch: vi.fn(async () => ({})),
|
||||
post: vi.fn(async () => ({})),
|
||||
del: vi.fn(async () => ({}))
|
||||
}
|
||||
}));
|
||||
|
||||
import { api } from '../../public/api.js';
|
||||
|
||||
let renderDevicesBand;
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="h"></div></body></html>', { url: 'http://localhost/' });
|
||||
global.window = dom.window; global.document = dom.window.document; global.Node = dom.window.Node;
|
||||
({ renderDevicesBand } = await import('../../public/views/devices_band.js'));
|
||||
});
|
||||
afterAll(() => { delete global.window; delete global.document; delete global.Node; });
|
||||
|
||||
describe('devices band', () => {
|
||||
it('renders known devices from the API with MAC, and a discovered count', async () => {
|
||||
const host = document.getElementById('h');
|
||||
await renderDevicesBand(host);
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
expect(host.textContent).toContain('Orbi Satellite');
|
||||
expect(host.querySelector('.dv-mac').textContent).toBe('bc:a5:11:3e:06:88');
|
||||
expect(host.querySelector('.dv-discovered')).not.toBeNull(); // review affordance present
|
||||
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');
|
||||
});
|
||||
});
|
||||
28
tests/frontend/links_view.test.js
Normal file
28
tests/frontend/links_view.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
35
tests/infra/scan.test.js
Normal file
35
tests/infra/scan.test.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// tests/infra/scan.test.js
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isRandomizedMac, parseArpScan, runScan } from '../../lib/infra/scan.js';
|
||||
|
||||
const SAMPLE = [
|
||||
'Interface: eth0, type: EN10MB, MAC: bc:24:11:9b:b7:3a, IPv4: 192.168.1.216',
|
||||
'Starting arp-scan 1.10.0',
|
||||
'192.168.1.13\tbc:a5:11:3e:06:88\tNetgear',
|
||||
'192.168.1.171\t5a:da:61:7a:0f:12\t(Unknown)',
|
||||
'192.168.1.1\t44:A5:6E:68:D0:E9\tNetgear Inc.',
|
||||
'garbage line that is not a host',
|
||||
'',
|
||||
'3 packets received by filter, 0 packets dropped'
|
||||
].join('\n');
|
||||
|
||||
describe('scan parsing', () => {
|
||||
it('isRandomizedMac flags locally-administered MACs', () => {
|
||||
expect(isRandomizedMac('5a:da:61:7a:0f:12')).toBe(true); // 0x5a & 0x02
|
||||
expect(isRandomizedMac('bc:a5:11:3e:06:88')).toBe(false); // 0xbc & 0x02 == 0
|
||||
expect(isRandomizedMac('44:A5:6E:68:D0:E9')).toBe(false);
|
||||
});
|
||||
|
||||
it('parseArpScan keeps only host lines, lowercases MAC, flags randomized', () => {
|
||||
const rows = parseArpScan(SAMPLE);
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows[0]).toEqual({ ip: '192.168.1.13', mac: 'bc:a5:11:3e:06:88', vendor: 'Netgear', randomized: false });
|
||||
expect(rows[1]).toEqual({ ip: '192.168.1.171', mac: '5a:da:61:7a:0f:12', vendor: '(Unknown)', randomized: true });
|
||||
expect(rows[2].mac).toBe('44:a5:6e:68:d0:e9'); // lowercased
|
||||
});
|
||||
|
||||
it('runScan parses the injected exec stdout', async () => {
|
||||
const rows = await runScan({ exec: async () => ({ stdout: SAMPLE }) });
|
||||
expect(rows.map(r => r.ip)).toEqual(['192.168.1.13', '192.168.1.171', '192.168.1.1']);
|
||||
});
|
||||
});
|
||||
47
tests/infra/scan_cycle.test.js
Normal file
47
tests/infra/scan_cycle.test.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// tests/infra/scan_cycle.test.js
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { runDeviceScanCycle } from '../../lib/infra/scan_cycle.js';
|
||||
|
||||
function fakeRepo() {
|
||||
return {
|
||||
upsertScan: vi.fn(async r => r.length),
|
||||
markAbsent: vi.fn(async () => 1),
|
||||
prune: vi.fn(async () => 2)
|
||||
};
|
||||
}
|
||||
const noHosts = { all: async () => [] };
|
||||
|
||||
describe('runDeviceScanCycle', () => {
|
||||
it('scan→upsert→markAbsent→prune on a non-empty scan', async () => {
|
||||
const repo = fakeRepo();
|
||||
const scan = vi.fn(async () => [{ mac: 'aa:bb:cc:dd:ee:ff', ip: '1.2.3.4', vendor: 'x', randomized: false }]);
|
||||
const res = await runDeviceScanCycle({ scan, repo, hosts: noHosts });
|
||||
expect(repo.upsertScan).toHaveBeenCalledOnce();
|
||||
expect(repo.markAbsent).toHaveBeenCalledWith(['aa:bb:cc:dd:ee:ff']);
|
||||
expect(repo.prune).toHaveBeenCalledOnce();
|
||||
expect(res).toEqual({ seen: 1, pruned: 2 });
|
||||
});
|
||||
|
||||
it('skips upsert/prune when the scan returns nothing', async () => {
|
||||
const repo = fakeRepo();
|
||||
const res = await runDeviceScanCycle({ scan: async () => [], repo, hosts: noHosts });
|
||||
expect(repo.upsertScan).not.toHaveBeenCalled();
|
||||
expect(repo.prune).not.toHaveBeenCalled();
|
||||
expect(res).toEqual({ seen: 0 });
|
||||
});
|
||||
|
||||
it('excludes homelab guests (network_hosts inventory + bc:24:11 OUI)', async () => {
|
||||
const repo = fakeRepo();
|
||||
const hosts = { all: async () => [{ mac: 'BC:24:11:9B:B7:3A' }, { mac: '00:E0:4C:0F:36:00' }] };
|
||||
const scan = async () => [
|
||||
{ mac: 'bc:24:11:9b:b7:3a', ip: '192.168.1.216', vendor: '', randomized: false }, // in inventory
|
||||
{ mac: 'bc:24:11:de:ad:00', ip: '192.168.1.99', vendor: '', randomized: false }, // proxmox OUI
|
||||
{ mac: '00:e0:4c:0f:36:00', ip: '192.168.1.124', vendor: '', randomized: false }, // PVE host in inventory
|
||||
{ mac: 'd8:eb:46:77:37:a8', ip: '192.168.1.25', vendor: 'Google', randomized: false } // real device
|
||||
];
|
||||
const res = await runDeviceScanCycle({ scan, repo, hosts });
|
||||
expect(res.seen).toBe(1);
|
||||
expect(repo.upsertScan).toHaveBeenCalledWith([expect.objectContaining({ mac: 'd8:eb:46:77:37:a8' })]);
|
||||
expect(repo.markAbsent).toHaveBeenCalledWith(['d8:eb:46:77:37:a8']);
|
||||
});
|
||||
});
|
||||
23
tests/links/kutt.test.js
Normal file
23
tests/links/kutt.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
70
tests/proxmox/storage.test.js
Normal file
70
tests/proxmox/storage.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
63
tests/repos/lan_devices.test.js
Normal file
63
tests/repos/lan_devices.test.js
Normal file
@@ -0,0 +1,63 @@
|
||||
// tests/repos/lan_devices.test.js
|
||||
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
import { pool } from '../../lib/db/pool.js';
|
||||
import * as repo from '../../lib/db/repos/lan_devices.js';
|
||||
|
||||
beforeAll(async () => { await resetDb(); await migrateUp(); });
|
||||
beforeEach(async () => { await resetDb(); await migrateUp(); });
|
||||
|
||||
describe('lan_devices repo', () => {
|
||||
it('seed: 17 known, 1 discovered (ASUS)', async () => {
|
||||
expect(await repo.listKnown()).toHaveLength(17);
|
||||
const disc = await repo.listDiscovered();
|
||||
expect(disc).toHaveLength(1);
|
||||
expect(disc[0].mac).toBe('24:4b:fe:8e:09:a4');
|
||||
expect(disc[0].flagged).toBe(true);
|
||||
});
|
||||
|
||||
it('upsertScan inserts unseen as new, updates known IP without clobbering name', async () => {
|
||||
await repo.upsertScan([
|
||||
{ mac: 'aa:bb:cc:dd:ee:ff', ip: '192.168.1.99', vendor: 'NewCo', randomized: false }, // new
|
||||
{ mac: 'bc:a5:11:3e:06:88', ip: '192.168.1.77', vendor: 'Netgear', randomized: false } // known Orbi, IP changed
|
||||
]);
|
||||
const orbi = await repo.get('bc:a5:11:3e:06:88');
|
||||
expect(orbi.ip).toBe('192.168.1.77'); // ip updated
|
||||
expect(orbi.name).toBe('Orbi Satellite'); // name preserved
|
||||
expect(orbi.status).toBe('known'); // status preserved
|
||||
expect(orbi.present).toBe(true);
|
||||
const fresh = await repo.get('aa:bb:cc:dd:ee:ff');
|
||||
expect(fresh.status).toBe('new');
|
||||
});
|
||||
|
||||
it('markAbsent flips present for unseen; empty list is a no-op', async () => {
|
||||
await repo.upsertScan([{ mac: 'aa:bb:cc:dd:ee:ff', ip: '192.168.1.99', vendor: '', randomized: false }]);
|
||||
await repo.markAbsent(['aa:bb:cc:dd:ee:ff']); // only this one seen
|
||||
expect((await repo.get('bc:a5:11:3e:06:88')).present).toBe(false); // seeded device now absent
|
||||
expect((await repo.get('aa:bb:cc:dd:ee:ff')).present).toBe(true);
|
||||
const before = (await repo.get('aa:bb:cc:dd:ee:ff')).present;
|
||||
expect(await repo.markAbsent([])).toBe(0); // guard: no-op
|
||||
expect((await repo.get('aa:bb:cc:dd:ee:ff')).present).toBe(before);
|
||||
});
|
||||
|
||||
it('prune deletes stale new+absent (randomized >24h, others >14d); keeps known', async () => {
|
||||
await pool.query(`INSERT INTO lan_devices (mac, status, randomized, present, last_seen)
|
||||
VALUES ('11:11:11:11:11:11','new',true,false, now()-interval '2 days'),
|
||||
('22:22:22:22:22:22','new',false,false, now()-interval '20 days'),
|
||||
('33:33:33:33:33:33','new',true,false, now()-interval '1 hour'),
|
||||
('44:44:44:44:44:44','known',true,false, now()-interval '99 days')`);
|
||||
const n = await repo.prune();
|
||||
expect(n).toBe(2); // the two stale 'new'
|
||||
expect(await repo.get('33:33:33:33:33:33')).not.toBeNull(); // recent kept
|
||||
expect(await repo.get('44:44:44:44:44:44')).not.toBeNull(); // known kept
|
||||
});
|
||||
|
||||
it('update promotes + names a discovered device', async () => {
|
||||
await repo.update('24:4b:fe:8e:09:a4', { name: 'ASUS RT-AX88U', grp: 'Network', status: 'known', flagged: false });
|
||||
expect(await repo.listDiscovered()).toHaveLength(0);
|
||||
const d = await repo.get('24:4b:fe:8e:09:a4');
|
||||
expect(d.name).toBe('ASUS RT-AX88U');
|
||||
expect(d.status).toBe('known');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user