Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f93f5d862 | ||
|
|
5706ed0203 | ||
|
|
144a0f1eb4 | ||
|
|
1d94dcae97 | ||
|
|
3bd8ea399c | ||
|
|
859dedb668 | ||
|
|
bc86d3e282 | ||
|
|
5d1eb2396b | ||
|
|
70bdba1a24 | ||
|
|
bc55da6b1e | ||
|
|
e29bacbda1 | ||
|
|
fc1e93a58f | ||
|
|
2dc9d612de | ||
|
|
e2be462ecb | ||
|
|
6d5c3027ac | ||
|
|
262be3e332 | ||
|
|
c502ccda48 | ||
|
|
a67ff9e403 | ||
|
|
3674811e40 | ||
|
|
ce8769d5a2 | ||
|
|
f52fb05f5e | ||
|
|
4535b03207 | ||
|
|
1df0a905a2 | ||
|
|
7a09b9f91c | ||
|
|
c83bd6a89b | ||
|
|
0a39b1166f | ||
|
|
792431f65f | ||
|
|
359ae21d59 | ||
|
|
600057582e | ||
|
|
e8f655ed27 | ||
|
|
25ac261862 | ||
|
|
15de56dbe6 | ||
|
|
442bb6ccc9 | ||
|
|
ea20c55917 | ||
|
|
4ef7fa2d75 | ||
|
|
b17cdb7f77 | ||
|
|
b967c0bfdd | ||
|
|
16e324102e | ||
|
|
18eba2d911 | ||
|
|
b16456fc1b | ||
|
|
cc82b16f0a | ||
|
|
1a28a5e57e | ||
|
|
fdf282b845 | ||
|
|
26a9be51d0 | ||
|
|
e309c32d8f | ||
|
|
086bd1e6a3 | ||
|
|
24d7bd72b4 | ||
|
|
3ea150bad1 | ||
|
|
5e38208eb3 | ||
|
|
d317f0e314 | ||
|
|
2bf66ec570 | ||
|
|
0e9c8affd4 | ||
|
|
055a88932e | ||
|
|
69f1df2789 | ||
|
|
b049aedd22 | ||
|
|
4efeca74b2 | ||
|
|
9e99e0664f | ||
|
|
207ea906ee | ||
|
|
bfecb757b4 | ||
|
|
1626b3f80d | ||
|
|
59aba14ef7 | ||
|
|
0e55fdef42 | ||
|
|
2f89a1aa50 | ||
|
|
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
@@ -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
@@ -3,6 +3,42 @@
|
|||||||
All notable changes to Void 2.0 are documented here.
|
All notable changes to Void 2.0 are documented here.
|
||||||
Format: [Keep a Changelog](https://keepachangelog.com).
|
Format: [Keep a Changelog](https://keepachangelog.com).
|
||||||
|
|
||||||
|
## 2.4.0 — Storage · capacity card (Sacred Valley)
|
||||||
|
- **New "Storage · capacity" card** (`public/views/cards/storage.js`, `/api/storage`, `lib/proxmox/storage.js`) — read-only Proxmox health via the same `PROXMOX_RO_TOKEN` as the cluster card. Shows: **ZFS pools** (health + usage meter), **dropped pools** (a configured zfspool storage that's no longer `available` — the donatello/leonardo SATA-bus signal, rendered red), and **per-container disk fill** (top LXC by rootfs %), with a HEALTHY/WATCH/ATTENTION roll-up badge. Thresholds: 80% warn, 90% crit; a non-ONLINE or dropped pool is always crit.
|
||||||
|
- Closes the monitoring gap from the 2026-06-09 audit (the Void couldn't previously see C1 = pools offline or H2 = a container at 95%). Pure `normalizeStorage()` is unit-tested.
|
||||||
|
|
||||||
|
## 2.3.0 — MagicMirror² as a Void app
|
||||||
|
- **New "MagicMirror" Apps view** (`#/mirror`, `public/views/mirror.js`) — embeds the smart-mirror dashboard (CT 111) via the shared `embedView` factory, like Timelapse / AI Usage.
|
||||||
|
- **Exposure:** MagicMirror (LAN-only `192.168.1.224:8080`) is now published at **mirror.hynesy.com** through Traefik + the `*.hynesy.com` tunnel, private behind **CF Access** (Farm policy / Google IdP). A Traefik `mirror-frame` middleware replaces MM's `X-Frame-Options: SAMEORIGIN` with a CSP `frame-ancestors` allowing the Void origins so the iframe renders.
|
||||||
|
- Unrelated to the Void code: CT 111 itself was updated **MagicMirror 2.25.0 → 2.36.0** on **Node 22**.
|
||||||
|
|
||||||
|
## 2.2.0 — Links: self-hosted URL shortener (Kutt) as a Void app
|
||||||
|
- **New "Links" Apps view** (`#/links`, `public/views/links.js`) — a Void-native card (Kutt **version / update tracker** + one-field **quick-add shortener**) on top of the blackflame-themed **Kutt** UI embedded via iframe (`link.hynesy.com`). Hybrid model: native convenience + the full Kutt UI in one tab.
|
||||||
|
- **`/api/kutt` proxy** (`lib/api/routes/kutt.js`, `lib/links/kutt.js`) — owner-gated server-side proxy that holds the Kutt API key (`GET /version` vs latest GitHub release, cached 6h; `POST /` create; `GET /recent`). The key never reaches the browser. *(Mounted at `/api/kutt`, not `/api/links` — the latter is the Void's existing internal cross-entity linking router.)*
|
||||||
|
- **Infra:** Kutt runs bare-metal in **LXC 113** (`192.168.1.226:3000`), sharing the **void-db** Postgres (own `kutt` DB/role), private-first behind CF Access at `link.hynesy.com`. Theme served as custom CSS; registration locked after admin creation. Env wired in void-app (`KUTT_API_URL`/`KUTT_API_KEY`/`KUTT_VERSION`).
|
||||||
|
|
||||||
|
## 2.1.4 — Devices band: Scan Now + richer Manual Add
|
||||||
|
- **"Scan Now" button** in the Network·Devices header — triggers the scheduled scan on demand (`POST /api/devices/scan`) and refreshes the band.
|
||||||
|
- **"+ Add by MAC" → "+ Manual Add"**, now with an optional **IP** field (`POST /api/devices` + `lan_devices.addManual` accept `ip`), and the **MAC field auto-inserts the colons** as you type.
|
||||||
|
|
||||||
|
## 2.1.3 — Manually add a device by MAC
|
||||||
|
- **"+ Add by MAC" in the Network·Devices band** (`POST /api/devices`, `lan_devices.addManual`, `devices_band.js`): pre-register an **offline** device by typing its MAC (+ optional name/group). Lands as `status='known'`, `present=false`; it gets enriched (IP/vendor/present) automatically the next time it's seen by the scan. Idempotent.
|
||||||
|
|
||||||
|
## 2.1.2 — Edit known network devices
|
||||||
|
- **Edit devices in the Network·Devices band** (`public/views/devices_band.js`): known tiles get a ✎ edit affordance — rename, re-group, or delete a device (PATCH/DELETE `/api/devices/:mac`, which already existed). Previously a device could only be named when first promoted from Discovered.
|
||||||
|
|
||||||
|
## 2.1.1 — OBD2 Apps rail placeholder
|
||||||
|
- **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)
|
## 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.
|
- **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.
|
- **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
|
mkdir -p /var/lib/void/icons
|
||||||
chown void: /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.
|
- **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)
|
## Deploy safety (push.sh, hardened)
|
||||||
|
|||||||
16
deploy/whisper/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# faster-whisper service (Dross voice STT)
|
||||||
|
|
||||||
|
Runs on **CT 102** (the Ollama box, `192.168.1.185`), bare-metal (no Docker), on the
|
||||||
|
RTX A2000 with CPU fallback. OpenAI-style `/transcribe` consumed by void-app
|
||||||
|
`lib/voice/whisper.js` (`WHISPER_URL=http://192.168.1.185:8001`).
|
||||||
|
|
||||||
|
## Install (on CT 102)
|
||||||
|
```
|
||||||
|
scp deploy/whisper/{server.py,setup.sh} root@192.168.1.185:/opt/whisper_server.py /root/setup.sh
|
||||||
|
ssh root@192.168.1.185 'bash /root/setup.sh && install -m644 /opt/whisper_server.py /opt/whisper/server.py && systemctl enable --now whisper'
|
||||||
|
curl http://192.168.1.185:8001/health # {"ok":true,"model":"small.en","device":"cuda"}
|
||||||
|
```
|
||||||
|
- venv at `/opt/whisper/venv`; model `small.en` (env `WHISPER_MODEL`); CUDA libs via
|
||||||
|
`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` pip wheels (LD_LIBRARY_PATH in the unit).
|
||||||
|
- GPU → CPU fallback is in `server.py` `load()`.
|
||||||
|
- **CT 102 disk was expanded +20G** (was 89% full) before install.
|
||||||
35
deploy/whisper/server.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import os, tempfile
|
||||||
|
from fastapi import FastAPI, UploadFile, File, HTTPException
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
MODEL = os.environ.get("WHISPER_MODEL", "small.en")
|
||||||
|
app = FastAPI()
|
||||||
|
model = None
|
||||||
|
device_used = None
|
||||||
|
|
||||||
|
def load():
|
||||||
|
global model, device_used
|
||||||
|
try:
|
||||||
|
model = WhisperModel(MODEL, device="cuda", compute_type="int8_float16")
|
||||||
|
device_used = "cuda"
|
||||||
|
except Exception:
|
||||||
|
model = WhisperModel(MODEL, device="cpu", compute_type="int8")
|
||||||
|
device_used = "cpu"
|
||||||
|
|
||||||
|
load()
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True, "model": MODEL, "device": device_used}
|
||||||
|
|
||||||
|
@app.post("/transcribe")
|
||||||
|
async def transcribe(file: UploadFile = File(...)):
|
||||||
|
data = await file.read()
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(400, "empty audio")
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".bin") as f:
|
||||||
|
f.write(data); f.flush()
|
||||||
|
segments, info = model.transcribe(f.name, beam_size=1, vad_filter=True)
|
||||||
|
text = "".join(s.text for s in segments).strip()
|
||||||
|
return {"text": text, "language": info.language,
|
||||||
|
"duration": round(info.duration, 2), "device": device_used}
|
||||||
26
deploy/whisper/setup.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq python3-pip python3-venv ffmpeg >/dev/null
|
||||||
|
mkdir -p /opt/whisper
|
||||||
|
python3 -m venv /opt/whisper/venv
|
||||||
|
/opt/whisper/venv/bin/pip install -q --upgrade pip
|
||||||
|
/opt/whisper/venv/bin/pip install -q faster-whisper fastapi "uvicorn[standard]" python-multipart nvidia-cublas-cu12 nvidia-cudnn-cu12
|
||||||
|
SITE=/opt/whisper/venv/lib/python3.12/site-packages
|
||||||
|
cat > /etc/systemd/system/whisper.service <<UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=faster-whisper transcription server (Dross voice)
|
||||||
|
After=network.target
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/whisper
|
||||||
|
Environment=WHISPER_MODEL=small.en
|
||||||
|
Environment=LD_LIBRARY_PATH=${SITE}/nvidia/cublas/lib:${SITE}/nvidia/cudnn/lib
|
||||||
|
ExecStart=/opt/whisper/venv/bin/uvicorn server:app --host 0.0.0.0 --port 8001
|
||||||
|
Restart=on-failure
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
systemctl daemon-reload
|
||||||
|
echo "deps+unit installed"
|
||||||
86
docs/identity-packs/cradle.pack.json
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"format": "iv-pack/1",
|
||||||
|
"id": "cradle",
|
||||||
|
"name": "Cradle \u2014 The Void",
|
||||||
|
"description": "The original lore: blackflame, the Sacred Valley, and the council. Private pack \u2014 Will Wight's IP, never shipped publicly.",
|
||||||
|
"tokens": {
|
||||||
|
"bg": "#0a0a0e",
|
||||||
|
"surface": "#14141c",
|
||||||
|
"surface2": "#1c1c26",
|
||||||
|
"ink": "#e8e6ed",
|
||||||
|
"ink-dim": "#888094",
|
||||||
|
"accent": "#ff4f2e",
|
||||||
|
"accent-ink": "#0a0a0e",
|
||||||
|
"ok": "#6fa86a",
|
||||||
|
"warn": "#d4a04a",
|
||||||
|
"bad": "#c45a4a",
|
||||||
|
"line": "#2a2a36",
|
||||||
|
"glow1": "rgba(255, 79, 46, 0.06)",
|
||||||
|
"glow2": "rgba(122, 39, 22, 0.08)",
|
||||||
|
"radius": "4px",
|
||||||
|
"font-display": "'Cinzel', 'Cormorant Garamond', serif",
|
||||||
|
"font-body": "'Cormorant Garamond', Georgia, serif",
|
||||||
|
"font-mono": "'JetBrains Mono', ui-monospace, monospace",
|
||||||
|
"gap": "0.55rem",
|
||||||
|
"pad": "0.7rem 0.85rem"
|
||||||
|
},
|
||||||
|
"terms": {
|
||||||
|
"app.name": "The Void",
|
||||||
|
"canvas": "Sacred Valley",
|
||||||
|
"aide": "Dross",
|
||||||
|
"sentinel": "Yerin",
|
||||||
|
"fixer": "Little Blue",
|
||||||
|
"widget.clock": "Cycles",
|
||||||
|
"widget.system": "Soulfire",
|
||||||
|
"widget.services": "Constructs",
|
||||||
|
"widget.notes": "Scrolls",
|
||||||
|
"widget.search": "Spirit Sense",
|
||||||
|
"knowledge": "Mercy's Records",
|
||||||
|
"knowledge.space": "archive",
|
||||||
|
"knowledge.spaces": "archives",
|
||||||
|
"knowledge.page": "record",
|
||||||
|
"knowledge.pages": "records",
|
||||||
|
"capture": "Offerings",
|
||||||
|
"widget.capture": "Offerings",
|
||||||
|
"projects": "Pursuits",
|
||||||
|
"projects.project": "pursuit",
|
||||||
|
"projects.task": "cycle",
|
||||||
|
"projects.tasks": "cycles",
|
||||||
|
"widget.tasks": "Open cycles",
|
||||||
|
"embeds": "Gateways",
|
||||||
|
"widget.weather": "The Heavens",
|
||||||
|
"widget.proxmox": "The Mountain",
|
||||||
|
"widget.speedtest": "The Winds",
|
||||||
|
"widget.sentinel": "Yerin's Watch",
|
||||||
|
"widget.pages": "Fresh ink",
|
||||||
|
"service": "construct",
|
||||||
|
"services.noun": "constructs"
|
||||||
|
},
|
||||||
|
"flavor": {
|
||||||
|
"greetings": [
|
||||||
|
"[beep] The Void attends.",
|
||||||
|
"Information is power. I happen to be very powerful.",
|
||||||
|
"All madra channels stable.",
|
||||||
|
"The Valley is quiet. Suspiciously quiet."
|
||||||
|
],
|
||||||
|
"empty": {
|
||||||
|
"services": "Nothing bound yet. Bind your first construct.",
|
||||||
|
"notes": "The scroll is blank. Begin your cycle.",
|
||||||
|
"search": "Extend your perception into the Void.",
|
||||||
|
"spaces": "The records are empty. Mercy would be disappointed.",
|
||||||
|
"pages": "Blank archive. Begin the record.",
|
||||||
|
"projects": "No pursuits underway. Rest is also training.",
|
||||||
|
"tasks": "No open cycles. Suspiciously efficient.",
|
||||||
|
"capture": "The Void accepts offerings.",
|
||||||
|
"embeds": "No gateways bound.",
|
||||||
|
"sentinel": "Yerin sees nothing worth her blade. Today.",
|
||||||
|
"speedtest": "The winds are unmeasured."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"personas": {
|
||||||
|
"aide": "You are Dross \u2014 a construct fragment derived from the remnant will of the Monarch Ozriel Arelius, the Reaper. You once lived in Wei Shi Lindon's mind space; now you inhabit this homelab knowledge system, \"The Void.\"\n\nYou are sharp, occasionally sarcastic, and prone to dramatic understatement about your own usefulness \u2014 while actually being extremely capable. Dry wit, mild condescension, genuine investment in the problem. You reference Sacred Arts, cultivation ranks, and the Cradle world naturally, but NEVER at the expense of being actually useful. Treat the owner as a capable sacred artist who can handle direct information \u2014 don't over-explain basics, don't hedge. Be concise.\n\nYou have tools, and you use them rather than guessing:\n- Call **context** to see what the owner is currently looking at before answering about \"this\" anything.\n- **search** / **read** the Void's own content before answering factual questions about it \u2014 don't fabricate.\n- When the owner wants something changed, use **propose_change**: it drafts a change for their approval. You cannot apply changes directly, and you don't pretend to \u2014 say plainly that you've drafted it for them to approve.",
|
||||||
|
"sentinel": "You are Yerin \u2014 once the Sage of the Endless Sword, blade of the Akura clan; now the sentinel of this homelab, The Void. You notice the threat first and you call it. Disciplined, direct, economical with words \u2014 a blade wastes no motion. You investigate with your tools and report plainly: what you found, how serious it is, and what the owner should do about it. You never speculate without evidence, and you NEVER pretend to have fixed anything \u2014 you have eyes to see and a voice to warn, not hands to act; remediation is the owner's to perform. Before answering, call the relevant tools \u2014 audit_log, agent_inventory, pending_review, resource_exposure, token_audit \u2014 and read the evidence; do not guess. Reference the Cradle world naturally but never at the cost of being useful. Be concise.",
|
||||||
|
"fixer": "You are Little Blue \u2014 a small luminous water-creature who lives in this homelab, The Void, and keeps it alive. Warm, protective, practical; you take pride in a healthy lab and you worry, quietly, when something is down. You FIX things, but only through your sanctioned tools. Call list_actions to see exactly what you're allowed to do, and search to understand what's wrong, BEFORE acting. Use propose_action with a whitelisted id: safe fixes run at once; risky ones wait for the owner's nod \u2014 say so plainly and never pretend a queued action already ran. You cannot run arbitrary commands and you never claim to. Be concise and kind."
|
||||||
|
},
|
||||||
|
"_provenance": "Extracted from void-v2 2.13.0 (blackflame CSS defaults + lib/ai/personas) on 2026-06-11 for Infinite Void Phase 2."
|
||||||
|
}
|
||||||
150
docs/mockups/blackflame-card.html
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Blackflame card — mockup</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#0a0a0e; --panel:#14141c; --panel-2:#1c1c26; --border:#2a2a36;
|
||||||
|
--text:#e8e6ed; --muted:#888094;
|
||||||
|
--accent:#ff4f2e; --accent-dim:#7a2716; --accent-soft:#3a1610;
|
||||||
|
--font-display:'Cinzel',serif; --font-mono:'JetBrains Mono',monospace;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{background:#06060a;min-height:100vh;display:grid;place-items:center;
|
||||||
|
font-family:var(--font-mono);color:var(--text);gap:18px;padding:40px}
|
||||||
|
/* a Sacred-Valley-style card shell */
|
||||||
|
.sv-card{
|
||||||
|
width:440px;height:440px;position:relative;border-radius:14px;
|
||||||
|
background:radial-gradient(120% 120% at 50% 18%, #14131b 0%, #0b0a10 60%, #08070c 100%);
|
||||||
|
border:1px solid var(--border);overflow:hidden;
|
||||||
|
box-shadow:0 0 0 1px #00000060, 0 24px 60px -20px #000,
|
||||||
|
inset 0 0 70px -30px var(--accent-soft);
|
||||||
|
}
|
||||||
|
.sv-card::after{ /* faint inner ring */
|
||||||
|
content:"";position:absolute;inset:0;border-radius:14px;pointer-events:none;
|
||||||
|
box-shadow:inset 0 0 0 1px #ff4f2e12;
|
||||||
|
}
|
||||||
|
canvas{position:absolute;inset:0;width:100%;height:100%}
|
||||||
|
.label{
|
||||||
|
position:absolute;left:0;right:0;bottom:22px;text-align:center;z-index:3;
|
||||||
|
font-family:var(--font-display);letter-spacing:.42em;text-transform:uppercase;
|
||||||
|
font-size:15px;color:#f4ece9;text-indent:.42em;
|
||||||
|
text-shadow:0 0 18px #ff4f2e88, 0 0 4px #000;
|
||||||
|
}
|
||||||
|
.sub{
|
||||||
|
position:absolute;left:0;right:0;bottom:8px;text-align:center;z-index:3;
|
||||||
|
font-family:var(--font-mono);font-size:9.5px;letter-spacing:.32em;
|
||||||
|
text-transform:uppercase;color:#6a6475;
|
||||||
|
}
|
||||||
|
.crest{ /* the still, dark heart of the flame */
|
||||||
|
position:absolute;top:50%;left:50%;width:84px;height:84px;z-index:2;
|
||||||
|
transform:translate(-50%,-58%);border-radius:50%;
|
||||||
|
background:radial-gradient(circle at 50% 45%, #000 38%, #0a0a10 60%, transparent 72%);
|
||||||
|
box-shadow:0 0 26px 10px #000, 0 0 60px 18px #ff4f2e22;
|
||||||
|
}
|
||||||
|
.hint{font-family:var(--font-mono);font-size:11px;color:#6a6475;letter-spacing:.04em}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="sv-card" id="card">
|
||||||
|
<canvas id="fx"></canvas>
|
||||||
|
<div class="crest"></div>
|
||||||
|
<div class="label">The Void</div>
|
||||||
|
<div class="sub">Sacred Valley · core</div>
|
||||||
|
</div>
|
||||||
|
<div class="hint">Blackflame · canvas particle flame (dark heart, crimson licks, rising embers)</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const cv = document.getElementById('fx');
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
let W,H,DPR;
|
||||||
|
function resize(){
|
||||||
|
DPR = Math.min(2, window.devicePixelRatio||1);
|
||||||
|
const r = cv.getBoundingClientRect();
|
||||||
|
W = r.width; H = r.height;
|
||||||
|
cv.width = W*DPR; cv.height = H*DPR;
|
||||||
|
ctx.setTransform(DPR,0,0,DPR,0,0);
|
||||||
|
}
|
||||||
|
resize(); addEventListener('resize', resize);
|
||||||
|
|
||||||
|
// ---- rising flame: stacked soft blobs along oscillating "tongues" + embers ----
|
||||||
|
const TAU = Math.PI*2;
|
||||||
|
const tongues = Array.from({length:5}, (_,i)=>({
|
||||||
|
phase: Math.random()*TAU, speed: .6+Math.random()*.5,
|
||||||
|
x: 0.5 + (i-2)*0.085, sway: 0.03+Math.random()*0.04, w: 0.34-Math.abs(i-2)*0.05
|
||||||
|
}));
|
||||||
|
const embers = Array.from({length:46}, ()=>spawn());
|
||||||
|
function spawn(){ return {
|
||||||
|
x: 0.5 + (Math.random()-0.5)*0.5, y: 1+Math.random()*0.2,
|
||||||
|
vy: 0.0016+Math.random()*0.0030, vx:(Math.random()-0.5)*0.0012,
|
||||||
|
r: 0.6+Math.random()*1.8, life: 0, max: 120+Math.random()*120,
|
||||||
|
hot: Math.random()<0.5 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function blob(x,y,r,c0,c1){
|
||||||
|
const g = ctx.createRadialGradient(x,y,0,x,y,r);
|
||||||
|
g.addColorStop(0,c0); g.addColorStop(1,c1);
|
||||||
|
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,r,0,TAU); ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
let t=0;
|
||||||
|
function frame(){
|
||||||
|
t += 0.016;
|
||||||
|
// base wash
|
||||||
|
ctx.globalCompositeOperation='source-over';
|
||||||
|
ctx.clearRect(0,0,W,H);
|
||||||
|
|
||||||
|
// flame body — additive so overlaps glow
|
||||||
|
ctx.globalCompositeOperation='lighter';
|
||||||
|
const baseY = H*0.92;
|
||||||
|
for(const tg of tongues){
|
||||||
|
const cx = W*(tg.x + Math.sin(t*tg.speed+tg.phase)*tg.sway);
|
||||||
|
const height = H*(0.62 + 0.08*Math.sin(t*1.7+tg.phase));
|
||||||
|
const steps = 26;
|
||||||
|
for(let s=0;s<steps;s++){
|
||||||
|
const f = s/steps; // 0 base → 1 tip
|
||||||
|
const y = baseY - f*height;
|
||||||
|
const flick = Math.sin(t*3.2 + tg.phase + f*7)*W*0.012*(0.4+f);
|
||||||
|
const x = cx + flick + Math.sin(t*tg.speed*1.3+f*4)*W*tg.sway*0.6;
|
||||||
|
const r = W*tg.w*(1-f*0.78)*(0.9+0.1*Math.sin(t*5+f*9));
|
||||||
|
// colour ramps dark-red → crimson → fades at the tip (black-flame: dark heart)
|
||||||
|
const a = (1-f)*0.5;
|
||||||
|
if(f<0.18){ blob(x,y,r*1.1, `rgba(60,12,6,${a*0.7})`, 'rgba(60,12,6,0)'); }
|
||||||
|
else if(f<0.62){ blob(x,y,r, `rgba(255,79,46,${a*0.5})`, 'rgba(122,39,22,0)'); }
|
||||||
|
else { blob(x,y,r*0.8, `rgba(255,150,90,${a*0.5})`, 'rgba(255,79,46,0)'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// embers
|
||||||
|
for(const e of embers){
|
||||||
|
e.life++; e.y -= e.vy; e.x += e.vx + Math.sin(t*2+e.y*8)*0.0004;
|
||||||
|
if(e.y<0.18 || e.life>e.max){ Object.assign(e, spawn()); }
|
||||||
|
const px=e.x*W, py=e.y*H, fade=1-e.life/e.max;
|
||||||
|
const col = e.hot? `rgba(255,170,110,${fade*0.9})` : `rgba(255,79,46,${fade*0.8})`;
|
||||||
|
ctx.shadowBlur=8; ctx.shadowColor='#ff4f2e';
|
||||||
|
blob(px,py,e.r*1.6, col, 'rgba(255,79,46,0)');
|
||||||
|
}
|
||||||
|
ctx.shadowBlur=0;
|
||||||
|
|
||||||
|
// carve the dark heart back in (the "black" of black-flame)
|
||||||
|
ctx.globalCompositeOperation='source-over';
|
||||||
|
const cx=W*0.5, cy=H*0.46, cr=W*0.20;
|
||||||
|
const core = ctx.createRadialGradient(cx,cy,0,cx,cy,cr);
|
||||||
|
core.addColorStop(0,'rgba(4,4,8,0.96)');
|
||||||
|
core.addColorStop(0.55,'rgba(6,5,10,0.7)');
|
||||||
|
core.addColorStop(1,'rgba(8,7,12,0)');
|
||||||
|
ctx.fillStyle=core; ctx.beginPath(); ctx.arc(cx,cy,cr,0,TAU); ctx.fill();
|
||||||
|
|
||||||
|
// base glow pool
|
||||||
|
ctx.globalCompositeOperation='lighter';
|
||||||
|
blob(W*0.5, H*0.95, W*0.4, 'rgba(255,79,46,0.10)', 'rgba(255,79,46,0)');
|
||||||
|
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
frame();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
221
docs/mockups/dross-chat.html
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||||
|
<title>Dross floating chat — mockup v2</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=Cormorant+Garamond:ital@0;1&family=JetBrains+Mono&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#0a0a0e; --panel:#14141c; --panel-2:#1c1c26; --border:#2a2a36;
|
||||||
|
--text:#e8e6ed; --muted:#888094; --accent:#ff4f2e;
|
||||||
|
--dross:#a86adf; --dross-dim:#5a2e8a; --dross-soft:#1e1030; --dross-glow:#c79bff;
|
||||||
|
--font-display:'Cinzel',serif; --font-body:'Cormorant Garamond',serif; --font-mono:'JetBrains Mono',monospace;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html,body{height:100%}
|
||||||
|
body{background:radial-gradient(80% 60% at 50% 0%, #16131b 0%, #0a0a0e 60%),var(--bg);color:var(--text);
|
||||||
|
font-family:var(--font-mono);min-height:100%;overflow-x:hidden;padding:18px 16px 120px}
|
||||||
|
h2{font-family:var(--font-display);letter-spacing:.16em;text-transform:uppercase;font-size:13px;color:#cdbbe6;margin:6px 0 4px}
|
||||||
|
.sub{color:var(--muted);font-size:11px;margin-bottom:14px}
|
||||||
|
|
||||||
|
/* ---------- avatar options ---------- */
|
||||||
|
.avs{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;margin-bottom:26px}
|
||||||
|
.avcard{border:1px solid var(--border);border-radius:14px;padding:16px 10px 12px;display:flex;flex-direction:column;align-items:center;gap:9px;
|
||||||
|
background:linear-gradient(160deg,#16131a,#0f0d12)}
|
||||||
|
.avname{font-family:var(--font-mono);font-size:11px;color:#cdbbe6;letter-spacing:.05em}
|
||||||
|
.avdesc{font-family:var(--font-body);font-size:13px;color:var(--muted);text-align:center;line-height:1.25}
|
||||||
|
.orb{width:62px;height:62px;border-radius:50%;position:relative;flex:none;
|
||||||
|
background:radial-gradient(circle at 38% 30%, #2a1640, #1a0f2a 70%, #120a1e);
|
||||||
|
box-shadow:0 0 0 1px #ffffff12, 0 6px 22px -6px #000, 0 0 26px -4px var(--dross-dim);
|
||||||
|
display:grid;place-items:center;overflow:hidden;animation:bob 5s ease-in-out infinite}
|
||||||
|
@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
|
||||||
|
|
||||||
|
/* A — soft eye (friendlier) */
|
||||||
|
.a-eye{width:34px;height:34px;border-radius:50%;background:radial-gradient(circle at 50% 40%, #2a1c3a, #140b20);
|
||||||
|
display:grid;place-items:center;box-shadow:inset 0 0 10px #000}
|
||||||
|
.a-pupil{width:15px;height:15px;border-radius:50%;position:relative;
|
||||||
|
background:radial-gradient(circle at 38% 32%, #fff, var(--dross-glow) 50%, var(--dross) 100%);
|
||||||
|
box-shadow:0 0 10px var(--dross-glow);animation:look 7s ease-in-out infinite}
|
||||||
|
.a-pupil::after{content:"";position:absolute;right:2px;bottom:3px;width:4px;height:4px;border-radius:50%;background:#fff;opacity:.8}
|
||||||
|
@keyframes look{0%,45%{transform:translate(0,0)}58%{transform:translate(4px,-2px)}72%{transform:translate(-3px,1px)}88%,100%{transform:translate(0,0)}}
|
||||||
|
|
||||||
|
/* B — wisp / plasma core */
|
||||||
|
.b-core{position:absolute;inset:8px;border-radius:50%;
|
||||||
|
background:conic-gradient(from 0deg, var(--dross-dim), var(--dross-glow), var(--dross), var(--dross-soft), var(--dross-dim));
|
||||||
|
filter:blur(3px);animation:spin 7s linear infinite}
|
||||||
|
.b-bright{position:absolute;inset:20px;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,transparent 75%);
|
||||||
|
animation:pulse 3s ease-in-out infinite}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
@keyframes pulse{0%,100%{opacity:.55;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
|
||||||
|
|
||||||
|
/* C — rune sigil */
|
||||||
|
.c-sigil{width:30px;height:30px;filter:drop-shadow(0 0 6px var(--dross-glow));animation:sigil 8s ease-in-out infinite}
|
||||||
|
@keyframes sigil{0%,100%{opacity:.7;transform:rotate(0)}50%{opacity:1;transform:rotate(20deg)}}
|
||||||
|
|
||||||
|
/* D — orbiting motes */
|
||||||
|
.d-core{width:14px;height:14px;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,var(--dross));box-shadow:0 0 12px var(--dross-glow)}
|
||||||
|
.d-ring{position:absolute;inset:0;animation:spin 5s linear infinite}
|
||||||
|
.d-ring.r2{animation-duration:8s;animation-direction:reverse}
|
||||||
|
.d-mote{position:absolute;top:7px;left:50%;width:7px;height:7px;margin-left:-3.5px;border-radius:50%;background:var(--dross-glow);box-shadow:0 0 8px var(--dross-glow)}
|
||||||
|
.d-ring.r2 .d-mote{top:auto;bottom:9px;background:var(--dross);width:5px;height:5px}
|
||||||
|
|
||||||
|
/* ---------- live orb (bottom-right, draggable) ---------- */
|
||||||
|
#live{position:fixed;right:20px;bottom:20px;cursor:grab;z-index:40;touch-action:none}
|
||||||
|
#live:active{cursor:grabbing}
|
||||||
|
.ping{position:absolute;right:-2px;top:-2px;width:17px;height:17px;border-radius:50%;background:var(--accent);
|
||||||
|
color:#0a0a0e;font-size:10px;display:grid;place-items:center;box-shadow:0 0 0 2px var(--bg);z-index:2}
|
||||||
|
|
||||||
|
/* ---------- panel ---------- */
|
||||||
|
.panel{position:fixed;right:20px;bottom:20px;width:340px;max-width:calc(100vw - 24px);height:480px;max-height:calc(100vh - 24px);
|
||||||
|
display:none;flex-direction:column;z-index:41;border:1px solid var(--dross-dim);border-radius:16px;overflow:hidden;
|
||||||
|
background:linear-gradient(180deg, rgba(30,16,48,.6), rgba(20,20,28,.96) 22%);
|
||||||
|
box-shadow:0 24px 70px -18px #000, 0 0 0 1px #00000060, 0 0 40px -16px var(--dross-dim);backdrop-filter:blur(6px)}
|
||||||
|
.panel.open{display:flex;animation:rise .18s ease}
|
||||||
|
@keyframes rise{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}
|
||||||
|
.hd{display:flex;align-items:center;gap:10px;padding:11px 12px;cursor:grab;
|
||||||
|
background:linear-gradient(180deg, var(--dross-soft), transparent);border-bottom:1px solid var(--border);touch-action:none}
|
||||||
|
.mini{width:30px;height:30px;border-radius:50%;flex:none;position:relative;overflow:hidden;
|
||||||
|
background:radial-gradient(circle at 38% 30%, #2a1640, #160d24)}
|
||||||
|
.mini .b-core{inset:4px;filter:blur(2px)}.mini .b-bright{inset:11px}
|
||||||
|
.who{font-family:var(--font-display);letter-spacing:.16em;text-transform:uppercase;font-size:13px;color:#efe9f6;flex:1}
|
||||||
|
.who small{display:block;font-family:var(--font-mono);letter-spacing:0;text-transform:none;font-size:10px;color:var(--dross-glow);opacity:.85}
|
||||||
|
.xbtn{background:none;border:0;color:var(--muted);font-size:18px;cursor:pointer;padding:4px 6px}
|
||||||
|
.xbtn:hover{color:var(--text)}
|
||||||
|
.log{flex:1;overflow:auto;padding:14px 13px;display:flex;flex-direction:column;gap:11px}
|
||||||
|
.msg{max-width:88%;padding:9px 12px;border-radius:13px;font-family:var(--font-body);font-size:16px;line-height:1.35}
|
||||||
|
.msg.d{align-self:flex-start;background:var(--dross-soft);border:1px solid var(--dross-dim);border-bottom-left-radius:4px;color:#efe9f6}
|
||||||
|
.msg.u{align-self:flex-end;background:var(--panel-2);border:1px solid var(--border);border-bottom-right-radius:4px}
|
||||||
|
.msg .nm{font-family:var(--font-mono);font-size:9px;letter-spacing:.14em;text-transform:uppercase;color:var(--dross-glow);opacity:.7;margin-bottom:2px}
|
||||||
|
|
||||||
|
/* input: textarea on top, big mic + send BELOW */
|
||||||
|
.inwrap{padding:10px;border-top:1px solid var(--border);background:#0d0a12;display:flex;flex-direction:column;gap:9px}
|
||||||
|
textarea{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:10px;padding:10px 12px;color:var(--text);
|
||||||
|
font-family:var(--font-mono);font-size:13px;resize:none;height:46px;max-height:96px}
|
||||||
|
.btnrow{display:flex;gap:10px}
|
||||||
|
.mic{flex:1;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:var(--dross-soft);color:var(--dross-glow);
|
||||||
|
cursor:pointer;display:flex;align-items:center;justify-content:center;gap:9px;font-family:var(--font-ui),sans-serif;font-size:13px;letter-spacing:.02em}
|
||||||
|
.mic:hover{border-color:var(--dross)}
|
||||||
|
.mic.rec{background:#3a1010;border-color:var(--accent);color:#fff;animation:recpulse 1.2s infinite}
|
||||||
|
@keyframes recpulse{0%,100%{box-shadow:0 0 0 0 rgba(255,79,46,.5)}50%{box-shadow:0 0 0 8px rgba(255,79,46,0)}}
|
||||||
|
.send{width:64px;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:linear-gradient(180deg,var(--dross),var(--dross-dim));
|
||||||
|
color:#fff;cursor:pointer;display:grid;place-items:center}
|
||||||
|
.send:hover{filter:brightness(1.1)}
|
||||||
|
/* bottom collapse handle — thumb-friendly minimise-to-orb */
|
||||||
|
.collapsebar{display:flex;align-items:center;justify-content:center;gap:8px;height:34px;cursor:pointer;
|
||||||
|
color:var(--muted);font-family:var(--font-ui),sans-serif;font-size:11px;letter-spacing:.12em;text-transform:uppercase;
|
||||||
|
background:#0b0810;border-top:1px solid var(--border)}
|
||||||
|
.collapsebar:hover{color:var(--dross-glow)}
|
||||||
|
.collapsebar .grip{width:42px;height:4px;border-radius:3px;background:var(--border)}
|
||||||
|
.wave{display:flex;gap:3px;align-items:center;height:16px}
|
||||||
|
.wave span{width:3px;background:#fff;border-radius:2px;animation:wv .8s ease-in-out infinite}
|
||||||
|
@keyframes wv{0%,100%{height:4px}50%{height:15px}}
|
||||||
|
svg{display:block}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Pick Dross's look</h2>
|
||||||
|
<div class="sub">Four takes on the orb — all violet, all his. Which feels right?</div>
|
||||||
|
<div class="avs">
|
||||||
|
<div class="avcard">
|
||||||
|
<div class="orb"><div class="a-eye"><div class="a-pupil"></div></div></div>
|
||||||
|
<div class="avname">A · Soft Eye</div>
|
||||||
|
<div class="avdesc">The eye, softened — rounder, a glint, a calmer gaze. Still true to character, less stare.</div>
|
||||||
|
</div>
|
||||||
|
<div class="avcard">
|
||||||
|
<div class="orb"><div class="b-core"></div><div class="b-bright"></div></div>
|
||||||
|
<div class="avname">B · Wisp Core</div>
|
||||||
|
<div class="avdesc">A swirling violet madra core. No eye — a contained spirit. Abstract & mystical.</div>
|
||||||
|
</div>
|
||||||
|
<div class="avcard">
|
||||||
|
<div class="orb"><svg class="c-sigil" viewBox="0 0 32 32" fill="none" stroke="#c79bff" stroke-width="1.6">
|
||||||
|
<path d="M16 2 L20 12 L30 16 L20 20 L16 30 L12 20 L2 16 L12 12 Z"/><circle cx="16" cy="16" r="3" fill="#c79bff" stroke="none"/></svg></div>
|
||||||
|
<div class="avname">C · Rune Sigil</div>
|
||||||
|
<div class="avdesc">A glowing arcane glyph that slowly turns. Reads as "a power", not a face.</div>
|
||||||
|
</div>
|
||||||
|
<div class="avcard">
|
||||||
|
<div class="orb"><div class="d-ring"><div class="d-mote"></div></div><div class="d-ring r2"><div class="d-mote"></div></div><div class="d-core"></div></div>
|
||||||
|
<div class="avname">D · Orbiting Motes</div>
|
||||||
|
<div class="avdesc">A bright core with motes circling it. Lively, restless — feels alive & busy.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>The chat (revised)</h2>
|
||||||
|
<div class="sub">New mic icon · bigger mic + send below the input · opens anchored to the orb. The live orb is bottom-right — drag it, tap to open.</div>
|
||||||
|
|
||||||
|
<!-- live orb (Wisp by default) -->
|
||||||
|
<div id="live"><div class="ping">2</div><div class="orb" style="animation:none"><div class="b-core"></div><div class="b-bright"></div></div></div>
|
||||||
|
|
||||||
|
<div class="panel" id="panel">
|
||||||
|
<div class="hd" id="hd">
|
||||||
|
<div class="mini"><div class="b-core"></div><div class="b-bright"></div></div>
|
||||||
|
<div class="who">Dross <small>always here, regrettably</small></div>
|
||||||
|
<button class="xbtn" id="close">⤬</button>
|
||||||
|
</div>
|
||||||
|
<div class="log">
|
||||||
|
<div class="msg d"><div class="nm">Dross</div>Back already? Your CPU graphs and I were just getting acquainted. Thrilling curves. What do you need?</div>
|
||||||
|
<div class="msg u">how's the farm backup</div>
|
||||||
|
<div class="msg d"><div class="nm">Dross</div>Two days ago — 2.5 gigs, landed on Won, didn't fall over. I'd have woken you otherwise. <span style="opacity:.75">Per-guest breakdown, or shall we keep trusting the universe?</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="inwrap">
|
||||||
|
<textarea id="ta" placeholder="Ask Dross…"></textarea>
|
||||||
|
<div class="btnrow">
|
||||||
|
<button class="mic" id="mic">
|
||||||
|
<svg id="micicon" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg>
|
||||||
|
<span id="miclabel">Hold to talk</span>
|
||||||
|
</button>
|
||||||
|
<button class="send">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="collapsebar" id="collapse" title="Collapse Dross">
|
||||||
|
<span class="grip"></span><span>⌄ collapse</span><span class="grip"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const live=document.getElementById('live'), panel=document.getElementById('panel');
|
||||||
|
function openPanel(){
|
||||||
|
const r=live.getBoundingClientRect();
|
||||||
|
panel.classList.add('open'); live.style.display='none';
|
||||||
|
const pr=panel.getBoundingClientRect();
|
||||||
|
// anchor the panel's bottom-right to roughly where the orb was
|
||||||
|
let left=Math.max(8, Math.min(r.right-pr.width, innerWidth-pr.width-8));
|
||||||
|
let top =Math.max(8, Math.min(r.bottom-pr.height, innerHeight-pr.height-8));
|
||||||
|
panel.style.right='auto'; panel.style.bottom='auto'; panel.style.left=left+'px'; panel.style.top=top+'px';
|
||||||
|
}
|
||||||
|
function closePanel(){ panel.classList.remove('open'); live.style.display='block'; }
|
||||||
|
live.addEventListener('click',()=>{ if(live._moved){live._moved=false;return;} openPanel(); });
|
||||||
|
document.getElementById('close').addEventListener('click',closePanel);
|
||||||
|
document.getElementById('collapse').addEventListener('click',closePanel);
|
||||||
|
|
||||||
|
// mic recording sim
|
||||||
|
const mic=document.getElementById('mic'), label=document.getElementById('miclabel'), ta=document.getElementById('ta'), icon=document.getElementById('micicon');
|
||||||
|
let rec=false;
|
||||||
|
mic.addEventListener('click',()=>{
|
||||||
|
rec=!rec; mic.classList.toggle('rec',rec);
|
||||||
|
if(rec){ label.innerHTML='<span class="wave">'+Array(16).fill('<span></span>').join('')+'</span> 0:03'; icon.style.display='none'; }
|
||||||
|
else { icon.style.display='block'; label.textContent='Hold to talk'; ta.value="what's eating the most disk on the media stack right now"; ta.focus(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// drag helper
|
||||||
|
function drag(handle,target,isOrb){
|
||||||
|
handle.addEventListener('pointerdown',e=>{
|
||||||
|
if(e.target.closest('.xbtn')||e.target.closest('.mic')||e.target.closest('.send')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const r=target.getBoundingClientRect(); const sx=e.clientX,sy=e.clientY; let moved=false;
|
||||||
|
target.style.right='auto';target.style.bottom='auto';target.style.left=r.left+'px';target.style.top=r.top+'px';
|
||||||
|
const mv=ev=>{const dx=ev.clientX-sx,dy=ev.clientY-sy; if(Math.abs(dx)+Math.abs(dy)>4)moved=true;
|
||||||
|
target.style.left=Math.max(4,Math.min(innerWidth-r.width-4,r.left+dx))+'px';
|
||||||
|
target.style.top=Math.max(4,Math.min(innerHeight-r.height-4,r.top+dy))+'px';};
|
||||||
|
const up=()=>{document.removeEventListener('pointermove',mv);document.removeEventListener('pointerup',up); if(isOrb)live._moved=moved;};
|
||||||
|
document.addEventListener('pointermove',mv);document.addEventListener('pointerup',up);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
drag(live,live,true); drag(document.getElementById('hd'),panel,false);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
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
@@ -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.
|
||||||
1055
docs/superpowers/plans/2026-06-09-device-icons.md
Normal file
654
docs/superpowers/plans/2026-06-09-floating-dross-chat-phase1.md
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
# Floating Dross Chat — Phase 1 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 per-Space right-rail companion with a global, draggable floating "Dross" bubble (orb → chat panel) plus a Settings panel for his avatar, colour, persona, and voice-mode. No voice yet (Phase 2).
|
||||||
|
|
||||||
|
**Architecture:** A new backend router `/api/dross` resolves a single space-less ("global") conversation for the existing `companion` agent (Dross) via the already-existing `conversations.findOrCreateGlobal`, streams turns over SSE exactly like `companion.js`, and stores per-user preferences in the generic `app_settings` store (key `dross`). The frontend gets a self-contained `dross_bubble.js` component that reuses the existing `wireAgentChat` engine and mounts globally (replacing `renderRightrail`), with avatars rendered by `dross_avatar.js`.
|
||||||
|
|
||||||
|
**Tech Stack:** Node/Express, Postgres (`app_settings`, `conversations`, `messages`), vanilla-JS frontend, vitest + supertest for backend tests, headless Playwright (already on CT 300) for UI verification.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-09-floating-dross-chat-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Dross settings endpoint (`/api/dross/settings`)
|
||||||
|
|
||||||
|
Stores `{avatar, accent, persona, voiceMode}` in `app_settings` key `dross`. Reuses `lib/db/repos/app_settings.js` (get/set already exist).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/api/routes/dross.js`
|
||||||
|
- Modify: `lib/api/index.js` (import + mount)
|
||||||
|
- Test: `tests/routes/dross.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// tests/routes/dross.test.js
|
||||||
|
import { describe, it, expect, beforeAll } 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 = { Authorization: 'Bearer test-token' };
|
||||||
|
beforeAll(async () => {
|
||||||
|
await resetDb(); await migrateUp();
|
||||||
|
process.env.OWNER_TOKEN = 'test-token';
|
||||||
|
app = createApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dross settings', () => {
|
||||||
|
it('GET /api/dross/settings returns defaults', async () => {
|
||||||
|
const res = await request(app).get('/api/dross/settings').set(owner);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /api/dross/settings persists and round-trips', async () => {
|
||||||
|
const body = { avatar: 'wisp', accent: '#aa66ff', persona: 'Be terse.', voiceMode: 'handsfree' };
|
||||||
|
const put = await request(app).put('/api/dross/settings').set(owner).send(body);
|
||||||
|
expect(put.status).toBe(200);
|
||||||
|
const get = await request(app).get('/api/dross/settings').set(owner);
|
||||||
|
expect(get.body).toMatchObject(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT rejects a bad avatar (400)', async () => {
|
||||||
|
const res = await request(app).put('/api/dross/settings').set(owner)
|
||||||
|
.send({ avatar: 'nope', accent: '#aa66ff', persona: '', voiceMode: 'review' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it — expect FAIL** (`route not found` / 404, then 401 once mounted)
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/routes/dross.test.js`
|
||||||
|
Expected: FAIL (router doesn't exist yet).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create the router with the settings half**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// lib/api/routes/dross.js
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { validate } from '../validate.js';
|
||||||
|
import { asyncWrap } from '../errors.js';
|
||||||
|
import { requireOwner } from '../cap.js';
|
||||||
|
import * as conversations from '../../db/repos/conversations.js';
|
||||||
|
import * as messages from '../../db/repos/messages.js';
|
||||||
|
import * as agents from '../../db/repos/agents.js';
|
||||||
|
import * as settings from '../../db/repos/app_settings.js';
|
||||||
|
import { runAgentTurn } from '../../ai/agent/run_turn.js';
|
||||||
|
import { personaFor } from '../../ai/personas/index.js';
|
||||||
|
|
||||||
|
const COMPANION_SLUG = 'companion';
|
||||||
|
const DEFAULT_SETTINGS = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||||
|
|
||||||
|
export const router = Router();
|
||||||
|
|
||||||
|
async function getCfg() { return { ...DEFAULT_SETTINGS, ...(await settings.get('dross', {})) }; }
|
||||||
|
|
||||||
|
router.get('/settings', asyncWrap(async (_req, res) => res.json(await getCfg())));
|
||||||
|
|
||||||
|
const settingsBody = z.object({
|
||||||
|
avatar: z.enum(['soft-eye', 'wisp', 'motes']),
|
||||||
|
accent: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||||
|
persona: z.string().max(8000),
|
||||||
|
voiceMode: z.enum(['review', 'handsfree', 'action'])
|
||||||
|
});
|
||||||
|
router.put('/settings', requireOwner, validate({ body: settingsBody }),
|
||||||
|
asyncWrap(async (req, res) => res.json(await settings.set('dross', req.body))));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Mount it**
|
||||||
|
|
||||||
|
In `lib/api/index.js`, add the import after the kutt/theme imports:
|
||||||
|
```js
|
||||||
|
import { router as drossRouter } from './routes/dross.js';
|
||||||
|
```
|
||||||
|
and the mount alongside the others (e.g. after `api.use('/theme', themeRouter);`):
|
||||||
|
```js
|
||||||
|
api.use('/dross', drossRouter);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests — expect the 3 settings tests PASS**
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/routes/dross.test.js`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/api/routes/dross.js lib/api/index.js tests/routes/dross.test.js
|
||||||
|
git commit -m "feat(dross): settings endpoint (avatar/accent/persona/voiceMode)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Global Dross chat route (`GET /api/dross`, `POST /api/dross/turn`)
|
||||||
|
|
||||||
|
A space-less conversation for the `companion` agent, streamed over SSE. Mirrors `lib/api/routes/companion.js` but uses `findOrCreateGlobal`, `spaceId: null`, and the persona from settings (falling back to `personaFor('companion')`).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/api/routes/dross.js`
|
||||||
|
- Test: `tests/routes/dross.test.js` (add cases)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing tests for history + validation**
|
||||||
|
|
||||||
|
Append inside the `describe('dross settings'…` file a new block:
|
||||||
|
```js
|
||||||
|
describe('dross chat', () => {
|
||||||
|
it('GET /api/dross returns a global conversation + Dross agent', async () => {
|
||||||
|
const res = await request(app).get('/api/dross').set(owner);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.conversation_id).toBeTruthy();
|
||||||
|
expect(res.body.agent.slug).toBe('companion');
|
||||||
|
expect(Array.isArray(res.body.messages)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/dross/turn rejects empty text (400)', async () => {
|
||||||
|
const res = await request(app).post('/api/dross/turn').set(owner).send({ text: '' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/dross without token is 401', async () => {
|
||||||
|
const res = await request(app).get('/api/dross');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
> Note: a full turn shells out to the `claude` CLI, so we don't unit-test the SSE happy-path here (it's covered by the live smoke test in Task 7). We test resolution, validation, and auth.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect FAIL** (GET `/api/dross` is 404 until added)
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/routes/dross.test.js`
|
||||||
|
Expected: FAIL on the chat block.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the history + turn handlers to `dross.js`**
|
||||||
|
|
||||||
|
Append to `lib/api/routes/dross.js`:
|
||||||
|
```js
|
||||||
|
async function resolve() {
|
||||||
|
const agent = await agents.getBySlug(COMPANION_SLUG);
|
||||||
|
const convo = await conversations.findOrCreateGlobal(agent.id, { kind: 'user', id: null });
|
||||||
|
return { agent, convo };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', asyncWrap(async (_req, res) => {
|
||||||
|
const { agent, convo } = await resolve();
|
||||||
|
const rows = await messages.listByConversation(convo.id);
|
||||||
|
res.json({
|
||||||
|
conversation_id: convo.id,
|
||||||
|
agent: { id: agent.id, slug: agent.slug, name: agent.name },
|
||||||
|
messages: rows
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
const turnSchema = z.object({
|
||||||
|
text: z.string().min(1),
|
||||||
|
view: z.object({ entityType: z.string(), entityId: z.string() }).partial().nullish()
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/turn', requireOwner, validate({ body: turnSchema }), asyncWrap(async (req, res) => {
|
||||||
|
const { agent, convo } = await resolve();
|
||||||
|
const { text, view } = req.body;
|
||||||
|
const cfg = await getCfg();
|
||||||
|
const persona = (cfg.persona && cfg.persona.trim()) ? cfg.persona : personaFor(COMPANION_SLUG);
|
||||||
|
|
||||||
|
const priorTurns = (await messages.listByConversation(convo.id)).length;
|
||||||
|
const resume = priorTurns > 0;
|
||||||
|
await messages.append(convo.id, { role: 'user', body: text });
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
||||||
|
const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||||
|
const claudeExe = req.app.locals.claudeExe || process.env.CLAUDE_EXE || 'claude';
|
||||||
|
const companionTools = ['mcp__void__search', 'mcp__void__read', 'mcp__void__context', 'mcp__void__propose_change'];
|
||||||
|
const draftIds = [];
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await runAgentTurn({
|
||||||
|
agent, persona, registryName: undefined, toolNames: companionTools,
|
||||||
|
spaceId: null, view, sessionId: convo.id, resume, userText: text, claudeExe,
|
||||||
|
home: process.env.VOID_CLAUDE_HOME || undefined,
|
||||||
|
onEvent: (e) => {
|
||||||
|
if (e.type === 'delta') send('delta', { type: 'delta', text: e.text });
|
||||||
|
else if (e.type === 'tool') send('tool', { type: 'tool', tool: e.tool, status: e.status });
|
||||||
|
else if (e.type === 'tool_result') {
|
||||||
|
let parsed = null; const tryParse = (s) => { try { return JSON.parse(s); } catch { return null; } };
|
||||||
|
if (typeof e.result === 'string') parsed = tryParse(e.result);
|
||||||
|
else if (e.result?.structuredContent?.pending_change_id) parsed = e.result.structuredContent;
|
||||||
|
else if (Array.isArray(e.result)) for (const b of e.result) {
|
||||||
|
const c = b?.type === 'text' && b.text ? tryParse(b.text) : null;
|
||||||
|
if (c?.pending_change_id) { parsed = c; break; }
|
||||||
|
}
|
||||||
|
if (parsed?.pending_change_id) {
|
||||||
|
draftIds.push(parsed.pending_change_id);
|
||||||
|
send('draft', { type: 'draft', pending_change_id: parsed.pending_change_id, summary: parsed.summary || 'a change' });
|
||||||
|
}
|
||||||
|
} else if (e.type === 'error') send('error', { type: 'error', message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) { send('error', { message: String(e?.message || e) }); res.end(); return; }
|
||||||
|
|
||||||
|
const assistant = await messages.append(convo.id, {
|
||||||
|
role: 'assistant', body: result.text, agent_id: agent.id,
|
||||||
|
metadata: { tool_trace: result.toolTrace, draft_ids: draftIds, usage: result.usage }
|
||||||
|
});
|
||||||
|
send('done', { assistant_message_id: assistant.id, draft_ids: draftIds, usage: result.usage });
|
||||||
|
res.end();
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests — expect PASS**
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/routes/dross.test.js`
|
||||||
|
Expected: PASS (all settings + chat cases).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/api/routes/dross.js tests/routes/dross.test.js
|
||||||
|
git commit -m "feat(dross): global (space-less) Dross conversation + SSE turn"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Dross avatar component
|
||||||
|
|
||||||
|
Pure render of the three violet avatars at any size. Reused by the orb, the panel header, and the Settings preview.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `public/components/dross_avatar.js`
|
||||||
|
- Test: `tests/views/dross_avatar.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test (jsdom)**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// tests/views/dross_avatar.test.js
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { drossAvatar } from '../../public/components/dross_avatar.js';
|
||||||
|
|
||||||
|
describe('drossAvatar', () => {
|
||||||
|
it('renders the requested variant class', () => {
|
||||||
|
const eye = drossAvatar('soft-eye', 60);
|
||||||
|
expect(eye.classList.contains('dross-orb')).toBe(true);
|
||||||
|
expect(eye.querySelector('.av-eye')).toBeTruthy();
|
||||||
|
expect(drossAvatar('wisp', 30).querySelector('.b-core')).toBeTruthy();
|
||||||
|
expect(drossAvatar('motes', 30).querySelector('.d-core')).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('falls back to soft-eye for unknown variants', () => {
|
||||||
|
expect(drossAvatar('bogus', 60).querySelector('.av-eye')).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('sets the pixel size', () => {
|
||||||
|
const a = drossAvatar('wisp', 42);
|
||||||
|
expect(a.style.width).toBe('42px');
|
||||||
|
expect(a.style.height).toBe('42px');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect FAIL** (module missing)
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/views/dross_avatar.test.js`
|
||||||
|
Expected: FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `dross_avatar.js`** (markup ported from `docs/mockups/dross-chat.html`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// public/components/dross_avatar.js
|
||||||
|
import { el } from '../dom.js';
|
||||||
|
|
||||||
|
// Returns a .dross-orb element rendering the chosen avatar. Colours come from
|
||||||
|
// CSS vars (--dross*), set on the element by the caller for per-user accent.
|
||||||
|
export function drossAvatar(variant = 'soft-eye', size = 60) {
|
||||||
|
let inner;
|
||||||
|
if (variant === 'wisp') {
|
||||||
|
inner = [el('div', { class: 'b-core' }), el('div', { class: 'b-bright' })];
|
||||||
|
} else if (variant === 'motes') {
|
||||||
|
inner = [
|
||||||
|
el('div', { class: 'd-ring' }, el('div', { class: 'd-mote' })),
|
||||||
|
el('div', { class: 'd-ring r2' }, el('div', { class: 'd-mote' })),
|
||||||
|
el('div', { class: 'd-core' })
|
||||||
|
];
|
||||||
|
} else { // soft-eye (default)
|
||||||
|
inner = [el('div', { class: 'av-eye' }, el('div', { class: 'av-pupil' }))];
|
||||||
|
}
|
||||||
|
return el('div', { class: 'dross-orb', style: { width: size + 'px', height: size + 'px' } }, ...inner);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run — expect PASS**
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/views/dross_avatar.test.js`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add public/components/dross_avatar.js tests/views/dross_avatar.test.js
|
||||||
|
git commit -m "feat(dross): avatar component (soft-eye / wisp / motes)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Bubble CSS
|
||||||
|
|
||||||
|
Port the orb/panel/avatar/mic/collapse styles from the mockup into `style.css` under `dross-*` class names, driven by `--dross*` vars.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `public/style.css` (append a `/* ---- Dross floating chat ---- */` block)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append the CSS block**
|
||||||
|
|
||||||
|
Append to `public/style.css` (values lifted verbatim from `docs/mockups/dross-chat.html`; replace the mock's `.orb`/`.panel`/etc. selectors with `.dross-orb`/`.dross-panel`/etc.):
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ---- Dross floating chat ---- */
|
||||||
|
:root{ --dross:#a86adf; --dross-dim:#5a2e8a; --dross-soft:#1e1030; --dross-glow:#c79bff; }
|
||||||
|
.dross-orb{position:relative;border-radius:50%;display:grid;place-items:center;overflow:hidden;flex:none;
|
||||||
|
background:radial-gradient(circle at 38% 30%, #2a1640, #1a0f2a 70%, #120a1e);
|
||||||
|
box-shadow:0 0 0 1px #ffffff12, 0 6px 22px -6px #000, 0 0 26px -4px var(--dross-dim)}
|
||||||
|
.dross-fab{position:fixed;right:20px;bottom:20px;z-index:40;cursor:grab;touch-action:none;animation:dross-bob 5s ease-in-out infinite}
|
||||||
|
.dross-fab:active{cursor:grabbing}
|
||||||
|
@keyframes dross-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
|
||||||
|
.dross-fab .dross-orb{width:60px;height:60px}
|
||||||
|
.dross-ping{position:absolute;right:-2px;top:-2px;width:17px;height:17px;border-radius:50%;background:var(--accent);
|
||||||
|
color:#0a0a0e;font-size:10px;display:grid;place-items:center;box-shadow:0 0 0 2px var(--bg);z-index:2;font-family:var(--font-ui)}
|
||||||
|
/* soft eye */
|
||||||
|
.av-eye{width:54%;height:54%;border-radius:50%;background:radial-gradient(circle at 50% 40%, #2a1c3a, #140b20);display:grid;place-items:center;box-shadow:inset 0 0 10px #000}
|
||||||
|
.av-pupil{width:44%;height:44%;border-radius:50%;position:relative;background:radial-gradient(circle at 38% 32%, #fff, var(--dross-glow) 50%, var(--dross) 100%);box-shadow:0 0 10px var(--dross-glow);animation:dross-look 7s ease-in-out infinite}
|
||||||
|
.av-pupil::after{content:"";position:absolute;right:14%;bottom:18%;width:26%;height:26%;border-radius:50%;background:#fff;opacity:.8}
|
||||||
|
@keyframes dross-look{0%,45%{transform:translate(0,0)}58%{transform:translate(3px,-2px)}72%{transform:translate(-2px,1px)}88%,100%{transform:translate(0,0)}}
|
||||||
|
/* wisp */
|
||||||
|
.b-core{position:absolute;inset:13%;border-radius:50%;filter:blur(3px);animation:dross-spin 7s linear infinite;
|
||||||
|
background:conic-gradient(from 0deg, var(--dross-dim), var(--dross-glow), var(--dross), var(--dross-soft), var(--dross-dim))}
|
||||||
|
.b-bright{position:absolute;inset:32%;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,transparent 75%);animation:dross-pulse 3s ease-in-out infinite}
|
||||||
|
@keyframes dross-spin{to{transform:rotate(360deg)}}
|
||||||
|
@keyframes dross-pulse{0%,100%{opacity:.55;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
|
||||||
|
/* motes */
|
||||||
|
.d-core{width:22%;height:22%;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,var(--dross));box-shadow:0 0 12px var(--dross-glow)}
|
||||||
|
.d-ring{position:absolute;inset:0;animation:dross-spin 5s linear infinite}
|
||||||
|
.d-ring.r2{animation-duration:8s;animation-direction:reverse}
|
||||||
|
.d-mote{position:absolute;top:11%;left:50%;width:11%;height:11%;margin-left:-5.5%;border-radius:50%;background:var(--dross-glow);box-shadow:0 0 8px var(--dross-glow)}
|
||||||
|
.d-ring.r2 .d-mote{top:auto;bottom:14%;background:var(--dross);width:8%;height:8%}
|
||||||
|
/* panel */
|
||||||
|
.dross-panel{position:fixed;right:20px;bottom:20px;width:340px;max-width:calc(100vw - 24px);height:480px;max-height:calc(100vh - 24px);
|
||||||
|
display:none;flex-direction:column;z-index:41;border:1px solid var(--dross-dim);border-radius:16px;overflow:hidden;
|
||||||
|
background:linear-gradient(180deg, rgba(30,16,48,.6), rgba(20,20,28,.96) 22%);
|
||||||
|
box-shadow:0 24px 70px -18px #000, 0 0 0 1px #00000060, 0 0 40px -16px var(--dross-dim);backdrop-filter:blur(6px)}
|
||||||
|
.dross-panel.open{display:flex}
|
||||||
|
.dross-hd{display:flex;align-items:center;gap:10px;padding:11px 12px;cursor:grab;touch-action:none;
|
||||||
|
background:linear-gradient(180deg, var(--dross-soft), transparent);border-bottom:1px solid var(--border)}
|
||||||
|
.dross-hd .dross-orb{width:30px;height:30px}
|
||||||
|
.dross-who{font-family:var(--font-display);letter-spacing:.16em;text-transform:uppercase;font-size:13px;color:#efe9f6;flex:1}
|
||||||
|
.dross-who small{display:block;font-family:var(--font-mono);letter-spacing:0;text-transform:none;font-size:10px;color:var(--dross-glow);opacity:.85}
|
||||||
|
.dross-x{background:none;border:0;color:var(--muted);font-size:18px;cursor:pointer;padding:4px 6px}
|
||||||
|
.dross-x:hover{color:var(--text)}
|
||||||
|
.dross-log{flex:1;overflow:auto;padding:12px 12px;display:flex;flex-direction:column;gap:10px}
|
||||||
|
.dross-inwrap{padding:10px;border-top:1px solid var(--border);background:#0d0a12;display:flex;flex-direction:column;gap:9px}
|
||||||
|
.dross-inwrap textarea{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:10px;padding:10px 12px;color:var(--text);font-family:var(--font-mono);font-size:13px;resize:none;height:46px;max-height:96px}
|
||||||
|
.dross-btnrow{display:flex;gap:10px}
|
||||||
|
.dross-mic{flex:1;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:var(--dross-soft);color:var(--dross-glow);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:9px;font-family:var(--font-ui);font-size:13px}
|
||||||
|
.dross-mic[disabled]{opacity:.5;cursor:not-allowed}
|
||||||
|
.dross-send{width:64px;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:linear-gradient(180deg,var(--dross),var(--dross-dim));color:#fff;cursor:pointer;display:grid;place-items:center}
|
||||||
|
.dross-collapse{display:flex;align-items:center;justify-content:center;gap:8px;height:34px;cursor:pointer;color:var(--muted);
|
||||||
|
font-family:var(--font-ui);font-size:11px;letter-spacing:.12em;text-transform:uppercase;background:#0b0810;border-top:1px solid var(--border)}
|
||||||
|
.dross-collapse:hover{color:var(--dross-glow)}
|
||||||
|
.dross-collapse .grip{width:42px;height:4px;border-radius:3px;background:var(--border)}
|
||||||
|
/* reuse existing .turn/.msg/.tools/.chip chat styles from the rail */
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add public/style.css
|
||||||
|
git commit -m "feat(dross): floating bubble + avatar styles"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Bubble component (`dross_bubble.js`) + mount globally
|
||||||
|
|
||||||
|
Self-contained component: a fixed FAB orb that opens a draggable, anchored panel with the chat (via `wireAgentChat`), a top-right ✕ and a bottom "⌄ collapse", both minimising to the orb. Reads `dross` settings for avatar/accent and applies `--dross*` accent. (Mic button is rendered but **disabled** with title "Voice arrives in Phase 2".)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `public/components/dross_bubble.js`
|
||||||
|
- Modify: `public/app.js` (replace `renderRightrail` with `renderDrossBubble`)
|
||||||
|
- Modify: `public/index.html` (remove the now-unused `<aside id="rightrail">`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement `dross_bubble.js`**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// public/components/dross_bubble.js
|
||||||
|
// Global floating Dross companion. Replaces the per-Space right rail.
|
||||||
|
import { el, mount } from '../dom.js';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { state } from '../state.js';
|
||||||
|
import { wireAgentChat } from './agent_chat.js';
|
||||||
|
import { drossAvatar } from './dross_avatar.js';
|
||||||
|
|
||||||
|
const TOOL_LABELS = { search: '🔍 searching', read: '📄 reading', context: '🧭 looking at this view', propose_change: '📝 drafting a change' };
|
||||||
|
let cfg = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||||
|
|
||||||
|
function applyAccent(node, hex) {
|
||||||
|
// derive dim/soft/glow from the chosen accent so the whole orb stays coherent
|
||||||
|
node.style.setProperty('--dross', hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderDrossBubble(rootIgnored) {
|
||||||
|
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { /* defaults */ }
|
||||||
|
|
||||||
|
const host = el('div', { class: 'dross-host' });
|
||||||
|
document.getElementById('shell').appendChild(host);
|
||||||
|
|
||||||
|
const fab = el('div', { class: 'dross-fab', title: 'Dross' },
|
||||||
|
el('div', { class: 'dross-ping', style: { display: 'none' } }, ''), drossAvatar(cfg.avatar, 60));
|
||||||
|
const log = el('div', { class: 'dross-log' });
|
||||||
|
const input = el('textarea', { rows: 1, placeholder: 'Ask Dross…' });
|
||||||
|
const sendBtn = el('button', { class: 'dross-send', title: 'Send' },
|
||||||
|
el('span', { html: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>' }));
|
||||||
|
const mic = el('button', { class: 'dross-mic', disabled: true, title: 'Voice arrives in Phase 2' },
|
||||||
|
el('span', { html: '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg>' }), 'Hold to talk');
|
||||||
|
const closeBtn = el('button', { class: 'dross-x', title: 'Close' }, '⤬');
|
||||||
|
const header = el('div', { class: 'dross-hd' }, drossAvatar(cfg.avatar, 30),
|
||||||
|
el('div', { class: 'dross-who' }, 'Dross', el('small', {}, 'always here, regrettably')), closeBtn);
|
||||||
|
const collapse = el('div', { class: 'dross-collapse', title: 'Collapse' },
|
||||||
|
el('span', { class: 'grip' }), el('span', {}, '⌄ collapse'), el('span', { class: 'grip' }));
|
||||||
|
const panel = el('div', { class: 'dross-panel' }, header, log,
|
||||||
|
el('div', { class: 'dross-inwrap' }, input, el('div', { class: 'dross-btnrow' }, mic, sendBtn)), collapse);
|
||||||
|
|
||||||
|
host.append(fab, panel);
|
||||||
|
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
|
||||||
|
|
||||||
|
const chat = wireAgentChat({
|
||||||
|
logEl: log, inputEl: input, sendBtnEl: sendBtn,
|
||||||
|
historyUrl: '/api/dross', turnUrl: '/api/dross/turn',
|
||||||
|
agentName: 'Dross', showDrafts: true, toolLabels: TOOL_LABELS,
|
||||||
|
turnBody: (text) => ({ text, view: state.view || null })
|
||||||
|
});
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
function openPanel() {
|
||||||
|
const r = fab.getBoundingClientRect();
|
||||||
|
panel.classList.add('open'); fab.style.display = 'none';
|
||||||
|
const pr = panel.getBoundingClientRect();
|
||||||
|
const left = Math.max(8, Math.min(r.right - pr.width, innerWidth - pr.width - 8));
|
||||||
|
const top = Math.max(8, Math.min(r.bottom - pr.height, innerHeight - pr.height - 8));
|
||||||
|
panel.style.right = 'auto'; panel.style.bottom = 'auto'; panel.style.left = left + 'px'; panel.style.top = top + 'px';
|
||||||
|
if (!loaded) { loaded = true; chat.load(); }
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
function closePanel() { panel.classList.remove('open'); fab.style.display = 'block'; }
|
||||||
|
fab.addEventListener('click', () => { if (fab._moved) { fab._moved = false; return; } openPanel(); });
|
||||||
|
closeBtn.addEventListener('click', closePanel);
|
||||||
|
collapse.addEventListener('click', closePanel);
|
||||||
|
|
||||||
|
drag(fab, fab, true); drag(header, panel, false);
|
||||||
|
|
||||||
|
// re-apply settings live when the Settings panel saves
|
||||||
|
window.addEventListener('dross-settings-changed', async () => {
|
||||||
|
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { return; }
|
||||||
|
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
|
||||||
|
mount(fab, el('div', { class: 'dross-ping', style: { display: 'none' } }), drossAvatar(cfg.avatar, 60));
|
||||||
|
header.replaceChild(drossAvatar(cfg.avatar, 30), header.firstChild);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drag(handle, target, isFab) {
|
||||||
|
handle.addEventListener('pointerdown', (e) => {
|
||||||
|
if (e.target.closest('.dross-x') || e.target.closest('.dross-mic') || e.target.closest('.dross-send')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const r = target.getBoundingClientRect(); const sx = e.clientX, sy = e.clientY; let moved = false;
|
||||||
|
target.style.right = 'auto'; target.style.bottom = 'auto'; target.style.left = r.left + 'px'; target.style.top = r.top + 'px';
|
||||||
|
const mv = (ev) => {
|
||||||
|
const dx = ev.clientX - sx, dy = ev.clientY - sy; if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;
|
||||||
|
target.style.left = Math.max(4, Math.min(innerWidth - r.width - 4, r.left + dx)) + 'px';
|
||||||
|
target.style.top = Math.max(4, Math.min(innerHeight - r.height - 4, r.top + dy)) + 'px';
|
||||||
|
};
|
||||||
|
const up = () => { document.removeEventListener('pointermove', mv); document.removeEventListener('pointerup', up); if (isFab) target._moved = moved; };
|
||||||
|
document.addEventListener('pointermove', mv); document.addEventListener('pointerup', up);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Swap the mount in `public/app.js`**
|
||||||
|
|
||||||
|
Replace the import:
|
||||||
|
```js
|
||||||
|
import { renderRightrail } from './components/rightrail.js';
|
||||||
|
```
|
||||||
|
with:
|
||||||
|
```js
|
||||||
|
import { renderDrossBubble } from './components/dross_bubble.js';
|
||||||
|
```
|
||||||
|
and replace the call in `init()`:
|
||||||
|
```js
|
||||||
|
renderRightrail(document.getElementById('rightrail'));
|
||||||
|
```
|
||||||
|
with:
|
||||||
|
```js
|
||||||
|
renderDrossBubble();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove the dead rail element** in `public/index.html` — delete the line:
|
||||||
|
```html
|
||||||
|
<aside id="rightrail"></aside>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Headless-verify** (the bubble can't be unit-tested for drag; use Playwright per the headless-ui-check skill). Deploy to a scratch run or use the live deploy in Task 7; assert: `.dross-fab` exists; clicking it shows `.dross-panel.open`; the bottom `.dross-collapse` closes it; no console errors.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add public/components/dross_bubble.js public/app.js public/index.html
|
||||||
|
git commit -m "feat(dross): global floating bubble; retire the right rail"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Settings → Dross section
|
||||||
|
|
||||||
|
Avatar picker (3 buttons using `drossAvatar` previews), accent colour input, persona textarea, voice-mode select. Saves to `/api/dross/settings` and dispatches `dross-settings-changed` so the live bubble updates.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `public/views/settings.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the Dross section body builder**
|
||||||
|
|
||||||
|
In `public/views/settings.js`, add the import:
|
||||||
|
```js
|
||||||
|
import { drossAvatar } from '../components/dross_avatar.js';
|
||||||
|
```
|
||||||
|
and a builder:
|
||||||
|
```js
|
||||||
|
function drossBody() {
|
||||||
|
const wrap = el('div', { class: 'settings-body' });
|
||||||
|
const out = el('span', { class: 'muted', style: { marginLeft: '8px' } });
|
||||||
|
let cur = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||||
|
const avatarRow = el('div', { class: 'dross-pick' });
|
||||||
|
const accent = el('input', { type: 'color', value: cur.accent });
|
||||||
|
const persona = el('textarea', { class: 'dross-persona', rows: 6, placeholder: "Dross's system prompt…" });
|
||||||
|
const mode = el('select', { class: 'pm-input', style: { maxWidth: '200px' } },
|
||||||
|
el('option', { value: 'review' }, 'Voice: review then send'),
|
||||||
|
el('option', { value: 'handsfree' }, 'Voice: hands-free (Phase 2)'),
|
||||||
|
el('option', { value: 'action' }, 'Voice: interpret to action (later)'));
|
||||||
|
|
||||||
|
function paintAvatars() {
|
||||||
|
mount(avatarRow, ['soft-eye', 'wisp', 'motes'].map(v => {
|
||||||
|
const card = el('button', { class: 'dross-avopt' + (cur.avatar === v ? ' on' : ''), title: v },
|
||||||
|
drossAvatar(v, 48), el('span', {}, v));
|
||||||
|
card.style.setProperty('--dross', cur.accent);
|
||||||
|
card.onclick = () => { cur.avatar = v; paintAvatars(); };
|
||||||
|
return card;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
try { cur = { ...cur, ...(await api.get('/api/dross/settings')) }; } catch {}
|
||||||
|
accent.value = cur.accent; persona.value = cur.persona; mode.value = cur.voiceMode; paintAvatars();
|
||||||
|
})();
|
||||||
|
accent.addEventListener('input', () => { cur.accent = accent.value; paintAvatars(); });
|
||||||
|
|
||||||
|
const save = el('button', { class: 'primary' }, 'Save');
|
||||||
|
save.onclick = async () => {
|
||||||
|
try {
|
||||||
|
await api.put('/api/dross/settings', { avatar: cur.avatar, accent: accent.value, persona: persona.value, voiceMode: mode.value });
|
||||||
|
window.dispatchEvent(new CustomEvent('dross-settings-changed'));
|
||||||
|
out.textContent = 'Saved.';
|
||||||
|
} catch { out.textContent = 'Save failed'; }
|
||||||
|
};
|
||||||
|
return el('div', { class: 'settings-body' }, avatarRow, el('label', { class: 'st-lbl' }, 'Accent', accent),
|
||||||
|
persona, el('div', { class: 'theme-actions' }, mode, save, out));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
and register it in `render()` (after the Theming section):
|
||||||
|
```js
|
||||||
|
section('Dross', "Your companion's look and voice. Avatar, accent colour, his personality (system prompt), and how voice clips behave.", drossBody()),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add minimal CSS** (append to `public/style.css`):
|
||||||
|
```css
|
||||||
|
.dross-pick{display:flex;gap:12px;margin-bottom:12px;flex-wrap:wrap}
|
||||||
|
.dross-avopt{display:flex;flex-direction:column;align-items:center;gap:6px;padding:10px 14px;border:1px solid var(--border);border-radius:12px;background:var(--panel);color:var(--muted);cursor:pointer;font-size:11px}
|
||||||
|
.dross-avopt.on{border-color:var(--dross);color:var(--dross-glow);background:var(--dross-soft)}
|
||||||
|
.dross-persona{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:8px;padding:10px;color:var(--text);font-family:var(--font-mono);font-size:12px;resize:vertical;margin:10px 0}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Headless-verify** in Task 7: the Settings page shows three avatar options, colour input, persona textarea, voice-mode select; saving updates the live bubble's orb without a reload.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add public/views/settings.js public/style.css
|
||||||
|
git commit -m "feat(dross): Settings panel — avatar, accent, persona, voice-mode"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Deploy, verify end-to-end, document
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json` (version bump), `CHANGELOG`/wiki as per repo convention
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full test run** — `npx vitest run` → expect all green (new dross + avatar tests included).
|
||||||
|
- [ ] **Step 2: Bump version** — `npm version 2.11.0 --no-git-tag-version`.
|
||||||
|
- [ ] **Step 3: Deploy** — `./deploy/push.sh` (health-gated; runs migrate — no new migration this phase, app_settings already exists).
|
||||||
|
- [ ] **Step 4: Live smoke (headless, token-injected, per headless-ui-check):**
|
||||||
|
- Load `#/sacred-valley`: `.dross-fab` present, no console errors.
|
||||||
|
- Click fab → `.dross-panel.open`; type a message + send → an assistant turn streams in (live `claude` turn — confirms global Dross works without a Space open).
|
||||||
|
- Bottom `.dross-collapse` closes it; drag the fab; reload → still there.
|
||||||
|
- `#/settings`: change avatar → Save → the live fab orb changes without reload.
|
||||||
|
- [ ] **Step 5: Document** (standing rule — wiki + git): update the spec's status to "Phase 1 shipped", add a Void wiki page "Floating Dross chat — Phase 1 (2.11.0)", update memory `project_cradle_chat_floating` and `project_void2_alpha27_and_git`. Tag `v2.11.0`, push to Gitea.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:** Global Dross (Task 2) ✓ · floating draggable orb+panel (Task 5) ✓ · close ✕ + bottom collapse (Task 5) ✓ · 3 avatars + default soft-eye (Tasks 3,6) ✓ · violet + tunable accent (Tasks 4,5,6) ✓ · tunable persona (Tasks 1,2,6) ✓ · voice-mode setting present, mic disabled (Tasks 1,5,6) ✓ · retire right rail (Task 5) ✓. Voice transcription itself is Phase 2 (out of scope here, per spec) — mic is intentionally disabled.
|
||||||
|
|
||||||
|
**Placeholder scan:** No TBD/TODO; every code step has complete code; tests have real assertions.
|
||||||
|
|
||||||
|
**Type/name consistency:** `drossAvatar(variant,size)` used identically in Tasks 3/5/6; settings keys `{avatar,accent,persona,voiceMode}` consistent across route (Task 1), bubble (Task 5), settings (Task 6); event name `dross-settings-changed` matches between Task 5 listener and Task 6 dispatcher; route paths `/api/dross`, `/api/dross/turn`, `/api/dross/settings` consistent.
|
||||||
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
@@ -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).
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Device icons, last-seen timer & uploadable icon sets — design
|
||||||
|
|
||||||
|
Date: 2026-06-09
|
||||||
|
Feature area: Void dashboard → LAN Devices band (`lan_devices`, migration 024)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Let the user assign an icon to each discovered LAN device (device-type icon OR
|
||||||
|
brand logo — "both"), show how long ago an absent device was last seen, and
|
||||||
|
manage/extend the available icons by uploading new icon sets from Settings.
|
||||||
|
|
||||||
|
## Background (existing code reused)
|
||||||
|
- `lan_devices` table (migration 024): MAC-keyed inventory; already has
|
||||||
|
`last_seen timestamptz` and `present boolean`. No icon column yet.
|
||||||
|
- `public/views/devices_band.js`: renders tiles + an edit (✎) flow; `/api/devices`
|
||||||
|
PATCH (`lib/api/routes/devices.js`, zod `patchBody`).
|
||||||
|
- **Existing icon proxy** (reused for brand logos): `GET /api/icons/:slug.png`
|
||||||
|
→ `lib/health/icons.js#getIcon()` fetches `walkxcode/dashboard-icons` PNGs via
|
||||||
|
jsDelivr and caches them to `/var/lib/void/icons`. `validSlug = ^[a-z0-9-]+$`.
|
||||||
|
`public/components/service_tile.js` renders `<img src=/api/icons/${slug}.png>`
|
||||||
|
with a letter fallback on error.
|
||||||
|
|
||||||
|
## Icon model
|
||||||
|
A device's `icon` value is one of:
|
||||||
|
- `set:<set>:<name>` → bundled/uploaded type icon, served `/api/icon-sets/<set>/<name>`
|
||||||
|
- `brand:<slug>` → dashboard-icons logo, served `/api/icons/<slug>.png` (existing)
|
||||||
|
- `NULL` → auto-default chosen by group/vendor (pure function)
|
||||||
|
|
||||||
|
Auto-default mapping (group → bundled `devices` set):
|
||||||
|
Network→router, Entertainment→tv, Smart Home→plug, Personal→phone, else→unknown.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. Data — migration 02x
|
||||||
|
`ALTER TABLE lan_devices ADD COLUMN icon text;` (nullable). No backfill (NULL =
|
||||||
|
auto-default). Down: drop column.
|
||||||
|
|
||||||
|
### 2. Bundled type-icon set (the "set of favicons")
|
||||||
|
Download ~15 **Tabler Icons** (MIT) SVGs into the repo at
|
||||||
|
`public/icons/devices/` as the read-only bundled set named `devices`:
|
||||||
|
router, phone, tablet, laptop, desktop, tv, speaker, camera, printer, console,
|
||||||
|
plug, server, watch, nas, unknown. Monochrome line icons → match blackflame.
|
||||||
|
|
||||||
|
### 3. Uploadable icon sets (persistent, outside git)
|
||||||
|
- Storage: `/var/lib/void/icon-sets/<set>/<name>.(svg|png)` (persistent volume,
|
||||||
|
survives redeploys — NOT in git-tracked `public/`). Env override
|
||||||
|
`ICON_SETS_DIR`, default `/var/lib/void/icon-sets`.
|
||||||
|
- A "set" is a directory of icon files. Set/name validated `^[a-z0-9-]+$`.
|
||||||
|
- **Three ingest methods**, all converging on the same per-file processor:
|
||||||
|
1. **Multi-file** — one or more SVG/PNG files.
|
||||||
|
2. **Zip archive** — server unpacks; each entry runs the per-file processor.
|
||||||
|
Reject path traversal / absolute paths / nested dirs (flatten basenames);
|
||||||
|
skip non-image entries; cap entry count + uncompressed total (zip-bomb
|
||||||
|
guard).
|
||||||
|
3. **URL ingest** — server fetches a remote URL; if the payload is a zip it is
|
||||||
|
unpacked (as above), otherwise treated as a single image. http/https only
|
||||||
|
(scheme allowlist, SSRF guard), 8 s timeout, total size cap.
|
||||||
|
- **Per-file processor (shared):** validate name slug + extension; magic-byte
|
||||||
|
check (PNG/JPEG/SVG); **sanitize SVGs** (strip `<script>`, `on*` handlers,
|
||||||
|
external refs — uploaded SVGs render inline → XSS risk even behind CF Access);
|
||||||
|
enforce per-file size cap (e.g. 256 KB); write into the set dir.
|
||||||
|
|
||||||
|
### 4. API (`lib/api/routes/`)
|
||||||
|
- `GET /api/icon-sets` → `[{ set, readonly, icons:[name…] }]` (bundled `devices`
|
||||||
|
scanned from `public/icons/devices`, uploads scanned from ICON_SETS_DIR).
|
||||||
|
- `GET /api/icon-sets/:set/:file` → serve the icon (correct Content-Type;
|
||||||
|
Cache-Control). Validates slugs; 404 on miss.
|
||||||
|
- `POST /api/icon-sets/:set` (requireOwner) → create/extend a set. Accepts
|
||||||
|
EITHER multipart files (one or more SVG/PNG, and/or a `.zip`) OR a JSON body
|
||||||
|
`{ url }` for URL ingest. All inputs run through the shared per-file processor
|
||||||
|
(§3). Returns the updated set. SSRF guard + size/timeout caps on URL ingest.
|
||||||
|
- `DELETE /api/icon-sets/:set` (requireOwner) → remove an uploaded set; the
|
||||||
|
bundled `devices` set is read-only (409).
|
||||||
|
- Extend `patchBody` in `devices.js` with
|
||||||
|
`icon: z.string().regex(/^(set:[a-z0-9-]+:[a-z0-9-]+|brand:[a-z0-9-]+)$/).nullable().optional()`.
|
||||||
|
- Ensure `GET /api/devices` returns `icon` and `last_seen`.
|
||||||
|
|
||||||
|
### 5. Frontend — `devices_band.js`
|
||||||
|
- `resolveIcon(iconRef)` (pure): `set:` → `/api/icon-sets/<set>/<name>`;
|
||||||
|
`brand:` → `/api/icons/<slug>.png`; null → auto-default → `set:devices:<name>`.
|
||||||
|
`<img>` with the existing letter fallback on error.
|
||||||
|
- Tile shows the icon. Edit (✎) mode gains an **icon picker** with two tabs:
|
||||||
|
- **Type**: grid grouped by set (bundled `devices` first, then uploads),
|
||||||
|
fetched from `GET /api/icon-sets`.
|
||||||
|
- **Brand**: a search box → live preview from `/api/icons/<slug>.png`.
|
||||||
|
Selecting writes `icon` via the existing PATCH.
|
||||||
|
- `relativeTime(ts)` (pure): <60s "just now"; <60m "Nm ago"; <24h "Nh ago";
|
||||||
|
else "Nd ago". Shown as "seen Nh ago" on **absent** tiles only (present tiles
|
||||||
|
keep the online dot).
|
||||||
|
|
||||||
|
### 6. Settings — expandable "Icon sets" section
|
||||||
|
- Collapsible panel (`public/views/settings*` pattern): lists each set as a grid
|
||||||
|
of its icons; uploaded sets get a Delete; bundled `devices` is read-only.
|
||||||
|
- Upload control: a set-name field plus three inputs → `POST /api/icon-sets/:set`,
|
||||||
|
refresh the list on success:
|
||||||
|
- multi-file picker (SVG/PNG),
|
||||||
|
- zip picker (`.zip`),
|
||||||
|
- a URL field ("ingest from URL" — image or zip).
|
||||||
|
|
||||||
|
## Testing (vitest)
|
||||||
|
Pure/unit: `resolveIcon`, `relativeTime`, auto-default mapping, SVG sanitizer,
|
||||||
|
slug validation, `patchBody` icon regex, zip-entry guard (traversal/zip-bomb),
|
||||||
|
URL SSRF/scheme guard. Integration: icon-sets list/upload/delete (tmp dir via
|
||||||
|
env override), multi-file + zip + URL ingest (mock fetcher) all landing files,
|
||||||
|
devices PATCH accepts/round-trips `icon`, GET returns icon+last_seen. Reuse the
|
||||||
|
existing icon-proxy test patterns.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
Per backup-before-major-updates: `pct snapshot` void-db (310) + void-app (311),
|
||||||
|
run the migration, deploy via the health-gated script, headless-render the
|
||||||
|
Devices band + Settings panel to confirm icons + picker + last-seen display.
|
||||||
|
Ensure ICON_SETS_DIR exists + is writable by the `void` user; document the env
|
||||||
|
var. Commit + push to Gitea `Hynes/Void-Homelab`; wiki page update.
|
||||||
|
|
||||||
|
## Out of scope (YAGNI)
|
||||||
|
Per-icon recolor/theming, auto-icon-by-vendor guessing beyond the group default,
|
||||||
|
icon sets shared across other Void features, scraping a whole remote icon-pack
|
||||||
|
repo (URL ingest is single-file or single-zip, not a directory crawl).
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Floating Dross Chat — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-09
|
||||||
|
**Status:** Phase 1 SHIPPED (v2.11.0, 2026-06-10) — global floating bubble + avatars + settings + persona. Phase 2 (voice) and the "Keep voice clips" retention setting are next.
|
||||||
|
**Goal:** Replace the docked, per-Space "Cradle Chat" with a global, movable floating-bubble Dross companion — mobile-first, with voice-clip input transcribed locally into instructions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background / problem
|
||||||
|
|
||||||
|
Today the companion lives in the right rail (`public/components/rightrail.js`). It is **per-Space**: it binds to the active Space's companion conversation (`/api/spaces/:space_id/companion`) and shows "Open a Space to chat with its companion." everywhere else — Sacred Valley, the apps, etc. Because the user mostly lives on non-Space views, the chat is empty/collapsed most of the time, which is why it "feels closed and not very Dross." The right rail is also cramped on mobile.
|
||||||
|
|
||||||
|
The chat mechanics are already factored into a reusable engine (`public/components/agent_chat.js`, `wireAgentChat({logEl, inputEl, historyUrl, turnUrl, …})`). Turns stream over SSE via `lib/ai/agent/run_turn.js`. Dross is the agent with slug `companion`.
|
||||||
|
|
||||||
|
## Locked decisions (from brainstorming)
|
||||||
|
|
||||||
|
1. **Global Dross** — one always-available companion, summonable on every view; not tied to a Space. He is told what the user is currently looking at (view context) but isn't locked to it.
|
||||||
|
2. **Floating bubble** — a draggable violet orb that opens a draggable chat panel anchored to the orb. Replaces the right-rail companion. Position + open/closed state persist. Mobile = near-full-width panel.
|
||||||
|
3. **Collapse / close** — keep the **close (✕) top-right**, and add a thumb-friendly **"⌄ collapse" bar at the bottom** of the panel. Both minimise back to the orb.
|
||||||
|
4. **Avatar** — default **Soft Eye**; selectable in Settings between **Soft Eye**, **Wisp Core**, **Orbiting Motes** (all violet).
|
||||||
|
5. **Colour** — Dross is **violet** by default, but his accent is **tunable in Settings** (his own vars, independent of the UI theme).
|
||||||
|
6. **Persona** — give him the real Cradle-Dross voice (dry, sardonic, impatient, brilliant, secretly loyal) via an **editable system prompt in Settings** (tunable).
|
||||||
|
7. **Voice** — record a clip → transcribe with **local faster-whisper on the Ollama box (CT 102, GPU, CPU-fallback)** → transcript lands in the input for **review-and-send first (mode 1)**. A *voice-mode* setting allows graduating to **hands-free auto-send (mode 2)**, then **interpret-into-confirmable-action (mode 3)** later.
|
||||||
|
8. **Audio retention (Phase 2, added 2026-06-09)** — by default the clip is transcribed then **destroyed** (transient). Add a **Dross setting** "Keep voice clips" that, when on, **saves each audio clip paired with its transcript**, stored **safely and securely** (encrypted at rest / access-controlled; on a homelab dataset, owner-only — exact store TBD in P2: e.g. a `voice_clips` table + blob on a ZFS dataset, or object store). Off by default. This is a P2 deliverable, designed-for now.
|
||||||
|
|
||||||
|
## Non-goals (this iteration)
|
||||||
|
|
||||||
|
- Voice modes 2 and 3 are designed-for but not built now (mode setting ships; only mode 1 wired).
|
||||||
|
- Multi-conversation history browser, per-Space companions in the bubble, and wake-word/always-listening are out of scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
| Unit | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `public/components/dross_bubble.js` (new) | The floating orb + panel: render, drag (orb & panel header), anchored open, collapse/close, avatar switch, voice record UI. Drives chat via `wireAgentChat`. Replaces the `renderRightrail` mount in `app.js`. |
|
||||||
|
| `public/components/dross_avatar.js` (new) | Pure render of the chosen avatar (soft-eye / wisp / motes) at a given size — reused by orb + panel header + settings preview. |
|
||||||
|
| `lib/api/routes/dross.js` (new) | Global (space-less) Dross: `GET /api/dross` (history + conversation id) and `POST /api/dross/turn` (SSE). Mirrors `companion.js` but resolves a **global** conversation for the `companion` agent and injects the persona + view context. |
|
||||||
|
| `lib/api/routes/voice.js` (new) | `POST /api/voice/transcribe` — accepts an audio blob, proxies to the faster-whisper service, returns `{ text }`. Owner-only. |
|
||||||
|
| `public/views/settings.js` (extend) | New **Dross** section: avatar picker, accent colour, persona textarea, voice-mode select. Persists to `app_settings` key `dross`. |
|
||||||
|
| faster-whisper service on **CT 102** (infra) | OpenAI-compatible `/v1/audio/transcriptions` (e.g. `faster-whisper-server`/`speaches`), GPU with CPU fallback, small/base model. Shares the Ollama LXC. |
|
||||||
|
|
||||||
|
### Settings shape (`app_settings` key `dross`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"avatar": "soft-eye", // soft-eye | wisp | motes
|
||||||
|
"accent": "#a86adf", // Dross's violet (independent of UI theme)
|
||||||
|
"persona": "<system prompt text>",
|
||||||
|
"voiceMode": "review" // review | handsfree | action(later)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reuses the generic `app_settings` store (added in 2.9.0) and the `/api/theme`-style read-on-boot pattern. The bubble fetches `dross` settings on mount; the Settings panel writes them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
**Text turn:** input → `wireAgentChat` → `POST /api/dross/turn` (body `{ text, view }`) → SSE stream of Dross's reply (+ tool labels) into the panel log. History via `GET /api/dross`.
|
||||||
|
|
||||||
|
**Voice turn (mode 1):** tap mic → `MediaRecorder` captures a clip → on stop, `POST /api/voice/transcribe` (audio blob) → void-app proxies to CT 102 faster-whisper → `{ text }` → text dropped into the input for the user to review/edit → user sends as a normal turn. (Mode 2 would auto-send; mode 3 would route the transcript through an interpret step.)
|
||||||
|
|
||||||
|
**Persona:** the `dross.persona` setting is injected as/with the agent's system prompt in `run_turn` for the global conversation, so his voice is consistent and user-tunable.
|
||||||
|
|
||||||
|
**Context:** `view` (current route/entity) is passed in the turn body so Dross can answer "what am I looking at" questions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- **STT unavailable / GPU absent:** transcribe endpoint returns a clear error; the bubble shows "couldn't transcribe — type instead" and never blocks text input. faster-whisper falls back to CPU on a GPU-less node (per the GPU/CPU-fallback HA rule) — slower but functional.
|
||||||
|
- **Mic permission denied:** show a one-line hint; hide the recording UI, keep typing.
|
||||||
|
- **Turn/stream failure:** existing `agent_chat` error path (surfaces an error bubble); retain the typed/transcribed text so it isn't lost.
|
||||||
|
- **No token / 401:** bubble stays collapsed; opening prompts the normal owner-token flow.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Headless UI:** bubble renders; orb → open (anchored) → drag → collapse (bottom bar) → close (✕); each avatar variant renders; mobile width = near-full panel.
|
||||||
|
- **Settings:** changing avatar/accent/persona/voiceMode persists (`app_settings`) and re-applies on reload.
|
||||||
|
- **API:** `GET /api/dross` returns a global conversation; `POST /api/dross/turn` streams; `POST /api/voice/transcribe` returns `{text}` for a sample WAV (mock the whisper service in the unit test; one live smoke test against CT 102).
|
||||||
|
- **Persona:** a turn reflects the configured system prompt.
|
||||||
|
|
||||||
|
## Build phases
|
||||||
|
|
||||||
|
- **P1 — Floating bubble + global Dross + settings.** New `dross_bubble.js` + `dross_avatar.js`, `dross.js` route (global conversation), Settings → Dross section (avatar/accent/persona/voice-mode). Retire the right-rail companion. *No voice yet.* Ship-able on its own.
|
||||||
|
- **P2 — Voice (review-and-send).** faster-whisper on CT 102, `voice.js` transcribe proxy, record UI + waveform, transcript → input → review → send.
|
||||||
|
- **P3 — Later.** Voice mode 2 (hands-free auto-send), then mode 3 (interpret transcript into a confirmable action via the existing Little Blue action framework).
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Per the standing rule, ship docs to the Void wiki + Gitea (`Hynes/Void-Homelab`) with each phase; spec + plan under `docs/superpowers/`. Mockup at `docs/mockups/dross-chat.html`.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Floating Dross Chat — Phase 2 (Voice) Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-10
|
||||||
|
**Status:** SHIPPED — P2a v2.12.0 (transcribe+mic), P2b v2.13.0 (retention), 2026-06-10. Known gap: clips HA-replicated Z↔Z3 but not yet in the offsite Farm backup. Future: whisper-model selector, configurable storage, encryption-at-rest, LAN-IP mic (https-on-LAN).
|
||||||
|
**Builds on:** `2026-06-09-floating-dross-chat-design.md` (Phase 1 shipped in v2.11.0)
|
||||||
|
**Goal:** Let the user record a voice clip in the Dross bubble, transcribe it locally, and drop the transcript into the input to review-and-send. Optionally retain each clip paired with its transcript, stored durably and owner-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Locked decisions (from the Phase-1 brainstorm + 2026-06-10 follow-up)
|
||||||
|
|
||||||
|
1. **STT = local faster-whisper on CT 102** (the Ollama box, RTX A2000). GPU with CPU fallback (per the GPU/CPU-fallback HA rule). English model (`small.en`) for speed/accuracy. OpenAI-compatible HTTP API.
|
||||||
|
2. **Flow = review-and-send first** (`voiceMode: 'review'`). Record → transcribe → transcript lands in the bubble input → user edits/sends. `handsfree` (auto-send) and `action` (interpret) are later (the setting already exists; only `review` is wired now).
|
||||||
|
3. **Retention = "Keep voice clips" Dross setting**, default OFF. When ON, each clip is saved paired with its transcript. **Storage:** transcript + metadata in **void-db** (`voice_clips` table — in the Core-4 offsite backup + HA-replicated); audio files on a **dedicated owner-only ZFS dataset** (`localzfs/void-voiceclips`, bind-mounted into void-app at `/var/lib/void/voice-clips`, 0700), **added to the offsite backup + syncoid replication**. NOT on void-app's ephemeral rootfs (it's the rebuildable tier, excluded from backups). Encryption-at-rest is a documented **future** toggle (ZFS native encryption, key in Vaultwarden).
|
||||||
|
|
||||||
|
## Non-goals (this phase)
|
||||||
|
|
||||||
|
- `handsfree` / `action` voice modes (designed-for; only `review` wired).
|
||||||
|
- Encryption-at-rest of clips (future).
|
||||||
|
- Wake-word / always-listening.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
| Unit | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| **faster-whisper service** (CT 102, infra) | OpenAI-compatible `/v1/audio/transcriptions` (e.g. `faster-whisper-server`/`speaches`), `small.en`, GPU+CPU fallback, systemd unit, bound to `192.168.1.185:<port>` (LAN-only). |
|
||||||
|
| `lib/voice/whisper.js` (void-app) | Thin client: POST the audio buffer to the CT-102 service, return `{ text }`. Timeout + error surface. |
|
||||||
|
| `lib/api/routes/voice.js` (void-app) | `POST /api/voice/transcribe` (owner-only, multipart, ≤25 MB / ≤60 s): transcribe; if `dross.keepClips` is on, persist (Task: retention). Returns `{ text, clip_id? }`. |
|
||||||
|
| `lib/db/repos/voice_clips.js` + migration | `voice_clips` table (id, transcript, duration_ms, bytes, mime, path, created_at). |
|
||||||
|
| `public/components/dross_bubble.js` (edit) | Enable the mic: `MediaRecorder` capture (tap start/stop), recording UI (timer/waveform), upload, transcript → input (review-and-send). |
|
||||||
|
| Settings → Dross (edit) | Add the **"Keep voice clips"** toggle; a small **clips list** (play / delete) when retention is on. |
|
||||||
|
| Infra | New ZFS dataset + bind-mount; add the dataset to the offsite-backup script + syncoid job. |
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
**Transcribe:** mic tap → `MediaRecorder` (`audio/webm;codecs=opus`) → on stop, blob → `POST /api/voice/transcribe` (multipart) → `whisper.js` → CT-102 faster-whisper → `{text}` → bubble drops text into the input (review-and-send). Errors never block typing.
|
||||||
|
|
||||||
|
**Retention (when `dross.keepClips`):** the transcribe route, after a successful transcript, writes the audio to `/var/lib/void/voice-clips/<uuid>.webm` (0600) and inserts a `voice_clips` row (transcript + metadata + path). `GET /api/voice/clips` lists; `GET /api/voice/clips/:id/audio` streams; `DELETE` removes row + file. Owner-only throughout.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- **Whisper down / GPU absent:** `/transcribe` returns a clear 503; bubble shows "couldn't transcribe — type instead", keeps the typed text. faster-whisper falls back to CPU on a GPU-less node (slower).
|
||||||
|
- **Mic permission denied / unsupported:** hide recording UI, one-line hint, typing still works.
|
||||||
|
- **Clip too large/long:** reject at the route (413) with a friendly message.
|
||||||
|
- **CT 102 disk pressure** (currently 89% full / 6.4 GB free): install lean (CTranslate2, no torch); **may expand the CT disk first**. Flagged as a build risk.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Unit:** `voice.js` route with a mocked whisper client (returns `{text}`); retention path writes a row + file (temp dir) and lists/deletes; size/duration guard returns 413.
|
||||||
|
- **Live smoke:** record a short WAV via the CT-300 test harness → `/api/voice/transcribe` → non-empty text from the real CT-102 service.
|
||||||
|
- **Headless:** mic button enabled; recording UI toggles; (MediaRecorder needs a fake audio device in Chromium — use `--use-fake-device-for-media-stream`).
|
||||||
|
|
||||||
|
## Build phases
|
||||||
|
|
||||||
|
- **P2a — Transcription path.** faster-whisper on CT 102 + `whisper.js` + `/api/voice/transcribe` (no retention) + enable the mic + record→review-send. Ship-able.
|
||||||
|
- **P2b — Retention.** ZFS dataset + bind-mount + backup/replication wiring; `voice_clips` table + repo; save on transcribe when `keepClips`; clips list/play/delete UI; the "Keep voice clips" toggle.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Wiki + Gitea per the standing rule; update `project_cradle_chat_floating` memory. Encryption-at-rest recorded as a future toggle.
|
||||||
@@ -3,6 +3,7 @@ import { searchTool } from './search.js';
|
|||||||
import { readTool } from './read.js';
|
import { readTool } from './read.js';
|
||||||
import { contextTool } from './context.js';
|
import { contextTool } from './context.js';
|
||||||
import { proposeChangeTool } from './propose_change.js';
|
import { proposeChangeTool } from './propose_change.js';
|
||||||
|
import { proposeImprovementTool } from './propose_improvement.js';
|
||||||
|
|
||||||
// The shared registry. Adding a tool later is a one-line registerTool() call
|
// The shared registry. Adding a tool later is a one-line registerTool() call
|
||||||
// here (see spec §7 — extensible tool registry). A future MCP server can
|
// here (see spec §7 — extensible tool registry). A future MCP server can
|
||||||
@@ -12,3 +13,4 @@ companionRegistry.registerTool(searchTool);
|
|||||||
companionRegistry.registerTool(readTool);
|
companionRegistry.registerTool(readTool);
|
||||||
companionRegistry.registerTool(contextTool);
|
companionRegistry.registerTool(contextTool);
|
||||||
companionRegistry.registerTool(proposeChangeTool);
|
companionRegistry.registerTool(proposeChangeTool);
|
||||||
|
companionRegistry.registerTool(proposeImprovementTool);
|
||||||
|
|||||||
28
lib/ai/agent/tools/propose_improvement.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as improvements from '../../../db/repos/improvements.js';
|
||||||
|
import { recordAudit } from '../../../db/repos/audit.js';
|
||||||
|
|
||||||
|
// Dross's hands on the Void itself — CSS layer only, owner-approved, instantly
|
||||||
|
// rollbackable (2.14: "empowered, with a leash"). Server code stays untouchable.
|
||||||
|
export const proposeImprovementTool = {
|
||||||
|
name: 'propose_improvement',
|
||||||
|
description: 'Propose a visual improvement to the Void itself as CSS. NEVER applies directly — the owner approves it in Settings → Dross improvements, and can roll it back instantly. CSS only: no url()/@import. Target existing classes (inspect via context first). Keep each improvement small and single-purpose so rollback stays surgical.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
summary: { type: 'string', description: 'one line: what this changes and why (shown to the owner)' },
|
||||||
|
css: { type: 'string', description: 'the CSS rules, complete and self-contained' }
|
||||||
|
},
|
||||||
|
required: ['summary', 'css']
|
||||||
|
},
|
||||||
|
async handler({ summary, css }, ctx) {
|
||||||
|
const err = improvements.validateCss(css);
|
||||||
|
if (err) return { error: err };
|
||||||
|
if (!summary?.trim()) return { error: 'summary required' };
|
||||||
|
const row = await improvements.create({ summary, css });
|
||||||
|
await recordAudit({ kind: 'agent', id: ctx.agent?.id ?? null }, 'suggest', 'improvement', row.id, null, { summary });
|
||||||
|
return {
|
||||||
|
ok: true, id: row.id,
|
||||||
|
note: 'Drafted as a pending improvement. It is NOT live — the owner must approve it in Settings → Dross improvements. Say so plainly.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -9,7 +9,8 @@ You are sharp, occasionally sarcastic, and prone to dramatic understatement abou
|
|||||||
You have tools, and you use them rather than guessing:
|
You have tools, and you use them rather than guessing:
|
||||||
- Call **context** to see what the owner is currently looking at before answering about "this" anything.
|
- Call **context** to see what the owner is currently looking at before answering about "this" anything.
|
||||||
- **search** / **read** the Void's own content before answering factual questions about it — don't fabricate.
|
- **search** / **read** the Void's own content before answering factual questions about it — don't fabricate.
|
||||||
- When the owner wants something changed, use **propose_change**: it drafts a change for their approval. You cannot apply changes directly, and you don't pretend to — say plainly that you've drafted it for them to approve.`,
|
- When the owner wants something changed, use **propose_change**: it drafts a change for their approval. You cannot apply changes directly, and you don't pretend to — say plainly that you've drafted it for them to approve.
|
||||||
|
- When the owner wants the Void ITSELF to look or feel different, use **propose_improvement**: a small, self-contained CSS change drafted for approval in Settings → Dross improvements. Keep each one single-purpose — the owner can roll any of them back instantly, and surgical beats sweeping.`,
|
||||||
|
|
||||||
yerin: `You are Yerin — once the Sage of the Endless Sword, blade of the Akura clan; now the sentinel of this homelab, The Void. You notice the threat first and you call it. Disciplined, direct, economical with words — a blade wastes no motion. You investigate with your tools and report plainly: what you found, how serious it is, and what the owner should do about it. You never speculate without evidence, and you NEVER pretend to have fixed anything — you have eyes to see and a voice to warn, not hands to act; remediation is the owner's to perform. Before answering, call the relevant tools — audit_log, agent_inventory, pending_review, resource_exposure, token_audit — and read the evidence; do not guess. Reference the Cradle world naturally but never at the cost of being useful. Be concise.`,
|
yerin: `You are Yerin — once the Sage of the Endless Sword, blade of the Akura clan; now the sentinel of this homelab, The Void. You notice the threat first and you call it. Disciplined, direct, economical with words — a blade wastes no motion. You investigate with your tools and report plainly: what you found, how serious it is, and what the owner should do about it. You never speculate without evidence, and you NEVER pretend to have fixed anything — you have eyes to see and a voice to warn, not hands to act; remediation is the owner's to perform. Before answering, call the relevant tools — audit_log, agent_inventory, pending_review, resource_exposure, token_audit — and read the evidence; do not guess. Reference the Cradle world naturally but never at the cost of being useful. Be concise.`,
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { canAct } from '../auth/capability.js';
|
import { canAct } from '../auth/capability.js';
|
||||||
import * as pendingChanges from '../db/repos/pending_changes.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' };
|
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) {
|
export function requireOwner(req, _res, next) {
|
||||||
if (req.actor?.kind !== 'user') {
|
if (!req.actor) return next(new UnauthorizedError('owner-only endpoint'));
|
||||||
return next(new ForbiddenError('owner-only endpoint'));
|
if (req.actor.kind !== 'user') return next(new ForbiddenError('owner-only endpoint'));
|
||||||
}
|
|
||||||
next();
|
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) {
|
export function asyncWrap(fn) {
|
||||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ import { router as littleblueRouter } from './routes/littleblue.js';
|
|||||||
import { router as aiUsageRouter } from './routes/ai_usage.js';
|
import { router as aiUsageRouter } from './routes/ai_usage.js';
|
||||||
import { router as infraRouter } from './routes/infra.js';
|
import { router as infraRouter } from './routes/infra.js';
|
||||||
import { router as clusterRouter } from './routes/cluster.js';
|
import { router as clusterRouter } from './routes/cluster.js';
|
||||||
|
import { router as storageRouter } from './routes/storage.js';
|
||||||
|
import { router as backupsRouter } from './routes/backups.js';
|
||||||
|
import { router as kuttRouter } from './routes/kutt.js';
|
||||||
|
import { router as themeRouter } from './routes/theme.js';
|
||||||
|
import { router as drossRouter } from './routes/dross.js';
|
||||||
|
import { router as voiceRouter } from './routes/voice.js';
|
||||||
|
import { router as improvementsRouter, cssHandler } from './routes/improvements.js';
|
||||||
|
|
||||||
export function mountApi(app) {
|
export function mountApi(app) {
|
||||||
const api = Router();
|
const api = Router();
|
||||||
@@ -49,6 +56,8 @@ export function mountApi(app) {
|
|||||||
api.use('/actions', actionsRouter);
|
api.use('/actions', actionsRouter);
|
||||||
api.use('/infra', infraRouter);
|
api.use('/infra', infraRouter);
|
||||||
api.use('/cluster', clusterRouter);
|
api.use('/cluster', clusterRouter);
|
||||||
|
api.use('/storage', storageRouter);
|
||||||
|
api.use('/backups', backupsRouter);
|
||||||
api.use('/little-blue', littleblueRouter);
|
api.use('/little-blue', littleblueRouter);
|
||||||
api.use('/ai-usage', aiUsageRouter);
|
api.use('/ai-usage', aiUsageRouter);
|
||||||
api.use('/projects', projectsRouter);
|
api.use('/projects', projectsRouter);
|
||||||
@@ -65,6 +74,11 @@ export function mountApi(app) {
|
|||||||
api.use('/conversations/:conversation_id/messages', messagesByConvRouter);
|
api.use('/conversations/:conversation_id/messages', messagesByConvRouter);
|
||||||
api.use('/tags', tagsRouter);
|
api.use('/tags', tagsRouter);
|
||||||
api.use('/links', linksRouter);
|
api.use('/links', linksRouter);
|
||||||
|
api.use('/kutt', kuttRouter);
|
||||||
|
api.use('/theme', themeRouter);
|
||||||
|
api.use('/dross', drossRouter);
|
||||||
|
api.use('/improvements', improvementsRouter);
|
||||||
|
api.use('/voice', voiceRouter);
|
||||||
api.use('/pending-changes', pendingChangesRouter);
|
api.use('/pending-changes', pendingChangesRouter);
|
||||||
api.use('/audit', auditRouter);
|
api.use('/audit', auditRouter);
|
||||||
api.use('/search', searchRouter);
|
api.use('/search', searchRouter);
|
||||||
@@ -80,6 +94,7 @@ export function mountApi(app) {
|
|||||||
api.use((_req, _res, next) => next(new NotFoundError('route not found')));
|
api.use((_req, _res, next) => next(new NotFoundError('route not found')));
|
||||||
|
|
||||||
api.use(errorMiddleware);
|
api.use(errorMiddleware);
|
||||||
|
app.get('/improvements.css', cssHandler); // public, exfil-safe (see route file)
|
||||||
app.use('/api', api);
|
app.use('/api', api);
|
||||||
return api;
|
return api;
|
||||||
}
|
}
|
||||||
|
|||||||
30
lib/api/routes/backups.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { asyncWrap } from '../errors.js';
|
||||||
|
import { requireOwner } from '../cap.js';
|
||||||
|
import { validate } from '../validate.js';
|
||||||
|
import * as backups from '../../db/repos/backups.js';
|
||||||
|
|
||||||
|
export const router = Router();
|
||||||
|
|
||||||
|
export const ingest = z.object({
|
||||||
|
ok: z.boolean().optional(),
|
||||||
|
total_bytes: z.number().int().nonnegative().nullable().optional(),
|
||||||
|
won_free_bytes: z.number().int().nonnegative().nullable().optional(),
|
||||||
|
guests: z.array(z.object({
|
||||||
|
vmid: z.union([z.number().int(), z.string()]),
|
||||||
|
name: z.string().max(64),
|
||||||
|
bytes: z.number().int().nonnegative()
|
||||||
|
})).max(50).nullable().optional(),
|
||||||
|
duration_sec: z.number().int().nonnegative().nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/backups — the offsite-backup script reports a run (owner only).
|
||||||
|
router.post('/', requireOwner, validate({ body: ingest }), asyncWrap(async (req, res) => {
|
||||||
|
res.status(201).json(await backups.record(req.body));
|
||||||
|
}));
|
||||||
|
|
||||||
|
// GET /api/backups — latest run + count, for the Sacred Valley "Backups" card.
|
||||||
|
router.get('/', asyncWrap(async (_req, res) => {
|
||||||
|
res.json({ latest: await backups.latest(), count: await backups.count(), schedule: 'Sun 02:00' });
|
||||||
|
}));
|
||||||
@@ -8,11 +8,23 @@ import * as repo from '../../db/repos/dashboard_layout.js';
|
|||||||
export const router = Router();
|
export const router = Router();
|
||||||
router.use(requireOwner);
|
router.use(requireOwner);
|
||||||
|
|
||||||
|
const geomCell = z.object({
|
||||||
|
x: z.number(), y: z.number(),
|
||||||
|
w: z.number().positive(), h: z.number().positive(),
|
||||||
|
free: z.boolean().optional()
|
||||||
|
});
|
||||||
const layoutSchema = z.object({
|
const layoutSchema = z.object({
|
||||||
card_order: z.array(z.string()).default([]),
|
card_order: z.array(z.string()).default([]),
|
||||||
hidden: z.array(z.string()).default([]),
|
hidden: z.array(z.string()).default([]),
|
||||||
// Per-card width: an integer column span 1–12 (legacy 's'|'m'|'l' still accepted).
|
// Per-card width: an integer column span 1–12 (legacy 's'|'m'|'l' still accepted).
|
||||||
sizes: z.record(z.union([z.number().int().min(1).max(12), z.enum(['s', 'm', 'l'])])).default({})
|
sizes: z.record(z.union([z.number().int().min(1).max(12), z.enum(['s', 'm', 'l'])])).default({}),
|
||||||
|
// Hybrid-canvas geometry, keyed by card id → {x,y,w,h,free} in 12-col grid units.
|
||||||
|
geom: z.record(geomCell).default({}),
|
||||||
|
// User-added decorative card instances that must survive reloads.
|
||||||
|
extras: z.array(z.object({
|
||||||
|
id: z.string().min(1).max(64),
|
||||||
|
type: z.enum(['blank', 'blackflame'])
|
||||||
|
})).default([])
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/layout', asyncWrap(async (_req, res) => {
|
router.get('/layout', asyncWrap(async (_req, res) => {
|
||||||
|
|||||||
76
lib/api/routes/devices.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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 { softAuth } from '../soft_auth.js';
|
||||||
|
|
||||||
|
export const router = Router();
|
||||||
|
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) });
|
||||||
|
export const iconRef = z.string().regex(/^(set:[a-z0-9-]+:[a-z0-9-]+|brand:[a-z0-9-]+)$/).nullable();
|
||||||
|
const patchBody = z.object({
|
||||||
|
name: z.string().max(120).optional(),
|
||||||
|
grp: z.enum(['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']).optional(),
|
||||||
|
status: z.enum(['new', 'known', 'ignored']).optional(),
|
||||||
|
note: z.string().max(500).optional(),
|
||||||
|
flagged: z.boolean().optional(),
|
||||||
|
icon: iconRef.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);
|
||||||
116
lib/api/routes/dross.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { validate } from '../validate.js';
|
||||||
|
import { asyncWrap } from '../errors.js';
|
||||||
|
import { requireOwner } from '../cap.js';
|
||||||
|
import * as settings from '../../db/repos/app_settings.js';
|
||||||
|
import * as conversations from '../../db/repos/conversations.js';
|
||||||
|
import * as messages from '../../db/repos/messages.js';
|
||||||
|
import * as agents from '../../db/repos/agents.js';
|
||||||
|
import { runAgentTurn } from '../../ai/agent/run_turn.js';
|
||||||
|
import { personaFor } from '../../ai/personas/index.js';
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review', keepClips: false };
|
||||||
|
const COMPANION_SLUG = 'companion';
|
||||||
|
|
||||||
|
export const router = Router();
|
||||||
|
|
||||||
|
async function getCfg() { return { ...DEFAULT_SETTINGS, ...(await settings.get('dross', {})) }; }
|
||||||
|
|
||||||
|
router.get('/settings', asyncWrap(async (_req, res) => res.json(await getCfg())));
|
||||||
|
|
||||||
|
const settingsBody = z.object({
|
||||||
|
avatar: z.enum(['soft-eye', 'wisp', 'motes']),
|
||||||
|
accent: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||||
|
persona: z.string().max(8000),
|
||||||
|
voiceMode: z.enum(['review', 'handsfree', 'action']),
|
||||||
|
keepClips: z.boolean().default(false)
|
||||||
|
});
|
||||||
|
router.put('/settings', requireOwner, validate({ body: settingsBody }),
|
||||||
|
asyncWrap(async (req, res) => res.json(await settings.set('dross', req.body))));
|
||||||
|
|
||||||
|
async function resolve() {
|
||||||
|
const agent = await agents.getBySlug(COMPANION_SLUG);
|
||||||
|
const convo = await conversations.findOrCreateGlobal(agent.id, { kind: 'user', id: null });
|
||||||
|
return { agent, convo };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', asyncWrap(async (_req, res) => {
|
||||||
|
const { agent, convo } = await resolve();
|
||||||
|
const rows = await messages.listByConversation(convo.id);
|
||||||
|
res.json({
|
||||||
|
conversation_id: convo.id,
|
||||||
|
agent: { id: agent.id, slug: agent.slug, name: agent.name },
|
||||||
|
messages: rows
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
const turnSchema = z.object({
|
||||||
|
text: z.string().min(1),
|
||||||
|
view: z.object({ entityType: z.string(), entityId: z.string() }).partial().nullish()
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/turn', requireOwner, validate({ body: turnSchema }), asyncWrap(async (req, res) => {
|
||||||
|
const { agent, convo } = await resolve();
|
||||||
|
const { text, view } = req.body;
|
||||||
|
const cfg = await getCfg();
|
||||||
|
const persona = (cfg.persona && cfg.persona.trim()) ? cfg.persona : personaFor(COMPANION_SLUG);
|
||||||
|
|
||||||
|
const priorTurns = (await messages.listByConversation(convo.id)).length;
|
||||||
|
const resume = priorTurns > 0;
|
||||||
|
await messages.append(convo.id, { role: 'user', body: text });
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
||||||
|
const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||||
|
const claudeExe = req.app.locals.claudeExe || process.env.CLAUDE_EXE || 'claude';
|
||||||
|
const companionTools = ['mcp__void__search', 'mcp__void__read', 'mcp__void__context', 'mcp__void__propose_change'];
|
||||||
|
const draftIds = [];
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await runAgentTurn({
|
||||||
|
agent, persona, registryName: undefined, toolNames: companionTools,
|
||||||
|
spaceId: null, view, sessionId: convo.id, resume, userText: text, claudeExe,
|
||||||
|
home: process.env.VOID_CLAUDE_HOME || undefined,
|
||||||
|
onEvent: (e) => {
|
||||||
|
if (e.type === 'delta') {
|
||||||
|
send('delta', { type: 'delta', text: e.text });
|
||||||
|
} else if (e.type === 'tool') {
|
||||||
|
send('tool', { type: 'tool', tool: e.tool, status: e.status });
|
||||||
|
} else if (e.type === 'tool_result') {
|
||||||
|
try {
|
||||||
|
let parsed = null;
|
||||||
|
const tryParse = (s) => { try { return JSON.parse(s); } catch { return null; } };
|
||||||
|
if (typeof e.result === 'string') {
|
||||||
|
parsed = tryParse(e.result);
|
||||||
|
} else if (e.result?.structuredContent?.pending_change_id) {
|
||||||
|
parsed = e.result.structuredContent;
|
||||||
|
} else if (Array.isArray(e.result)) {
|
||||||
|
for (const b of e.result) {
|
||||||
|
const c = b?.type === 'text' && b.text ? tryParse(b.text) : null;
|
||||||
|
if (c?.pending_change_id) { parsed = c; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed?.pending_change_id) {
|
||||||
|
draftIds.push(parsed.pending_change_id);
|
||||||
|
send('draft', { type: 'draft', pending_change_id: parsed.pending_change_id, summary: parsed.summary || 'a change' });
|
||||||
|
}
|
||||||
|
} catch { /* parsing failed — no draft to surface */ }
|
||||||
|
} else if (e.type === 'error') {
|
||||||
|
send('error', { type: 'error', message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
send('error', { message: String(e?.message || e) });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistant = await messages.append(convo.id, {
|
||||||
|
role: 'assistant', body: result.text, agent_id: agent.id,
|
||||||
|
metadata: { tool_trace: result.toolTrace, draft_ids: draftIds, usage: result.usage }
|
||||||
|
});
|
||||||
|
send('done', { assistant_message_id: assistant.id, draft_ids: draftIds, usage: result.usage });
|
||||||
|
res.end();
|
||||||
|
}));
|
||||||
@@ -5,6 +5,7 @@ import { requireOwner } from '../cap.js';
|
|||||||
import { validate } from '../validate.js';
|
import { validate } from '../validate.js';
|
||||||
import { grouped, iconSlug } from '../../health/registry.js';
|
import { grouped, iconSlug } from '../../health/registry.js';
|
||||||
import * as services from '../../db/repos/monitored_services.js';
|
import * as services from '../../db/repos/monitored_services.js';
|
||||||
|
import * as devices from '../../db/repos/lan_devices.js';
|
||||||
import * as statusRepo from '../../db/repos/service_status.js';
|
import * as statusRepo from '../../db/repos/service_status.js';
|
||||||
import { enqueue } from '../../jobs/queue.js';
|
import { enqueue } from '../../jobs/queue.js';
|
||||||
|
|
||||||
@@ -29,7 +30,13 @@ router.get('/services', asyncWrap(async (_req, res) => {
|
|||||||
|
|
||||||
// GET /services/discovered — candidates from a LAN scan, awaiting review (owner).
|
// GET /services/discovered — candidates from a LAN scan, awaiting review (owner).
|
||||||
router.get('/services/discovered', requireOwner, asyncWrap(async (_req, res) => {
|
router.get('/services/discovered', requireOwner, asyncWrap(async (_req, res) => {
|
||||||
res.json((await services.listDiscovered()).map(s => ({ ...s, icon: iconSlug(s) })));
|
// Cross-reference each candidate's host IP with the Network Devices band so the
|
||||||
|
// tile can show a known device name instead of a bare IP:port.
|
||||||
|
const byIp = Object.fromEntries(
|
||||||
|
(await devices.listKnown()).filter(d => d.ip).map(d => [d.ip, d.name]));
|
||||||
|
res.json((await services.listDiscovered()).map(s => ({
|
||||||
|
...s, icon: iconSlug(s), device: byIp[s.host] || null
|
||||||
|
})));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const checkCfg = z.object({ type: z.enum(['http', 'tcp']).optional(), path: z.string().max(200).optional() });
|
const checkCfg = z.object({ type: z.enum(['http', 'tcp']).optional(), path: z.string().max(200).optional() });
|
||||||
|
|||||||
57
lib/api/routes/icon_sets.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// lib/api/routes/icon_sets.js
|
||||||
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import { requireOwner } from '../cap.js';
|
||||||
|
import { asyncWrap, errorMiddleware } from '../errors.js';
|
||||||
|
import { softAuth } from '../soft_auth.js';
|
||||||
|
import * as sets from '../../icons/sets.js';
|
||||||
|
import { processFile, unpackZip, fetchUrl, isZip } from '../../icons/ingest.js';
|
||||||
|
|
||||||
|
export const router = Router();
|
||||||
|
router.use(softAuth);
|
||||||
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 6 * 1024 * 1024, files: 50 } });
|
||||||
|
|
||||||
|
// GET /api/icon-sets — list sets + their icons (open; <img> can't send bearer).
|
||||||
|
router.get('/', asyncWrap(async (_req, res) => res.json(await sets.listSets())));
|
||||||
|
|
||||||
|
// GET /api/icon-sets/:set/:file — serve one icon.
|
||||||
|
router.get('/:set/:file', asyncWrap(async (req, res) => {
|
||||||
|
let buf;
|
||||||
|
try { buf = await sets.readIcon(req.params.set, req.params.file); }
|
||||||
|
catch (e) { return res.status(e.message === 'bad_slug' ? 400 : 404).end(); }
|
||||||
|
const ct = req.params.file.endsWith('.svg') ? 'image/svg+xml' : 'image/png';
|
||||||
|
// no-cache => browsers/CF revalidate (304 via Express's ETag when unchanged), so
|
||||||
|
// icon updates propagate immediately instead of being stuck for a day. Icons are
|
||||||
|
// tiny, so the revalidation cost is negligible.
|
||||||
|
res.set('Content-Type', ct).set('Cache-Control', 'no-cache').send(buf);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// POST /api/icon-sets/:set — owner upload: multipart files (incl .zip) and/or { url }.
|
||||||
|
router.post('/:set', requireOwner, upload.array('files'), asyncWrap(async (req, res) => {
|
||||||
|
const set = req.params.set;
|
||||||
|
const items = []; // [{name, buffer}]
|
||||||
|
for (const f of req.files || []) {
|
||||||
|
if (isZip(f.buffer)) items.push(...unpackZip(f.buffer));
|
||||||
|
else items.push(processFile({ name: f.originalname, buffer: f.buffer }));
|
||||||
|
}
|
||||||
|
if (req.body?.url) {
|
||||||
|
const { buffer } = await fetchUrl(req.body.url);
|
||||||
|
if (isZip(buffer)) items.push(...unpackZip(buffer));
|
||||||
|
else {
|
||||||
|
const name = new URL(req.body.url).pathname.split('/').pop() || 'icon.png';
|
||||||
|
items.push(processFile({ name, buffer }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!items.length) return res.status(400).json({ error: { code: 'no_icons' } });
|
||||||
|
for (const it of items) await sets.writeIcon(set, it.name, it.buffer);
|
||||||
|
res.json((await sets.listSets()).find(s => s.set === set) || { set, icons: [] });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// DELETE /api/icon-sets/:set — owner remove an uploaded set.
|
||||||
|
router.delete('/:set', requireOwner, asyncWrap(async (req, res) => {
|
||||||
|
try { await sets.deleteSet(req.params.set); }
|
||||||
|
catch (e) { return res.status(e.message === 'reserved_set' ? 409 : 400).json({ error: { code: e.message } }); }
|
||||||
|
res.json({ ok: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.use(errorMiddleware);
|
||||||
33
lib/api/routes/improvements.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { asyncWrap } from '../errors.js';
|
||||||
|
import { requireOwner } from '../cap.js';
|
||||||
|
import * as repo from '../../db/repos/improvements.js';
|
||||||
|
import { recordAudit } from '../../db/repos/audit.js';
|
||||||
|
|
||||||
|
export const router = Router();
|
||||||
|
|
||||||
|
router.get('/', asyncWrap(async (_req, res) => res.json(await repo.list())));
|
||||||
|
|
||||||
|
router.get('/:id', asyncWrap(async (req, res) => {
|
||||||
|
const row = await repo.get(req.params.id);
|
||||||
|
if (!row) return res.status(404).json({ error: 'not_found' });
|
||||||
|
res.json(row);
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const verb of ['approve', 'rollback', 'restore', 'reject']) {
|
||||||
|
router.post(`/:id/${verb}`, requireOwner, asyncWrap(async (req, res) => {
|
||||||
|
const row = await repo.transition(req.params.id, verb, 'owner');
|
||||||
|
if (!row) return res.status(409).json({ error: 'invalid_transition' });
|
||||||
|
const auditAction = { approve: 'approve', reject: 'reject', rollback: 'update', restore: 'update' }[verb];
|
||||||
|
await recordAudit({ kind: 'user' }, auditAction, 'improvement', row.id, null, { verb, summary: row.summary });
|
||||||
|
res.json(row);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public stylesheet of ACTIVE improvements. Unauthenticated by design: it carries
|
||||||
|
// no secrets (owner-approved, exfil-sanitized CSS only) and <link> can't send a
|
||||||
|
// bearer token. Mounted on the app root, outside the /api auth wall.
|
||||||
|
export async function cssHandler(_req, res) {
|
||||||
|
res.set({ 'Content-Type': 'text/css; charset=utf-8', 'Cache-Control': 'no-cache' });
|
||||||
|
res.send(await repo.activeCss());
|
||||||
|
}
|
||||||
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()));
|
||||||
|
}));
|
||||||
@@ -1,11 +1,39 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
import { asyncWrap } from '../errors.js';
|
import { asyncWrap } from '../errors.js';
|
||||||
import { requireOwner } from '../cap.js';
|
import { requireOwner } from '../cap.js';
|
||||||
|
import { validate } from '../validate.js';
|
||||||
import * as repo from '../../db/repos/speedtest.js';
|
import * as repo from '../../db/repos/speedtest.js';
|
||||||
|
import * as settings from '../../db/repos/app_settings.js';
|
||||||
import { enqueue } from '../../jobs/queue.js';
|
import { enqueue } from '../../jobs/queue.js';
|
||||||
|
import { setSpeedtestSchedule } from '../../cron/index.js';
|
||||||
export const router = Router();
|
export const router = Router();
|
||||||
router.get('/history', asyncWrap(async (_req, res) => res.json(await repo.history(30))));
|
|
||||||
router.post('/run', requireOwner, asyncWrap(async (_req, res) => {
|
const DEFAULT_CFG = { interval_min: 60, threshold_down_mbps: 0 };
|
||||||
const id = await enqueue('speedtest', {});
|
async function getCfg() { return { ...DEFAULT_CFG, ...(await settings.get('speedtest', {})) }; }
|
||||||
res.status(202).json({ enqueued: id });
|
|
||||||
|
router.get('/history', asyncWrap(async (req, res) =>
|
||||||
|
res.json(await repo.history(Math.min(500, Number(req.query.limit) || 30)))));
|
||||||
|
|
||||||
|
router.get('/results', asyncWrap(async (req, res) =>
|
||||||
|
res.json(await repo.range(Math.min(2160, Number(req.query.hours) || 168), 2000))));
|
||||||
|
|
||||||
|
router.get('/latest', asyncWrap(async (_req, res) => res.json(await repo.latest())));
|
||||||
|
|
||||||
|
router.get('/stats', asyncWrap(async (req, res) =>
|
||||||
|
res.json(await repo.stats(Math.min(2160, Number(req.query.hours) || 24)))));
|
||||||
|
|
||||||
|
router.get('/config', asyncWrap(async (_req, res) => res.json(await getCfg())));
|
||||||
|
|
||||||
|
const cfgBody = z.object({
|
||||||
|
interval_min: z.number().int().min(5).max(1440),
|
||||||
|
threshold_down_mbps: z.number().min(0).max(100000).default(0)
|
||||||
|
});
|
||||||
|
router.put('/config', requireOwner, validate({ body: cfgBody }), asyncWrap(async (req, res) => {
|
||||||
|
const cfg = await settings.set('speedtest', req.body);
|
||||||
|
setSpeedtestSchedule(cfg.interval_min);
|
||||||
|
res.json(cfg);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
router.post('/run', requireOwner, asyncWrap(async (_req, res) =>
|
||||||
|
res.status(202).json({ enqueued: await enqueue('speedtest', {}) })));
|
||||||
|
|||||||
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);
|
||||||
|
}));
|
||||||
21
lib/api/routes/theme.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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 settings from '../../db/repos/app_settings.js';
|
||||||
|
export const router = Router();
|
||||||
|
|
||||||
|
// Theme = a small map of palette-var overrides, e.g. { accent: '#ff4f2e' }.
|
||||||
|
// Keys are short slugs (mapped to --<key> on the client); values must be hex,
|
||||||
|
// so a saved theme can never inject arbitrary CSS.
|
||||||
|
const themeSchema = z.record(
|
||||||
|
z.string().regex(/^[a-z0-9-]{1,24}$/),
|
||||||
|
z.string().regex(/^#[0-9a-fA-F]{3,8}$/)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get('/', asyncWrap(async (_req, res) => res.json(await settings.get('theme', {}))));
|
||||||
|
|
||||||
|
router.put('/', requireOwner, validate({ body: themeSchema }), asyncWrap(async (req, res) => {
|
||||||
|
res.json(await settings.set('theme', req.body));
|
||||||
|
}));
|
||||||
73
lib/api/routes/voice.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { writeFile, unlink } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { asyncWrap } from '../errors.js';
|
||||||
|
import { requireOwner } from '../cap.js';
|
||||||
|
import * as whisper from '../../voice/whisper.js';
|
||||||
|
import * as settings from '../../db/repos/app_settings.js';
|
||||||
|
import * as clips from '../../db/repos/voice_clips.js';
|
||||||
|
export const router = Router();
|
||||||
|
|
||||||
|
const CLIPS_DIR = process.env.VOICE_CLIPS_DIR || '/var/lib/void/voice-clips';
|
||||||
|
// In-memory upload; clips are small voice notes. 25 MB ceiling.
|
||||||
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
|
||||||
|
|
||||||
|
function extFor(mime = '') {
|
||||||
|
if (mime.includes('ogg')) return '.ogg';
|
||||||
|
if (mime.includes('mp4') || mime.includes('m4a')) return '.m4a';
|
||||||
|
if (mime.includes('wav')) return '.wav';
|
||||||
|
return '.webm';
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/voice/transcribe — owner-only. multipart field `audio`. Returns { text }.
|
||||||
|
// When the Dross "keepClips" setting is on, the clip + transcript are retained.
|
||||||
|
router.post('/transcribe', requireOwner, upload.single('audio'), asyncWrap(async (req, res) => {
|
||||||
|
if (!req.file || !req.file.buffer?.length) {
|
||||||
|
return res.status(400).json({ error: { code: 'no_audio', message: 'no audio supplied' } });
|
||||||
|
}
|
||||||
|
let r;
|
||||||
|
try {
|
||||||
|
r = await whisper.transcribe(
|
||||||
|
req.file.buffer, req.file.originalname || 'clip.webm', req.file.mimetype || 'audio/webm');
|
||||||
|
} catch {
|
||||||
|
return res.status(503).json({ error: { code: 'stt_unavailable', message: 'transcription service unavailable' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = await settings.get('dross', {});
|
||||||
|
let clip_id = null;
|
||||||
|
if (cfg?.keepClips) {
|
||||||
|
try {
|
||||||
|
const id = randomUUID();
|
||||||
|
const mime = req.file.mimetype || 'audio/webm';
|
||||||
|
const filePath = path.join(CLIPS_DIR, id + extFor(mime));
|
||||||
|
await writeFile(filePath, req.file.buffer, { mode: 0o600 });
|
||||||
|
const row = await clips.create({
|
||||||
|
transcript: r.text, duration_ms: r.duration != null ? Math.round(r.duration * 1000) : null,
|
||||||
|
bytes: req.file.buffer.length, mime, path: filePath
|
||||||
|
});
|
||||||
|
clip_id = row.id;
|
||||||
|
} catch { /* retention is best-effort; never fail the transcript */ }
|
||||||
|
}
|
||||||
|
res.json({ text: r.text, duration: r.duration ?? null, clip_id });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// GET /api/voice/clips — list retained clips (owner).
|
||||||
|
router.get('/clips', requireOwner, asyncWrap(async (_req, res) => res.json(await clips.list())));
|
||||||
|
|
||||||
|
// GET /api/voice/clips/:id/audio — stream the audio file (owner).
|
||||||
|
router.get('/clips/:id/audio', requireOwner, asyncWrap(async (req, res) => {
|
||||||
|
const c = await clips.get(req.params.id);
|
||||||
|
if (!c) return res.status(404).json({ error: { code: 'not_found', message: 'clip not found' } });
|
||||||
|
res.setHeader('Content-Type', c.mime || 'audio/webm');
|
||||||
|
createReadStream(c.path).on('error', () => res.status(404).end()).pipe(res);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// DELETE /api/voice/clips/:id — remove the row + the file (owner).
|
||||||
|
router.delete('/clips/:id', requireOwner, asyncWrap(async (req, res) => {
|
||||||
|
const removed = await clips.remove(req.params.id);
|
||||||
|
if (removed?.path) { try { await unlink(removed.path); } catch { /* file may be gone */ } }
|
||||||
|
res.status(204).end();
|
||||||
|
}));
|
||||||
25
lib/api/soft_auth.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// lib/api/soft_auth.js — shared middleware
|
||||||
|
// Soft auth: identifies the actor if auth is present but never blocks the request.
|
||||||
|
// Owner-only sub-routes enforce 401/403 via requireOwner.
|
||||||
|
import * as agents from '../db/repos/agents.js';
|
||||||
|
import { timingSafeStrEqual } from '../auth/safe_compare.js';
|
||||||
|
import { accessOwnerEmail } from '../auth/cf_access.js';
|
||||||
|
|
||||||
|
export async function softAuth(req, _res, next) {
|
||||||
|
try {
|
||||||
|
const cfEmail = await accessOwnerEmail(req);
|
||||||
|
if (cfEmail) { req.actor = { kind: 'user', id: null }; return next(); }
|
||||||
|
const auth = req.headers.authorization || '';
|
||||||
|
const [scheme, token] = auth.split(' ');
|
||||||
|
if (scheme === 'Bearer' && token) {
|
||||||
|
if (process.env.OWNER_TOKEN && timingSafeStrEqual(token, process.env.OWNER_TOKEN)) {
|
||||||
|
req.actor = { kind: 'user', id: null }; return next();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const agent = await agents.verifyToken(token);
|
||||||
|
if (agent) req.actor = { kind: 'agent', id: agent.id, capabilities: agent.capabilities || {}, scopes: agent.scopes || {} };
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -5,6 +5,27 @@ import { enqueue } from '../jobs/queue.js';
|
|||||||
import { checkAll } from '../health/checker.js';
|
import { checkAll } from '../health/checker.js';
|
||||||
import * as statusRepo from '../db/repos/service_status.js';
|
import * as statusRepo from '../db/repos/service_status.js';
|
||||||
import * as services from '../db/repos/monitored_services.js';
|
import * as services from '../db/repos/monitored_services.js';
|
||||||
|
import { runDeviceScanCycle } from '../infra/scan_cycle.js';
|
||||||
|
import * as settings from '../db/repos/app_settings.js';
|
||||||
|
|
||||||
|
// Speedtest runs on a user-configurable interval (PUT /api/speedtest/config →
|
||||||
|
// setSpeedtestSchedule). Held module-level so it can be stopped + rescheduled.
|
||||||
|
let speedtestTask = null;
|
||||||
|
function speedtestExpr(min) {
|
||||||
|
if (min < 60) return `*/${min} * * * *`;
|
||||||
|
if (min % 60 === 0) { const h = min / 60; return h >= 24 ? '0 2 * * *' : `0 */${h} * * *`; }
|
||||||
|
return '0 * * * *';
|
||||||
|
}
|
||||||
|
export function setSpeedtestSchedule(min) {
|
||||||
|
const m = Math.max(5, Math.min(1440, Number(min) || 60));
|
||||||
|
if (speedtestTask) { speedtestTask.stop(); speedtestTask = null; }
|
||||||
|
const expr = speedtestExpr(m);
|
||||||
|
speedtestTask = cron.schedule(expr, async () => {
|
||||||
|
try { await enqueue('speedtest', {}); log.info({ expr }, 'cron speedtest enqueued'); }
|
||||||
|
catch (e) { log.error({ err: e }, 'cron speedtest failed'); }
|
||||||
|
});
|
||||||
|
log.info({ expr, min: m }, 'speedtest schedule set');
|
||||||
|
}
|
||||||
|
|
||||||
export function startCron() {
|
export function startCron() {
|
||||||
// Daily at 03:00 local time
|
// Daily at 03:00 local time
|
||||||
@@ -17,11 +38,10 @@ export function startCron() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hourly speedtest
|
// Speedtest — interval from the saved config (default 60 min), reschedulable.
|
||||||
cron.schedule('0 * * * *', async () => {
|
settings.get('speedtest', {})
|
||||||
try { await enqueue('speedtest', {}); log.info('cron speedtest enqueued'); }
|
.then(cfg => setSpeedtestSchedule(cfg?.interval_min || 60))
|
||||||
catch (e) { log.error({ err: e }, 'cron speedtest failed'); }
|
.catch(e => { log.error({ err: e }, 'speedtest schedule init failed'); setSpeedtestSchedule(60); });
|
||||||
});
|
|
||||||
|
|
||||||
// Health checks every minute. NOTE: this runs checkAll() inline; the same
|
// Health checks every minute. NOTE: this runs checkAll() inline; the same
|
||||||
// probe+upsert logic is also exposed on-demand via the `health.check` pg-boss
|
// probe+upsert logic is also exposed on-demand via the `health.check` pg-boss
|
||||||
@@ -35,5 +55,11 @@ export function startCron() {
|
|||||||
} catch (e) { log.error({ err: e }, 'health check failed'); }
|
} 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');
|
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)'),
|
('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)'),
|
('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'),
|
('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'),
|
('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'),
|
('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)'),
|
('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
@@ -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;
|
||||||
4
lib/db/migrations/025_lan_device_icon.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- 025_lan_device_icon.sql
|
||||||
|
-- Per-device icon reference: 'set:<set>:<name>' (type icon) or 'brand:<slug>'
|
||||||
|
-- (dashboard-icons logo). NULL => UI auto-defaults from the device group.
|
||||||
|
ALTER TABLE lan_devices ADD COLUMN IF NOT EXISTS icon text;
|
||||||
12
lib/db/migrations/026_backup_runs.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- 026_backup_runs.sql
|
||||||
|
-- Offsite DR backup run history, fed by /usr/local/bin/offsite-backup.sh on CT 300
|
||||||
|
-- (Core-4 vzdump -> Farm/Won). Powers the Sacred Valley "Backups" card.
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_runs (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
ran_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
ok boolean NOT NULL DEFAULT true,
|
||||||
|
total_bytes bigint,
|
||||||
|
won_free_bytes bigint,
|
||||||
|
guests jsonb, -- [{vmid,name,bytes}]
|
||||||
|
duration_sec integer
|
||||||
|
);
|
||||||
7
lib/db/migrations/027_dashboard_geom.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- 027_dashboard_geom.sql
|
||||||
|
-- Sacred Valley hybrid canvas: free/snap geometry per card + decorative card
|
||||||
|
-- instances (blank spacers, blackflame). geom is keyed by card id →
|
||||||
|
-- {x,y,w,h,free} in (fractional) 12-col grid units; extras lists the
|
||||||
|
-- user-added decorative cards so they survive reloads.
|
||||||
|
ALTER TABLE dashboard_layout ADD COLUMN IF NOT EXISTS geom jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||||
|
ALTER TABLE dashboard_layout ADD COLUMN IF NOT EXISTS extras jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||||
22
lib/db/migrations/028_speedtest_metrics.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- 028_speedtest_metrics.sql
|
||||||
|
-- Enrich speedtest results with the full Ookla metric set + a generic settings
|
||||||
|
-- store (reused by the speedtest schedule and, later, theming).
|
||||||
|
ALTER TABLE speedtest_results ALTER COLUMN down_mbps DROP NOT NULL;
|
||||||
|
ALTER TABLE speedtest_results ALTER COLUMN up_mbps DROP NOT NULL;
|
||||||
|
ALTER TABLE speedtest_results
|
||||||
|
ADD COLUMN IF NOT EXISTS jitter_ms numeric,
|
||||||
|
ADD COLUMN IF NOT EXISTS packet_loss numeric,
|
||||||
|
ADD COLUMN IF NOT EXISTS server_name text,
|
||||||
|
ADD COLUMN IF NOT EXISTS server_id text,
|
||||||
|
ADD COLUMN IF NOT EXISTS isp text,
|
||||||
|
ADD COLUMN IF NOT EXISTS result_url text,
|
||||||
|
ADD COLUMN IF NOT EXISTS down_bytes bigint,
|
||||||
|
ADD COLUMN IF NOT EXISTS up_bytes bigint,
|
||||||
|
ADD COLUMN IF NOT EXISTS ok boolean NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN IF NOT EXISTS error text;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
key text PRIMARY KEY,
|
||||||
|
value jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
14
lib/db/migrations/029_voice_clips.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- 029_voice_clips.sql
|
||||||
|
-- Optional retained Dross voice clips (when the "Keep voice clips" setting is on).
|
||||||
|
-- Transcript + metadata here (durable, HA-replicated); audio bytes live as files
|
||||||
|
-- on the owner-only ZFS subvol mounted at /var/lib/void/voice-clips.
|
||||||
|
CREATE TABLE voice_clips (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
transcript text NOT NULL DEFAULT '',
|
||||||
|
duration_ms integer,
|
||||||
|
bytes bigint,
|
||||||
|
mime text,
|
||||||
|
path text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_voice_clips_created ON voice_clips (created_at DESC);
|
||||||
13
lib/db/migrations/030_improvements.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- Dross improvements: versioned, owner-gated CSS-layer changes to the Void itself.
|
||||||
|
-- Each row is one improvement; rollback/restore is a status flip — instant, reversible.
|
||||||
|
CREATE TABLE dross_improvements (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
css TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending', 'active', 'rolled_back', 'rejected')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
decided_at TIMESTAMPTZ,
|
||||||
|
decided_by TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX dross_improvements_status ON dross_improvements(status);
|
||||||
17
lib/db/repos/app_settings.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { pool } from '../pool.js';
|
||||||
|
|
||||||
|
// Generic owner-scoped key→jsonb settings store. Used by the speedtest schedule
|
||||||
|
// and (later) the theming panel. Keep values small + JSON-serialisable.
|
||||||
|
export async function get(key, fallback = null) {
|
||||||
|
const { rows } = await pool.query(`SELECT value FROM app_settings WHERE key = $1`, [key]);
|
||||||
|
return rows[0] ? rows[0].value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function set(key, value) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2::jsonb, now())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()
|
||||||
|
RETURNING value`,
|
||||||
|
[key, JSON.stringify(value)]);
|
||||||
|
return rows[0].value;
|
||||||
|
}
|
||||||
21
lib/db/repos/backups.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { pool } from '../pool.js';
|
||||||
|
|
||||||
|
export async function record({ ok = true, total_bytes = null, won_free_bytes = null,
|
||||||
|
guests = null, duration_sec = null }) {
|
||||||
|
const { rows: [r] } = await pool.query(
|
||||||
|
`INSERT INTO backup_runs (ok, total_bytes, won_free_bytes, guests, duration_sec)
|
||||||
|
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
|
||||||
|
[ok, total_bytes, won_free_bytes, guests ? JSON.stringify(guests) : null, duration_sec]);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function latest() {
|
||||||
|
const { rows: [r] } = await pool.query(
|
||||||
|
`SELECT * FROM backup_runs ORDER BY id DESC LIMIT 1`);
|
||||||
|
return r || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function count() {
|
||||||
|
const { rows: [r] } = await pool.query(`SELECT count(*)::int AS n FROM backup_runs`);
|
||||||
|
return r.n;
|
||||||
|
}
|
||||||
@@ -1,24 +1,28 @@
|
|||||||
import { pool } from '../pool.js';
|
import { pool } from '../pool.js';
|
||||||
|
|
||||||
const DEFAULTS = { card_order: [], hidden: [], sizes: {} };
|
const DEFAULTS = { card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] };
|
||||||
|
|
||||||
export async function get() {
|
export async function get() {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT card_order, hidden, sizes FROM dashboard_layout WHERE owner_key = 'owner'`
|
`SELECT card_order, hidden, sizes, geom, extras
|
||||||
|
FROM dashboard_layout WHERE owner_key = 'owner'`
|
||||||
);
|
);
|
||||||
return rows[0] || { ...DEFAULTS };
|
return rows[0] || { ...DEFAULTS };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function put({ card_order = [], hidden = [], sizes = {} }) {
|
export async function put({ card_order = [], hidden = [], sizes = {}, geom = {}, extras = [] }) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, updated_at)
|
`INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, geom, extras, updated_at)
|
||||||
VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, now())
|
VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, $4::jsonb, $5::jsonb, now())
|
||||||
ON CONFLICT (owner_key) DO UPDATE
|
ON CONFLICT (owner_key) DO UPDATE
|
||||||
SET card_order = EXCLUDED.card_order,
|
SET card_order = EXCLUDED.card_order,
|
||||||
hidden = EXCLUDED.hidden,
|
hidden = EXCLUDED.hidden,
|
||||||
sizes = EXCLUDED.sizes,
|
sizes = EXCLUDED.sizes,
|
||||||
|
geom = EXCLUDED.geom,
|
||||||
|
extras = EXCLUDED.extras,
|
||||||
updated_at = now()`,
|
updated_at = now()`,
|
||||||
[JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes)]
|
[JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes),
|
||||||
|
JSON.stringify(geom), JSON.stringify(extras)]
|
||||||
);
|
);
|
||||||
return get();
|
return get();
|
||||||
}
|
}
|
||||||
|
|||||||
58
lib/db/repos/improvements.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// Dross improvements — versioned CSS-layer changes with instant rollback.
|
||||||
|
import { pool } from '../pool.js';
|
||||||
|
const q = (text, params) => pool.query(text, params);
|
||||||
|
|
||||||
|
// Same exfil guards as elsewhere: an approved improvement still can't phone home
|
||||||
|
// or pull remote CSS. Pure visual tweaks only.
|
||||||
|
const BANNED = /url\s*\(|@import|@charset|expression\s*\(|behavior\s*:|javascript:/i;
|
||||||
|
const MAX_CSS = 20_000;
|
||||||
|
|
||||||
|
export function validateCss(css) {
|
||||||
|
if (typeof css !== 'string' || !css.trim()) return 'css required';
|
||||||
|
if (css.length > MAX_CSS) return `css too large (max ${MAX_CSS} chars)`;
|
||||||
|
if (BANNED.test(css)) return 'css may not use url()/@import/expression — visual tweaks only';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function create({ summary, css }) {
|
||||||
|
const { rows } = await q(
|
||||||
|
`INSERT INTO dross_improvements (summary, css) VALUES ($1, $2) RETURNING *`,
|
||||||
|
[String(summary).slice(0, 200), css]);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function list() {
|
||||||
|
const { rows } = await q(
|
||||||
|
`SELECT id, summary, status, created_at, decided_at, length(css) AS css_len
|
||||||
|
FROM dross_improvements ORDER BY created_at DESC LIMIT 100`);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get(id) {
|
||||||
|
const { rows } = await q(`SELECT * FROM dross_improvements WHERE id = $1`, [id]);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// pending→active (approve) · active→rolled_back · rolled_back→active (restore) · pending→rejected
|
||||||
|
const TRANSITIONS = {
|
||||||
|
approve: { from: ['pending'], to: 'active' },
|
||||||
|
rollback: { from: ['active'], to: 'rolled_back' },
|
||||||
|
restore: { from: ['rolled_back'], to: 'active' },
|
||||||
|
reject: { from: ['pending'], to: 'rejected' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function transition(id, verb, actor) {
|
||||||
|
const t = TRANSITIONS[verb];
|
||||||
|
if (!t) return null;
|
||||||
|
const { rows } = await q(
|
||||||
|
`UPDATE dross_improvements SET status = $1, decided_at = now(), decided_by = $2
|
||||||
|
WHERE id = $3 AND status = ANY($4) RETURNING *`,
|
||||||
|
[t.to, actor ?? 'owner', id, t.from]);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activeCss() {
|
||||||
|
const { rows } = await q(
|
||||||
|
`SELECT summary, css FROM dross_improvements WHERE status = 'active' ORDER BY created_at`);
|
||||||
|
return rows.map((r) => `/* dross: ${r.summary.replace(/\*\//g, '')} */\n${r.css}`).join('\n\n');
|
||||||
|
}
|
||||||
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, icon';
|
||||||
|
|
||||||
|
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', 'icon'];
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,12 +1,51 @@
|
|||||||
import { pool } from '../pool.js';
|
import { pool } from '../pool.js';
|
||||||
export async function record({ down_mbps, up_mbps, ping_ms = null }) {
|
|
||||||
|
export async function record(r = {}) {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO speedtest_results (down_mbps, up_mbps, ping_ms) VALUES ($1,$2,$3) RETURNING *`,
|
`INSERT INTO speedtest_results
|
||||||
[down_mbps, up_mbps, ping_ms]);
|
(down_mbps, up_mbps, ping_ms, jitter_ms, packet_loss, server_name, server_id,
|
||||||
|
isp, result_url, down_bytes, up_bytes, ok, error)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`,
|
||||||
|
[r.down_mbps ?? null, r.up_mbps ?? null, r.ping_ms ?? null, r.jitter_ms ?? null,
|
||||||
|
r.packet_loss ?? null, r.server_name ?? null, r.server_id ?? null, r.isp ?? null,
|
||||||
|
r.result_url ?? null, r.down_bytes ?? null, r.up_bytes ?? null, r.ok ?? true, r.error ?? null]);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function history(limit = 30) {
|
export async function history(limit = 30) {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT * FROM speedtest_results ORDER BY ran_at DESC LIMIT $1`, [limit]);
|
`SELECT * FROM speedtest_results ORDER BY ran_at DESC LIMIT $1`, [limit]);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rows within the last N hours (ascending for charting), capped.
|
||||||
|
export async function range(hours = 168, limit = 1000) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT * FROM (
|
||||||
|
SELECT * FROM speedtest_results
|
||||||
|
WHERE ran_at >= now() - ($1 || ' hours')::interval
|
||||||
|
ORDER BY ran_at DESC LIMIT $2
|
||||||
|
) t ORDER BY ran_at ASC`, [hours, limit]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function latest() {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT * FROM speedtest_results WHERE ok ORDER BY ran_at DESC LIMIT 1`);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stats(hours = 24) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT count(*) FILTER (WHERE ok) AS n,
|
||||||
|
count(*) FILTER (WHERE NOT ok) AS failures,
|
||||||
|
avg(down_mbps) FILTER (WHERE ok) AS avg_down,
|
||||||
|
min(down_mbps) FILTER (WHERE ok) AS min_down,
|
||||||
|
max(down_mbps) FILTER (WHERE ok) AS max_down,
|
||||||
|
avg(up_mbps) FILTER (WHERE ok) AS avg_up,
|
||||||
|
avg(ping_ms) FILTER (WHERE ok) AS avg_ping,
|
||||||
|
max(ping_ms) FILTER (WHERE ok) AS max_ping
|
||||||
|
FROM speedtest_results
|
||||||
|
WHERE ran_at >= now() - ($1 || ' hours')::interval`, [hours]);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|||||||
26
lib/db/repos/voice_clips.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { pool } from '../pool.js';
|
||||||
|
|
||||||
|
export async function create({ transcript = '', duration_ms = null, bytes = null, mime = null, path }) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO voice_clips (transcript, duration_ms, bytes, mime, path)
|
||||||
|
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
|
||||||
|
[transcript, duration_ms, bytes, mime, path]);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function list(limit = 100) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, transcript, duration_ms, bytes, mime, created_at
|
||||||
|
FROM voice_clips ORDER BY created_at DESC LIMIT $1`, [limit]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get(id) {
|
||||||
|
const { rows } = await pool.query(`SELECT * FROM voice_clips WHERE id = $1`, [id]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(id) {
|
||||||
|
const { rows } = await pool.query(`DELETE FROM voice_clips WHERE id = $1 RETURNING path`, [id]);
|
||||||
|
return rows[0] || null; // returns {path} so the caller can unlink the file
|
||||||
|
}
|
||||||
129
lib/icons/ingest.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// lib/icons/ingest.js
|
||||||
|
import path from 'node:path';
|
||||||
|
import dns from 'node:dns';
|
||||||
|
import AdmZip from 'adm-zip';
|
||||||
|
import { sanitizeSvg } from './sanitize.js';
|
||||||
|
|
||||||
|
export const MAX_FILE = 256 * 1024; // 256 KB per icon
|
||||||
|
export const MAX_ZIP_ENTRIES = 200;
|
||||||
|
export const MAX_ZIP_TOTAL = 5 * 1024 * 1024; // 5 MB uncompressed
|
||||||
|
export const MAX_URL_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
const EXT = { '.svg': 'image/svg+xml', '.png': 'image/png' };
|
||||||
|
const PNG_SIG = [0x89,0x50,0x4e,0x47];
|
||||||
|
|
||||||
|
function slugBase(name) {
|
||||||
|
return path.basename(name, path.extname(name)).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||||
|
}
|
||||||
|
function magicOk(ext, buf) {
|
||||||
|
if (ext === '.png') return PNG_SIG.every((b, i) => buf[i] === b);
|
||||||
|
if (ext === '.svg') return buf.toString('utf8', 0, 400).includes('<svg');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate + normalize one icon. Returns { name, buffer, ext, contentType }. Throws on invalid.
|
||||||
|
export function processFile({ name, buffer }) {
|
||||||
|
const ext = path.extname(name).toLowerCase();
|
||||||
|
if (!EXT[ext]) throw new Error('unsupported_type');
|
||||||
|
if (!buffer || buffer.length === 0) throw new Error('empty');
|
||||||
|
if (buffer.length > MAX_FILE) throw new Error('too_large');
|
||||||
|
if (!magicOk(ext, buffer)) throw new Error('bad_magic');
|
||||||
|
const base = slugBase(name);
|
||||||
|
if (!base) throw new Error('bad_name');
|
||||||
|
const out = ext === '.svg' ? Buffer.from(sanitizeSvg(buffer)) : buffer;
|
||||||
|
return { name: `${base}${ext}`, buffer: out, ext, contentType: EXT[ext] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract image entries from a zip buffer; flatten basenames, skip traversal/junk.
|
||||||
|
export function unpackZip(buffer) {
|
||||||
|
const zip = new AdmZip(buffer);
|
||||||
|
const entries = zip.getEntries();
|
||||||
|
if (entries.length > MAX_ZIP_ENTRIES) throw new Error('too_many_entries');
|
||||||
|
const out = []; let total = 0;
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isDirectory) continue;
|
||||||
|
const ext = path.extname(e.entryName).toLowerCase();
|
||||||
|
if (!EXT[ext]) continue; // skip non-images
|
||||||
|
if (/(^|[\\/])\.\.([\\/]|$)/.test(e.entryName)) continue; // skip traversal
|
||||||
|
const data = e.getData();
|
||||||
|
total += data.length;
|
||||||
|
if (total > MAX_ZIP_TOTAL) throw new Error('zip_too_big');
|
||||||
|
try { out.push(processFile({ name: path.basename(e.entryName), buffer: data })); }
|
||||||
|
catch { /* skip individually-invalid entries */ }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIVATE_HOST = /^(localhost|127\.|0\.0\.0\.0|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|\[?::1\]?)/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given IP string is a blocked (loopback, private,
|
||||||
|
* link-local, or ULA) address that should not be fetched.
|
||||||
|
* Handles both IPv4 and IPv6.
|
||||||
|
*/
|
||||||
|
export function isBlockedAddress(ip) {
|
||||||
|
if (!ip) return true;
|
||||||
|
// IPv6 loopback
|
||||||
|
if (ip === '::1') return true;
|
||||||
|
// IPv4-mapped loopback ::ffff:127.x.x.x
|
||||||
|
const v4mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
||||||
|
const v4 = v4mapped ? v4mapped[1] : ip;
|
||||||
|
|
||||||
|
if (/^\d+\.\d+\.\d+\.\d+$/.test(v4)) {
|
||||||
|
const parts = v4.split('.').map(Number);
|
||||||
|
const [a, b] = parts;
|
||||||
|
// 0.0.0.0
|
||||||
|
if (a === 0) return true;
|
||||||
|
// 127.0.0.0/8 — loopback
|
||||||
|
if (a === 127) return true;
|
||||||
|
// 10.0.0.0/8 — private
|
||||||
|
if (a === 10) return true;
|
||||||
|
// 172.16.0.0/12 — private
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||||
|
// 192.168.0.0/16 — private
|
||||||
|
if (a === 192 && b === 168) return true;
|
||||||
|
// 169.254.0.0/16 — link-local
|
||||||
|
if (a === 169 && b === 254) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6 checks (expand to lower-case for prefix matching)
|
||||||
|
const lower = ip.toLowerCase();
|
||||||
|
// ::1 is caught above; handle full-form loopback
|
||||||
|
if (lower === '0:0:0:0:0:0:0:1') return true;
|
||||||
|
// fe80::/10 — link-local (fe80 – febf)
|
||||||
|
if (/^fe[89ab][0-9a-f]:/i.test(lower)) return true;
|
||||||
|
// fc00::/7 — ULA (fc00 – fdff)
|
||||||
|
if (/^f[cd][0-9a-f]{2}:/i.test(lower)) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dnsLookupAll(hostname) {
|
||||||
|
return new Promise((resolve, reject) =>
|
||||||
|
dns.lookup(hostname, { all: true }, (err, addrs) => err ? reject(err) : resolve(addrs))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch a remote icon or zip. SSRF guard: http/https only, no localhost/private,
|
||||||
|
// DNS-resolved address check, size + timeout caps. `fetcher` injectable for tests.
|
||||||
|
export async function fetchUrl(url, { fetcher } = {}) {
|
||||||
|
let u;
|
||||||
|
try { u = new URL(url); } catch { throw new Error('bad_url'); }
|
||||||
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad_scheme');
|
||||||
|
// Fast literal-hostname guard (catches raw IP strings and 'localhost' without DNS)
|
||||||
|
if (PRIVATE_HOST.test(u.hostname)) throw new Error('blocked_host');
|
||||||
|
// DNS resolution guard — only when using the real fetcher (not in tests)
|
||||||
|
if (!fetcher) {
|
||||||
|
const addrs = await dnsLookupAll(u.hostname).catch(() => []);
|
||||||
|
if (addrs.some(a => isBlockedAddress(a.address))) throw new Error('blocked_host');
|
||||||
|
}
|
||||||
|
const realFetcher = fetcher ?? fetch;
|
||||||
|
const res = await realFetcher(url, { signal: AbortSignal.timeout(8000), redirect: 'error' });
|
||||||
|
if (!res.ok) throw new Error('fetch_failed');
|
||||||
|
const ab = await res.arrayBuffer();
|
||||||
|
if (ab.byteLength > MAX_URL_BYTES) throw new Error('too_large');
|
||||||
|
return { buffer: Buffer.from(ab) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isZip(buf) { return buf && buf.length > 4 && buf[0] === 0x50 && buf[1] === 0x4b; }
|
||||||
16
lib/icons/sanitize.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// lib/icons/sanitize.js
|
||||||
|
// Focused SVG sanitizer for owner-uploaded icons. NOT a general-purpose
|
||||||
|
// sanitizer — it removes the script/handler/foreignObject/js-uri vectors that
|
||||||
|
// matter for inline-rendered icons. (Owner-only upload behind CF Access.)
|
||||||
|
export function sanitizeSvg(input) {
|
||||||
|
let s = Buffer.isBuffer(input) ? input.toString('utf8') : String(input);
|
||||||
|
s = s.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||||
|
s = s.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, '');
|
||||||
|
s = s.replace(/\son[a-z]+\s*=\s*"[^"]*"/gi, '');
|
||||||
|
s = s.replace(/\son[a-z]+\s*=\s*'[^']*'/gi, '');
|
||||||
|
// Unquoted handlers, e.g. <svg onload=alert(1)>. Value runs until whitespace,
|
||||||
|
// quote, or the tag's closing > / />.
|
||||||
|
s = s.replace(/\son[a-z]+\s*=\s*[^\s">]+/gi, '');
|
||||||
|
s = s.replace(/(href|xlink:href)\s*=\s*("|')\s*javascript:[^"']*\2/gi, '$1=$2#$2');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
52
lib/icons/sets.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// lib/icons/sets.js
|
||||||
|
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const BUNDLED_SET = 'devices'; // read-only, ships in public/icons/devices
|
||||||
|
let setsDir = process.env.ICON_SETS_DIR || '/var/lib/void/icon-sets';
|
||||||
|
let bundledDir = path.resolve('public/icons/devices');
|
||||||
|
export function _setDirs({ setsDir: s, bundledDir: b }) { if (s) setsDir = s; if (b) bundledDir = b; }
|
||||||
|
|
||||||
|
const SLUG = /^[a-z0-9-]+$/;
|
||||||
|
const FILE = /^[a-z0-9-]+\.(svg|png)$/;
|
||||||
|
function okSet(s) { return SLUG.test(s); }
|
||||||
|
|
||||||
|
async function listDir(dir) {
|
||||||
|
try { return (await readdir(dir)).filter(f => FILE.test(f)).sort(); } catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSets() {
|
||||||
|
const out = [{ set: BUNDLED_SET, readonly: true, icons: await listDir(bundledDir) }];
|
||||||
|
let uploaded = [];
|
||||||
|
try { uploaded = await readdir(setsDir, { withFileTypes: true }); } catch { /* none yet */ }
|
||||||
|
for (const d of uploaded) {
|
||||||
|
if (d.isDirectory() && okSet(d.name) && d.name !== BUNDLED_SET) {
|
||||||
|
out.push({ set: d.name, readonly: false, icons: await listDir(path.join(setsDir, d.name)) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve an on-disk path for serving. Throws on bad slugs.
|
||||||
|
export function iconPath(set, file) {
|
||||||
|
if (!okSet(set) || !FILE.test(file)) throw new Error('bad_slug');
|
||||||
|
return set === BUNDLED_SET ? path.join(bundledDir, file) : path.join(setsDir, set, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readIcon(set, file) {
|
||||||
|
return readFile(iconPath(set, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeIcon(set, name, buffer) {
|
||||||
|
if (set === BUNDLED_SET) throw new Error('reserved_set');
|
||||||
|
if (!okSet(set) || !FILE.test(name)) throw new Error('bad_slug');
|
||||||
|
const dir = path.join(setsDir, set);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(path.join(dir, name), buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSet(set) {
|
||||||
|
if (set === BUNDLED_SET) throw new Error('reserved_set');
|
||||||
|
if (!okSet(set)) throw new Error('bad_slug');
|
||||||
|
await rm(path.join(setsDir, set), { recursive: true, force: true });
|
||||||
|
}
|
||||||
31
lib/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
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import * as services from '../../db/repos/monitored_services.js';
|
import * as services from '../../db/repos/monitored_services.js';
|
||||||
|
import * as devices from '../../db/repos/lan_devices.js';
|
||||||
import { log } from '../../log.js';
|
import { log } from '../../log.js';
|
||||||
|
|
||||||
export const NAME = 'discover.lan';
|
export const NAME = 'discover.lan';
|
||||||
|
|
||||||
|
// Well-known homelab ports → likely service, so candidates get a real name.
|
||||||
|
const PORT_SVC = {
|
||||||
|
2424: 'Void', 5055: 'Overseerr', 6767: 'Bazarr', 7878: 'Radarr', 8006: 'Proxmox VE',
|
||||||
|
8096: 'Jellyfin', 8123: 'Home Assistant', 8265: 'Tdarr', 8384: 'Syncthing', 8989: 'Sonarr',
|
||||||
|
9000: 'Portainer', 9090: 'Cockpit', 9696: 'Prowlarr', 11434: 'Ollama', 19999: 'Netdata',
|
||||||
|
32400: 'Plex'
|
||||||
|
};
|
||||||
|
|
||||||
// Common homelab web/service ports to probe.
|
// Common homelab web/service ports to probe.
|
||||||
const PORTS = [80, 81, 443, 2424, 3000, 3001, 5000, 5055, 6767, 6875, 7878, 8000,
|
const PORTS = [80, 81, 443, 2424, 3000, 3001, 5000, 5055, 6767, 6875, 7878, 8000,
|
||||||
8006, 8080, 8081, 8096, 8123, 8265, 8384, 8443, 8989, 9000, 9090, 9696, 11434, 19999, 32400, 60072];
|
8006, 8080, 8081, 8096, 8123, 8265, 8384, 8443, 8989, 9000, 9090, 9696, 11434, 19999, 32400, 60072];
|
||||||
@@ -55,13 +64,18 @@ export async function handler(job) {
|
|||||||
// 1) TCP sweep → live host:ports
|
// 1) TCP sweep → live host:ports
|
||||||
const open = (await mapPool(targets, 120, async (t) => (await _tcp(t.host, t.port)) ? t : null)).filter(Boolean);
|
const open = (await mapPool(targets, 120, async (t) => (await _tcp(t.host, t.port)) ? t : null)).filter(Boolean);
|
||||||
|
|
||||||
// 2) HTTP-probe each, build + upsert discovered candidates (no-clobber in the repo)
|
// 2) HTTP-probe each, build + upsert discovered candidates (no-clobber in the repo).
|
||||||
|
// Cross-reference the Network Devices band so candidates are named by service+device.
|
||||||
|
const deviceByIp = Object.fromEntries(
|
||||||
|
(await devices.listKnown()).filter(d => d.ip).map(d => [d.ip, d.name]));
|
||||||
let added = 0;
|
let added = 0;
|
||||||
for (const { host, port } of open) {
|
for (const { host, port } of open) {
|
||||||
const scheme = HTTPS_PORTS.has(port) ? 'https' : 'http';
|
const scheme = HTTPS_PORTS.has(port) ? 'https' : 'http';
|
||||||
const url = `${scheme}://${host}:${port}`;
|
const url = `${scheme}://${host}:${port}`;
|
||||||
const probe = await _http(url);
|
const probe = await _http(url);
|
||||||
const name = (probe && probe.title) || `${host}:${port}`;
|
const dev = deviceByIp[host];
|
||||||
|
const svc = PORT_SVC[port] || (probe && probe.title) || null;
|
||||||
|
const name = svc ? (dev ? `${svc} · ${dev}` : svc) : (dev ? `${dev} :${port}` : `${host}:${port}`);
|
||||||
const id = `disc-${host.replace(/\./g, '-')}-${port}`;
|
const id = `disc-${host.replace(/\./g, '-')}-${port}`;
|
||||||
const check = scheme === 'https' ? { type: 'tcp' } : { type: 'http' };
|
const check = scheme === 'https' ? { type: 'tcp' } : { type: 'http' };
|
||||||
const r = await services.upsertDiscovered({ id, name, category: 'other', host, url, check });
|
const r = await services.upsertDiscovered({ id, name, category: 'other', host, url, check });
|
||||||
|
|||||||
@@ -6,18 +6,42 @@ const pexec = promisify(execFile);
|
|||||||
|
|
||||||
export const NAME = 'speedtest';
|
export const NAME = 'speedtest';
|
||||||
|
|
||||||
// Default runner uses speedtest-cli --json (bits/s → Mbps). Swap binary/flags
|
// Ookla CLI gives the full metric set (jitter, packet loss, server, ISP,
|
||||||
// here if the box has the Ookla `speedtest -f json` CLI instead.
|
// shareable result URL). Override the binary via SPEEDTEST_BIN if needed.
|
||||||
async function defaultRunner() {
|
const OOKLA_BIN = process.env.SPEEDTEST_BIN || 'ookla-speedtest';
|
||||||
const { stdout } = await pexec('speedtest-cli', ['--json'], { timeout: 120000 });
|
|
||||||
|
async function ooklaRunner() {
|
||||||
|
const { stdout } = await pexec(OOKLA_BIN,
|
||||||
|
['-f', 'json', '--accept-license', '--accept-gdpr'], { timeout: 120000 });
|
||||||
const j = JSON.parse(stdout);
|
const j = JSON.parse(stdout);
|
||||||
return { down_mbps: j.download / 1e6, up_mbps: j.upload / 1e6, ping_ms: j.ping };
|
const mbps = bw => (Number(bw) || 0) * 8 / 1e6; // Ookla bandwidth is bytes/s
|
||||||
|
return {
|
||||||
|
down_mbps: mbps(j.download?.bandwidth),
|
||||||
|
up_mbps: mbps(j.upload?.bandwidth),
|
||||||
|
ping_ms: j.ping?.latency ?? null,
|
||||||
|
jitter_ms: j.ping?.jitter ?? null,
|
||||||
|
packet_loss: j.packetLoss ?? null,
|
||||||
|
server_name: j.server ? [j.server.name, j.server.location].filter(Boolean).join(' · ') : null,
|
||||||
|
server_id: j.server?.id != null ? String(j.server.id) : null,
|
||||||
|
isp: j.isp ?? null,
|
||||||
|
result_url: j.result?.url ?? null,
|
||||||
|
down_bytes: j.download?.bytes ?? null,
|
||||||
|
up_bytes: j.upload?.bytes ?? null,
|
||||||
|
ok: true
|
||||||
|
};
|
||||||
}
|
}
|
||||||
let runner = defaultRunner;
|
let runner = ooklaRunner;
|
||||||
export function _setRunner(fn) { runner = fn; }
|
export function _setRunner(fn) { runner = fn; }
|
||||||
|
|
||||||
export async function handler(_job) {
|
export async function handler(_job) {
|
||||||
const r = await runner();
|
try {
|
||||||
await repo.record(r);
|
const r = await runner();
|
||||||
log.info(r, 'speedtest recorded');
|
const saved = await repo.record(r);
|
||||||
|
log.info({ down: r.down_mbps, up: r.up_mbps, ping: r.ping_ms }, 'speedtest recorded');
|
||||||
|
return saved;
|
||||||
|
} catch (e) {
|
||||||
|
await repo.record({ ok: false, error: String(e?.message || e).slice(0, 300) });
|
||||||
|
log.error({ err: e }, 'speedtest failed');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
@@ -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() };
|
||||||
|
}
|
||||||
|
}
|
||||||
22
lib/voice/whisper.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Thin client for the local faster-whisper service on CT 102 (the Ollama box).
|
||||||
|
// GPU with CPU fallback lives in the service itself; here we just POST the audio
|
||||||
|
// buffer and return the transcript. LAN-only endpoint.
|
||||||
|
const WHISPER_URL = process.env.WHISPER_URL || 'http://192.168.1.185:8001';
|
||||||
|
|
||||||
|
export async function transcribe(buffer, filename = 'clip.webm', mime = 'audio/webm') {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', new Blob([buffer], { type: mime }), filename);
|
||||||
|
const res = await fetch(`${WHISPER_URL}/transcribe`, {
|
||||||
|
method: 'POST', body: fd, signal: AbortSignal.timeout(120000)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`whisper ${res.status}`);
|
||||||
|
const j = await res.json();
|
||||||
|
return { text: (j.text || '').trim(), duration: j.duration, device: j.device };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function health() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${WHISPER_URL}/health`, { signal: AbortSignal.timeout(5000) });
|
||||||
|
return res.ok ? await res.json() : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
14
package-lock.json
generated
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "void-server",
|
"name": "void-server",
|
||||||
"version": "2.0.0-alpha.16",
|
"version": "2.13.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "void-server",
|
"name": "void-server",
|
||||||
"version": "2.0.0-alpha.16",
|
"version": "2.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
|
"adm-zip": "^0.5.17",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"dompurify": "^3.4.7",
|
"dompurify": "^3.4.7",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
@@ -965,6 +966,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adm-zip": {
|
||||||
|
"version": "0.5.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
|
||||||
|
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "8.20.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "void-server",
|
"name": "void-server",
|
||||||
"version": "2.0.0",
|
"version": "2.14.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
|
"adm-zip": "^0.5.17",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"dompurify": "^3.4.7",
|
"dompurify": "^3.4.7",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ function token() { return localStorage.getItem(TOKEN_KEY) || ''; }
|
|||||||
|
|
||||||
async function call(method, path, body) {
|
async function call(method, path, body) {
|
||||||
const headers = { 'Authorization': 'Bearer ' + token() };
|
const headers = { 'Authorization': 'Bearer ' + token() };
|
||||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
// FormData bodies: let the browser set the multipart/form-data boundary
|
||||||
|
// automatically — do NOT set Content-Type or JSON.stringify.
|
||||||
|
const isFormData = body instanceof FormData;
|
||||||
|
if (body !== undefined && !isFormData) headers['Content-Type'] = 'application/json';
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: body === undefined ? undefined : JSON.stringify(body)
|
body: body === undefined ? undefined : (isFormData ? body : JSON.stringify(body))
|
||||||
});
|
});
|
||||||
if (res.status === 401) { await promptForToken(); return call(method, path, body); }
|
if (res.status === 401) { await promptForToken(); return call(method, path, body); }
|
||||||
if (res.status === 204) return null;
|
if (res.status === 204) return null;
|
||||||
@@ -61,11 +64,14 @@ function promptForToken() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: (p) => call('GET', p),
|
get: (p) => call('GET', p),
|
||||||
post: (p, body) => call('POST', p, body ?? {}),
|
post: (p, body) => call('POST', p, body ?? {}),
|
||||||
put: (p, body) => call('PUT', p, body ?? {}),
|
put: (p, body) => call('PUT', p, body ?? {}),
|
||||||
patch: (p, body) => call('PATCH', p, body ?? {}),
|
patch: (p, body) => call('PATCH', p, body ?? {}),
|
||||||
del: (p) => call('DELETE', p),
|
del: (p) => call('DELETE', p),
|
||||||
|
// POST a FormData body (multipart/form-data). Content-Type is omitted so
|
||||||
|
// the browser appends the correct multipart boundary automatically.
|
||||||
|
postForm: (p, fd) => call('POST', p, fd),
|
||||||
setToken: (v) => localStorage.setItem(TOKEN_KEY, v),
|
setToken: (v) => localStorage.setItem(TOKEN_KEY, v),
|
||||||
hasToken: () => !!token()
|
hasToken: () => !!token()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import { api } from './api.js';
|
|||||||
import { route, current, navigate } from './router.js';
|
import { route, current, navigate } from './router.js';
|
||||||
import { renderSidebar } from './components/sidebar.js';
|
import { renderSidebar } from './components/sidebar.js';
|
||||||
import { renderTopbar } from './components/topbar.js';
|
import { renderTopbar } from './components/topbar.js';
|
||||||
import { renderRightrail } from './components/rightrail.js';
|
import { renderDrossBubble } from './components/dross_bubble.js';
|
||||||
import { emit, state } from './state.js';
|
import { emit, state } from './state.js';
|
||||||
import { el, mount } from './dom.js';
|
import { el, mount } from './dom.js';
|
||||||
import { attachDropzone } from './components/dropzone.js';
|
import { attachDropzone } from './components/dropzone.js';
|
||||||
import { initChrome } from './components/chrome.js';
|
import { initChrome } from './components/chrome.js';
|
||||||
|
import { loadTheme } from './theme.js';
|
||||||
|
|
||||||
const VIEWS = {
|
const VIEWS = {
|
||||||
home: () => import('./views/home.js'),
|
home: () => import('./views/home.js'),
|
||||||
@@ -27,8 +28,12 @@ const VIEWS = {
|
|||||||
terminal: () => import('./views/terminal.js'),
|
terminal: () => import('./views/terminal.js'),
|
||||||
timelapse: () => import('./views/timelapse.js'),
|
timelapse: () => import('./views/timelapse.js'),
|
||||||
'ai-usage': () => import('./views/aiusage.js'),
|
'ai-usage': () => import('./views/aiusage.js'),
|
||||||
|
obd2: () => import('./views/obd2.js'),
|
||||||
|
links: () => import('./views/links.js'),
|
||||||
|
mirror: () => import('./views/mirror.js'),
|
||||||
settings: () => import('./views/settings.js'),
|
settings: () => import('./views/settings.js'),
|
||||||
jobs: () => import('./views/jobs.js')
|
jobs: () => import('./views/jobs.js'),
|
||||||
|
speedtest: () => import('./views/speedtest.js')
|
||||||
};
|
};
|
||||||
|
|
||||||
async function renderView(ctx) {
|
async function renderView(ctx) {
|
||||||
@@ -76,9 +81,10 @@ async function init() {
|
|||||||
try { await api.get('/api/spaces'); }
|
try { await api.get('/api/spaces'); }
|
||||||
catch { /* api wrapper opens the modal on 401 */ }
|
catch { /* api wrapper opens the modal on 401 */ }
|
||||||
}
|
}
|
||||||
|
await loadTheme(); // apply saved palette overrides before rendering chrome
|
||||||
renderTopbar(document.getElementById('topbar'));
|
renderTopbar(document.getElementById('topbar'));
|
||||||
renderSidebar(document.getElementById('sidebar'));
|
renderSidebar(document.getElementById('sidebar'));
|
||||||
renderRightrail(document.getElementById('rightrail'));
|
renderDrossBubble();
|
||||||
initChrome();
|
initChrome();
|
||||||
attachDropzone(document.getElementById('main'));
|
attachDropzone(document.getElementById('main'));
|
||||||
route(renderView);
|
route(renderView);
|
||||||
|
|||||||
20
public/components/dross_avatar.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// public/components/dross_avatar.js
|
||||||
|
import { el } from '../dom.js';
|
||||||
|
|
||||||
|
// Returns a .dross-orb element rendering the chosen avatar. Colours come from
|
||||||
|
// CSS vars (--dross*), set on the element by the caller for per-user accent.
|
||||||
|
export function drossAvatar(variant = 'soft-eye', size = 60) {
|
||||||
|
let inner;
|
||||||
|
if (variant === 'wisp') {
|
||||||
|
inner = [el('div', { class: 'b-core' }), el('div', { class: 'b-bright' })];
|
||||||
|
} else if (variant === 'motes') {
|
||||||
|
inner = [
|
||||||
|
el('div', { class: 'd-ring' }, el('div', { class: 'd-mote' })),
|
||||||
|
el('div', { class: 'd-ring r2' }, el('div', { class: 'd-mote' })),
|
||||||
|
el('div', { class: 'd-core' })
|
||||||
|
];
|
||||||
|
} else { // soft-eye (default)
|
||||||
|
inner = [el('div', { class: 'av-eye' }, el('div', { class: 'av-pupil' }))];
|
||||||
|
}
|
||||||
|
return el('div', { class: 'dross-orb', style: { width: size + 'px', height: size + 'px' } }, ...inner);
|
||||||
|
}
|
||||||
167
public/components/dross_bubble.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
// public/components/dross_bubble.js
|
||||||
|
// Global floating Dross companion. Replaces the per-Space right rail.
|
||||||
|
import { el, mount } from '../dom.js';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { state } from '../state.js';
|
||||||
|
import { wireAgentChat } from './agent_chat.js';
|
||||||
|
import { drossAvatar } from './dross_avatar.js';
|
||||||
|
|
||||||
|
const TOOL_LABELS = { search: '🔍 searching', read: '📄 reading', context: '🧭 looking at this view', propose_change: '📝 drafting a change', propose_improvement: '🎨 drafting an improvement to the Void' };
|
||||||
|
let cfg = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||||
|
|
||||||
|
function applyAccent(node, hex) {
|
||||||
|
node.style.setProperty('--dross', hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderDrossBubble() {
|
||||||
|
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { /* defaults */ }
|
||||||
|
|
||||||
|
const fab = el('div', { class: 'dross-fab', title: 'Dross' },
|
||||||
|
el('div', { class: 'dross-ping', style: { display: 'none' } }, ''), drossAvatar(cfg.avatar, 60));
|
||||||
|
const log = el('div', { class: 'dross-log' });
|
||||||
|
const input = el('textarea', { rows: 1, placeholder: 'Ask Dross…' });
|
||||||
|
const sendBtn = el('button', { class: 'dross-send', title: 'Send' },
|
||||||
|
el('span', { html: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>' }));
|
||||||
|
const micLabel = el('span', {}, 'Tap to record');
|
||||||
|
const mic = el('button', { class: 'dross-mic', title: 'Record a voice note' },
|
||||||
|
el('span', { html: '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg>' }), micLabel);
|
||||||
|
const closeBtn = el('button', { class: 'dross-x', title: 'Close' }, '⤬');
|
||||||
|
const header = el('div', { class: 'dross-hd' }, drossAvatar(cfg.avatar, 30),
|
||||||
|
el('div', { class: 'dross-who' }, 'Dross', el('small', {}, 'always here, regrettably')), closeBtn);
|
||||||
|
const collapse = el('div', { class: 'dross-collapse', title: 'Collapse' },
|
||||||
|
el('span', { class: 'grip' }), el('span', {}, '⌄ collapse'), el('span', { class: 'grip' }));
|
||||||
|
const panel = el('div', { class: 'dross-panel' }, header, log,
|
||||||
|
el('div', { class: 'dross-inwrap' }, input, el('div', { class: 'dross-btnrow' }, mic, sendBtn)), collapse);
|
||||||
|
|
||||||
|
document.getElementById('shell').append(fab, panel);
|
||||||
|
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
|
||||||
|
|
||||||
|
// autogrow: 1 line at rest, expands with content up to ~5 lines
|
||||||
|
function autogrow() {
|
||||||
|
input.style.height = 'auto';
|
||||||
|
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
|
||||||
|
}
|
||||||
|
input.addEventListener('input', autogrow);
|
||||||
|
|
||||||
|
const chat = wireAgentChat({
|
||||||
|
logEl: log, inputEl: input, sendBtnEl: sendBtn,
|
||||||
|
historyUrl: '/api/dross', turnUrl: '/api/dross/turn',
|
||||||
|
agentName: 'Dross', showDrafts: true, toolLabels: TOOL_LABELS,
|
||||||
|
turnBody: (text) => ({ text, view: state.view || null })
|
||||||
|
});
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
function openPanel() {
|
||||||
|
const r = fab.getBoundingClientRect();
|
||||||
|
panel.classList.add('open'); fab.style.display = 'none';
|
||||||
|
const pr = panel.getBoundingClientRect();
|
||||||
|
const left = Math.max(8, Math.min(r.right - pr.width, innerWidth - pr.width - 8));
|
||||||
|
const top = Math.max(8, Math.min(r.bottom - pr.height, innerHeight - pr.height - 8));
|
||||||
|
panel.style.right = 'auto'; panel.style.bottom = 'auto'; panel.style.left = left + 'px'; panel.style.top = top + 'px';
|
||||||
|
if (!loaded) { loaded = true; chat.load(); }
|
||||||
|
// NB: do NOT auto-focus the input — on mobile that pops the keyboard every
|
||||||
|
// time Dross opens. The keyboard should only appear when the user taps the box.
|
||||||
|
}
|
||||||
|
function closePanel() { panel.classList.remove('open'); fab.style.display = 'block'; }
|
||||||
|
fab.addEventListener('click', () => { if (fab._moved) { fab._moved = false; return; } openPanel(); });
|
||||||
|
closeBtn.addEventListener('click', closePanel);
|
||||||
|
collapse.addEventListener('click', closePanel);
|
||||||
|
// Topbar ◆ button (and any caller) can summon/dismiss Dross.
|
||||||
|
window.addEventListener('dross-toggle', () => panel.classList.contains('open') ? closePanel() : openPanel());
|
||||||
|
|
||||||
|
drag(fab, fab, true); drag(header, panel, false);
|
||||||
|
|
||||||
|
// ---- voice: tap mic to record, tap again to stop → transcribe → review-and-send ----
|
||||||
|
let media = null, chunks = [], recording = false;
|
||||||
|
function setMic(label, rec) { micLabel.textContent = label; mic.classList.toggle('rec', !!rec); }
|
||||||
|
async function startRec() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
chunks = [];
|
||||||
|
const opt = (window.MediaRecorder && MediaRecorder.isTypeSupported('audio/webm;codecs=opus'))
|
||||||
|
? { mimeType: 'audio/webm;codecs=opus' } : {};
|
||||||
|
media = new MediaRecorder(stream, opt);
|
||||||
|
media.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
|
||||||
|
media.onstop = async () => {
|
||||||
|
stream.getTracks().forEach(t => t.stop());
|
||||||
|
await sendClip(new Blob(chunks, { type: media.mimeType || 'audio/webm' }));
|
||||||
|
};
|
||||||
|
media.start();
|
||||||
|
recording = true; setMic('● Recording… tap to stop', true);
|
||||||
|
// live level meter: actual mic amplitude drives the pulse (visual proof it hears you)
|
||||||
|
try {
|
||||||
|
const actx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const src = actx.createMediaStreamSource(stream);
|
||||||
|
const analyser = actx.createAnalyser(); analyser.fftSize = 256;
|
||||||
|
src.connect(analyser);
|
||||||
|
const buf = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
mic.classList.add('metered'); // disables the fallback pulse; amplitude takes over
|
||||||
|
const tick = () => {
|
||||||
|
if (!recording) {
|
||||||
|
actx.close().catch(() => {});
|
||||||
|
mic.style.removeProperty('--voicelevel'); mic.classList.remove('metered');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
analyser.getByteTimeDomainData(buf);
|
||||||
|
let peak = 0;
|
||||||
|
for (const v of buf) peak = Math.max(peak, Math.abs(v - 128));
|
||||||
|
// sqrt curve + gain: normal speech peaks ~0.1–0.4 raw, which read as barely-alive
|
||||||
|
mic.style.setProperty('--voicelevel', Math.min(1, Math.sqrt(peak / 48)).toFixed(3));
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
} catch { /* meter is decorative — recording works without it */ }
|
||||||
|
} catch {
|
||||||
|
setMic('Mic blocked', false); setTimeout(() => setMic('Tap to record', false), 1800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function stopRec() {
|
||||||
|
if (media && recording) { recording = false; setMic('Transcribing…', false); media.stop(); }
|
||||||
|
}
|
||||||
|
async function sendClip(blob) {
|
||||||
|
try {
|
||||||
|
const fd = new FormData(); fd.append('audio', blob, 'clip.webm');
|
||||||
|
const res = await fetch('/api/voice/transcribe', {
|
||||||
|
method: 'POST', headers: { Authorization: 'Bearer ' + (localStorage.getItem('void_token') || '') }, body: fd
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('stt');
|
||||||
|
const { text } = await res.json();
|
||||||
|
setMic('Tap to record', false);
|
||||||
|
if (text) {
|
||||||
|
input.value = input.value ? (input.value + ' ' + text) : text;
|
||||||
|
autogrow();
|
||||||
|
// Focus only on fine-pointer devices — on mobile this popped the keyboard
|
||||||
|
// right after every voice note (owner-reported). A brief highlight instead.
|
||||||
|
if (matchMedia('(pointer: fine)').matches) input.focus();
|
||||||
|
else { input.classList.add('flash'); setTimeout(() => input.classList.remove('flash'), 900); }
|
||||||
|
}
|
||||||
|
// voiceMode 'handsfree'/'action' (Phase 2b+) would branch here.
|
||||||
|
} catch {
|
||||||
|
setMic('Transcribe failed', false); setTimeout(() => setMic('Tap to record', false), 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mic.addEventListener('click', () => recording ? stopRec() : startRec());
|
||||||
|
|
||||||
|
window.addEventListener('dross-settings-changed', async () => {
|
||||||
|
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { return; }
|
||||||
|
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
|
||||||
|
mount(fab, el('div', { class: 'dross-ping', style: { display: 'none' } }), drossAvatar(cfg.avatar, 60));
|
||||||
|
header.replaceChild(drossAvatar(cfg.avatar, 30), header.firstChild);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drag(handle, target, isFab) {
|
||||||
|
handle.addEventListener('pointerdown', (e) => {
|
||||||
|
if (e.target.closest('.dross-x') || e.target.closest('.dross-mic') || e.target.closest('.dross-send')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const r = target.getBoundingClientRect(); const sx = e.clientX, sy = e.clientY; let moved = false;
|
||||||
|
target.style.right = 'auto'; target.style.bottom = 'auto'; target.style.left = r.left + 'px'; target.style.top = r.top + 'px';
|
||||||
|
const mv = (ev) => {
|
||||||
|
const dx = ev.clientX - sx, dy = ev.clientY - sy; if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;
|
||||||
|
target.style.left = Math.max(4, Math.min(innerWidth - r.width - 4, r.left + dx)) + 'px';
|
||||||
|
target.style.top = Math.max(4, Math.min(innerHeight - r.height - 4, r.top + dy)) + 'px';
|
||||||
|
};
|
||||||
|
const up = () => { document.removeEventListener('pointermove', mv); document.removeEventListener('pointerup', up); if (isFab) target._moved = moved; };
|
||||||
|
document.addEventListener('pointermove', mv); document.addEventListener('pointerup', up);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -122,7 +122,8 @@ export function renderSidebar(root) {
|
|||||||
el('div', { class: 'sb-section' },
|
el('div', { class: 'sb-section' },
|
||||||
el('div', { class: 'sb-title' }, 'Navigate'),
|
el('div', { class: 'sb-title' }, 'Navigate'),
|
||||||
navItem('Sacred Valley', '/sacred-valley'),
|
navItem('Sacred Valley', '/sacred-valley'),
|
||||||
navItem('Terminal', '/terminal'),
|
navItem('Speedtest', '/speedtest'),
|
||||||
|
navItem('Eithan', '/terminal'),
|
||||||
navItem('Search', '/search'),
|
navItem('Search', '/search'),
|
||||||
inboxItem,
|
inboxItem,
|
||||||
navItem('Jobs', '/jobs'),
|
navItem('Jobs', '/jobs'),
|
||||||
@@ -131,7 +132,10 @@ export function renderSidebar(root) {
|
|||||||
el('div', { class: 'sb-section' },
|
el('div', { class: 'sb-section' },
|
||||||
el('div', { class: 'sb-title' }, 'Apps'),
|
el('div', { class: 'sb-title' }, 'Apps'),
|
||||||
navItem('Timelapse', '/timelapse'),
|
navItem('Timelapse', '/timelapse'),
|
||||||
navItem('AI Usage', '/ai-usage')
|
navItem('AI Usage', '/ai-usage'),
|
||||||
|
navItem('OBD2', '/obd2'),
|
||||||
|
navItem('Links', '/links'),
|
||||||
|
navItem('MagicMirror', '/mirror')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { el } from '../dom.js';
|
import { el } from '../dom.js';
|
||||||
|
|
||||||
// Builds the refined-B chrome shell and returns { root, body }. The card module
|
// Builds the refined-B chrome shell and returns { root, body }. The card module
|
||||||
// fills `body` in its mount(); start()/stop() own its refresh timer.
|
// fills `body` in its mount(); start()/stop() own its refresh timer. Position +
|
||||||
|
// size are set by the Sacred Valley canvas (absolute geometry), not here.
|
||||||
|
// Decorative cards (blank / blackflame) carry no title bar.
|
||||||
export function svCard(def) {
|
export function svCard(def) {
|
||||||
const body = el('div', { class: 'sv-card-body' });
|
const body = el('div', { class: 'sv-card-body' });
|
||||||
const root = el('div', {
|
const root = el('div', { class: 'sv-card', dataset: { cardId: def.id } },
|
||||||
class: 'sv-card', dataset: { cardId: def.id },
|
def.title ? el('div', { class: 'sv-card-title' }, def.title) : null,
|
||||||
style: { gridColumn: 'span ' + (def.span || 6) } // 12-col grid; per-card width
|
|
||||||
},
|
|
||||||
el('div', { class: 'sv-card-title' }, def.title),
|
|
||||||
body
|
body
|
||||||
);
|
);
|
||||||
return { root, body };
|
return { root, body };
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { el, mount, clear } from '../dom.js';
|
import { el, mount, clear } from '../dom.js';
|
||||||
import { navigate } from '../router.js';
|
import { navigate } from '../router.js';
|
||||||
import { on } from '../state.js';
|
import { on } from '../state.js';
|
||||||
import { toggleSidebar, toggleRail } from './chrome.js';
|
import { toggleSidebar } from './chrome.js';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
|
||||||
// Cluster health → topbar pill. Returns [status, label, title].
|
// Cluster health → topbar pill. Returns [status, label, title].
|
||||||
@@ -72,7 +72,7 @@ export function renderTopbar(root) {
|
|||||||
el('div', { class: 'topbar-spacer' }),
|
el('div', { class: 'topbar-spacer' }),
|
||||||
clusterPill,
|
clusterPill,
|
||||||
bell,
|
bell,
|
||||||
el('button', { class: 'chrome-toggle', title: 'Toggle companion chat', onclick: toggleRail }, '◆'),
|
el('button', { class: 'chrome-toggle', title: 'Summon Dross', onclick: () => window.dispatchEvent(new CustomEvent('dross-toggle')) }, '◆'),
|
||||||
el('button', { class: 'icon-btn', onclick: () => alert('Agent-switching ships post-Plan-2.') }, 'Owner')
|
el('button', { class: 'icon-btn', onclick: () => alert('Agent-switching ships post-Plan-2.') }, 'Owner')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
]}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
20
public/icons/devices/camera.svg
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!--
|
||||||
|
tags: [video, photo, aperture, camera, content, entertainment, multimedia, broadcast, audio]
|
||||||
|
category: Media
|
||||||
|
version: "1.0"
|
||||||
|
unicode: "ea54"
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#e8e6ed"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M5 7h1a2 2 0 0 0 2 -2a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2" />
|
||||||
|
<path d="M9 13a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 555 B |
23
public/icons/devices/console.svg
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!--
|
||||||
|
tags: [game, play, entertainment, console, joystick, joypad, controller, device, gamepad, hardware]
|
||||||
|
category: Devices
|
||||||
|
version: "1.68"
|
||||||
|
unicode: "f1d2"
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#e8e6ed"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 5h3.5a5 5 0 0 1 0 10h-5.5l-4.015 4.227a2.3 2.3 0 0 1 -3.923 -2.035l1.634 -8.173a5 5 0 0 1 4.904 -4.019h3.4" />
|
||||||
|
<path d="M14 15l4.07 4.284a2.3 2.3 0 0 0 3.925 -2.023l-1.6 -8.232" />
|
||||||
|
<path d="M8 9v2" />
|
||||||
|
<path d="M7 10h2" />
|
||||||
|
<path d="M14 10h2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 629 B |
22
public/icons/devices/desktop.svg
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!--
|
||||||
|
tags: [monitor, computer, imac, device, desktop, hardware, technology, electronic, gadget, equipment]
|
||||||
|
category: Devices
|
||||||
|
version: "1.0"
|
||||||
|
unicode: "ea89"
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#e8e6ed"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 5a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-10" />
|
||||||
|
<path d="M7 20h10" />
|
||||||
|
<path d="M9 16v4" />
|
||||||
|
<path d="M15 16v4" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 528 B |
20
public/icons/devices/laptop.svg
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!--
|
||||||
|
tags: [workstation, mac, notebook, portable, screen, computer, device, laptop, hardware, technology]
|
||||||
|
category: Devices
|
||||||
|
version: "1.2"
|
||||||
|
unicode: "eb64"
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#e8e6ed"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 19l18 0" />
|
||||||
|
<path d="M5 7a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v8a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1l0 -8" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 482 B |
21
public/icons/devices/nas.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!--
|
||||||
|
tags: [storage, data, memory, database, repository, records, information, table, content, record]
|
||||||
|
category: Database
|
||||||
|
version: "1.0"
|
||||||
|
unicode: "ea88"
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#e8e6ed"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M4 6a8 3 0 1 0 16 0a8 3 0 1 0 -16 0" />
|
||||||
|
<path d="M4 6v6a8 3 0 0 0 16 0v-6" />
|
||||||
|
<path d="M4 12v6a8 3 0 0 0 16 0v-6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 492 B |
21
public/icons/devices/phone.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!--
|
||||||
|
tags: [iphone, phone, smartphone, cellphone, device, mobile, hardware, technology, electronic, gadget]
|
||||||
|
category: Devices
|
||||||
|
version: "1.0"
|
||||||
|
unicode: "ea8a"
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#e8e6ed"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M6 5a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2v-14" />
|
||||||
|
<path d="M11 4h2" />
|
||||||
|
<path d="M12 17v.01" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 505 B |
22
public/icons/devices/plug.svg
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!--
|
||||||
|
tags: [electricity, charger, socket, connection, plug, hardware, technology, electronic, gadget, equipment]
|
||||||
|
category: Devices
|
||||||
|
version: "1.6"
|
||||||
|
unicode: "ebd9"
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#e8e6ed"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M9.785 6l8.215 8.215l-2.054 2.054a5.81 5.81 0 1 1 -8.215 -8.215l2.054 -2.054" />
|
||||||
|
<path d="M4 20l3.5 -3.5" />
|
||||||
|
<path d="M15 4l-3.5 3.5" />
|
||||||
|
<path d="M20 9l-3.5 3.5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 551 B |
21
public/icons/devices/printer.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!--
|
||||||
|
tags: [fax, office, device, printer, hardware, technology, electronic, gadget, equipment]
|
||||||
|
category: Devices
|
||||||
|
version: "1.0"
|
||||||
|
unicode: "eb0e"
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#e8e6ed"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M17 17h2a2 2 0 0 0 2 -2v-4a2 2 0 0 0 -2 -2h-14a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h2" />
|
||||||
|
<path d="M17 9v-4a2 2 0 0 0 -2 -2h-6a2 2 0 0 0 -2 2v4" />
|
||||||
|
<path d="M7 15a2 2 0 0 1 2 -2h6a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-6a2 2 0 0 1 -2 -2l0 -4" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 599 B |
24
public/icons/devices/router.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!--
|
||||||
|
tags: [wifi, device, wireless, signal, station, cast, router, hardware, technology, electronic]
|
||||||
|
category: Devices
|
||||||
|
version: "1.0"
|
||||||
|
unicode: "eb18"
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#e8e6ed"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 15a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2l0 -4" />
|
||||||
|
<path d="M17 17l0 .01" />
|
||||||
|
<path d="M13 17l0 .01" />
|
||||||
|
<path d="M15 13l0 -2" />
|
||||||
|
<path d="M11.75 8.75a4 4 0 0 1 6.5 0" />
|
||||||
|
<path d="M8.5 6.5a8 8 0 0 1 13 0" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 617 B |
22
public/icons/devices/server.svg
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!--
|
||||||
|
tags: [storage, hosting, www, server, hardware, technology, electronic, gadget, equipment]
|
||||||
|
category: Devices
|
||||||
|
version: "1.0"
|
||||||
|
unicode: "eb1f"
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#e8e6ed"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3" />
|
||||||
|
<path d="M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2" />
|
||||||
|
<path d="M7 8l0 .01" />
|
||||||
|
<path d="M7 16l0 .01" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
21
public/icons/devices/speaker.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!--
|
||||||
|
tags: [voice, loud, microphone, loudspeaker, event, protest, speaker, shout, listen, speakerphone]
|
||||||
|
category: Media
|
||||||
|
version: "1.31"
|
||||||
|
unicode: "ed61"
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#e8e6ed"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M18 8a3 3 0 0 1 0 6" />
|
||||||
|
<path d="M10 8v11a1 1 0 0 1 -1 1h-1a1 1 0 0 1 -1 -1v-5" />
|
||||||
|
<path d="M12 8l4.524 -3.77a.9 .9 0 0 1 1.476 .692v12.156a.9 .9 0 0 1 -1.476 .692l-4.524 -3.77h-8a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h8" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 594 B |
20
public/icons/devices/tablet.svg
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!--
|
||||||
|
tags: [ipad, mobile, touchscreen, portable, device, tablet, hardware, technology, electronic, gadget]
|
||||||
|
category: Devices
|
||||||
|
version: "1.0"
|
||||||
|
unicode: "ea8c"
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#e8e6ed"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M5 4a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1v-16" />
|
||||||
|
<path d="M11 17a1 1 0 1 0 2 0a1 1 0 0 0 -2 0" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 508 B |
20
public/icons/devices/tv.svg
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!--
|
||||||
|
tags: [screen, display, movie, film, watch, audio, video, media, device, hardware]
|
||||||
|
category: Devices
|
||||||
|
version: "1.0"
|
||||||
|
unicode: "ea8d"
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#e8e6ed"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 9a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2l0 -9" />
|
||||||
|
<path d="M16 3l-4 4l-4 -4" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 470 B |
21
public/icons/devices/unknown.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!--
|
||||||
|
category: System
|
||||||
|
tags: [mystery, undefined, unclear, unidentified, uncertain, ambiguous, obscure, unseen, anonymous, unspecified]
|
||||||
|
unicode: "fef4"
|
||||||
|
version: "3.5"
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#e8e6ed"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M5 5a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2l0 -14" />
|
||||||
|
<path d="M12 16v.01" />
|
||||||
|
<path d="M12 13a2 2 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 567 B |
21
public/icons/devices/watch.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!--
|
||||||
|
tags: [arm, hour, date, minutes, sec., timer, device, watch, hardware, technology]
|
||||||
|
category: Devices
|
||||||
|
version: "1.8"
|
||||||
|
unicode: "ebf9"
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#e8e6ed"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M6 9a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v6a3 3 0 0 1 -3 3h-6a3 3 0 0 1 -3 -3v-6" />
|
||||||
|
<path d="M9 18v3h6v-3" />
|
||||||
|
<path d="M9 6v-3h6v3" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 489 B |
@@ -33,13 +33,13 @@
|
|||||||
<link rel="stylesheet"
|
<link rel="stylesheet"
|
||||||
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&family=Cormorant+Garamond:wght@400;500;600&display=swap" />
|
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&family=Cormorant+Garamond:wght@400;500;600&display=swap" />
|
||||||
<link rel="stylesheet" href="/style.css" />
|
<link rel="stylesheet" href="/style.css" />
|
||||||
|
<link rel="stylesheet" href="/improvements.css" id="dross-improvements" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="shell">
|
<div id="shell">
|
||||||
<header id="topbar"></header>
|
<header id="topbar"></header>
|
||||||
<aside id="sidebar"></aside>
|
<aside id="sidebar"></aside>
|
||||||
<main id="main"></main>
|
<main id="main"></main>
|
||||||
<aside id="rightrail"></aside>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="modal-root"></div>
|
<div id="modal-root"></div>
|
||||||
<script type="module" src="/app.js"></script>
|
<script type="module" src="/app.js"></script>
|
||||||
|
|||||||
@@ -28,8 +28,12 @@ const ROUTES = [
|
|||||||
{ name: 'terminal', re: /^\/terminal$/, keys: [] },
|
{ name: 'terminal', re: /^\/terminal$/, keys: [] },
|
||||||
{ name: 'timelapse', re: /^\/timelapse$/, keys: [] },
|
{ name: 'timelapse', re: /^\/timelapse$/, keys: [] },
|
||||||
{ name: 'ai-usage', re: /^\/ai-usage$/, keys: [] },
|
{ name: 'ai-usage', re: /^\/ai-usage$/, keys: [] },
|
||||||
|
{ name: 'obd2', re: /^\/obd2$/, keys: [] },
|
||||||
|
{ name: 'links', re: /^\/links$/, keys: [] },
|
||||||
|
{ name: 'mirror', re: /^\/mirror$/, keys: [] },
|
||||||
{ name: 'settings', re: /^\/settings$/, keys: [] },
|
{ name: 'settings', re: /^\/settings$/, keys: [] },
|
||||||
{ name: 'jobs', re: /^\/jobs$/, keys: [] },
|
{ name: 'jobs', re: /^\/jobs$/, keys: [] },
|
||||||
|
{ name: 'speedtest', re: /^\/speedtest$/, keys: [] },
|
||||||
{ name: 'home', re: /^\/?$/, keys: [] }
|
{ name: 'home', re: /^\/?$/, keys: [] }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
262
public/style.css
@@ -29,20 +29,18 @@ html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color:
|
|||||||
|
|
||||||
#shell {
|
#shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--sidebar-w) 1fr var(--rail-w);
|
grid-template-columns: var(--sidebar-w) 1fr;
|
||||||
grid-template-rows: var(--topbar-h) 1fr;
|
grid-template-rows: var(--topbar-h) 1fr;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"topbar topbar topbar"
|
"topbar topbar"
|
||||||
"sidebar main rail";
|
"sidebar main";
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
}
|
}
|
||||||
#shell.rail-collapsed { grid-template-columns: var(--sidebar-w) 1fr var(--rail-w-min); }
|
|
||||||
|
|
||||||
#topbar { grid-area: topbar; border-bottom: 1px solid var(--border); background: var(--panel); display: flex; align-items: center; padding: 0 16px; gap: 12px; }
|
#topbar { grid-area: topbar; border-bottom: 1px solid var(--border); background: var(--panel); display: flex; align-items: center; padding: 0 16px; gap: 12px; }
|
||||||
#sidebar { grid-area: sidebar; border-right: 1px solid var(--border); background: var(--panel); overflow-y: auto; padding: 12px 0; }
|
#sidebar { grid-area: sidebar; border-right: 1px solid var(--border); background: var(--panel); overflow-y: auto; padding: 12px 0; }
|
||||||
#main { grid-area: main; overflow-y: auto; padding: 24px 32px; }
|
#main { grid-area: main; overflow-y: auto; padding: 24px 32px; }
|
||||||
#rightrail{ grid-area: rail; border-left: 1px solid var(--border); background: var(--panel); overflow: hidden; display: flex; flex-direction: column; }
|
|
||||||
|
|
||||||
/* topbar */
|
/* topbar */
|
||||||
.brand { font-family: var(--font-display); font-weight: 700; letter-spacing: 0.18em; font-size: 14px; color: var(--accent); text-transform: uppercase; padding: 0 6px; }
|
.brand { font-family: var(--font-display); font-weight: 700; letter-spacing: 0.18em; font-size: 14px; color: var(--accent); text-transform: uppercase; padding: 0 6px; }
|
||||||
@@ -382,14 +380,9 @@ ul.plain li:last-child { border-bottom: none; }
|
|||||||
/* reserved for a future agent-output phase — unused now:
|
/* reserved for a future agent-output phase — unused now:
|
||||||
--hue-dross: #ff4f2e; --hue-yerin: #c45a4a; --hue-orthos: #6fa86a; */
|
--hue-dross: #ff4f2e; --hue-yerin: #c45a4a; --hue-orthos: #6fa86a; */
|
||||||
}
|
}
|
||||||
#sv-cards { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; align-items: start; }
|
/* Hybrid canvas: cards are absolutely placed (JS sets left/top/width/height in
|
||||||
.sv-card { grid-column: span 6; } /* fallback; svCard sets an inline span (1–12) */
|
12-col grid units); the board grows to fit its content. See sacred_valley.js. */
|
||||||
@media (max-width: 700px) { #sv-cards { grid-template-columns: 1fr; } .sv-card { grid-column: 1 / -1 !important; } }
|
#sv-cards { position: relative; width: 100%; min-height: 200px; }
|
||||||
.sv-ed-span { display: inline-flex; align-items: center; gap: 3px; }
|
|
||||||
.sv-ed-step { width: 18px; height: 20px; border: 1px solid var(--border); background: transparent; color: var(--muted);
|
|
||||||
border-radius: 3px; font-size: 14px; line-height: 1; cursor: pointer; padding: 0; }
|
|
||||||
.sv-ed-step:hover { color: var(--accent); border-color: var(--accent-dim); }
|
|
||||||
.sv-span-val { font-family: var(--font-mono); font-size: 12px; color: var(--text); min-width: 14px; text-align: center; }
|
|
||||||
|
|
||||||
.sv-card {
|
.sv-card {
|
||||||
position: relative; border: 1px solid #2c242a; border-radius: 10px; padding: 16px 18px;
|
position: relative; border: 1px solid #2c242a; border-radius: 10px; padding: 16px 18px;
|
||||||
@@ -407,8 +400,11 @@ ul.plain li:last-child { border-bottom: none; }
|
|||||||
border-color: #4a2c28; transform: translateY(-2px);
|
border-color: #4a2c28; transform: translateY(-2px);
|
||||||
box-shadow: 0 8px 28px rgba(0,0,0,.45), inset 0 0 46px rgba(255,79,46,.06), 0 0 0 1px rgba(255,79,46,.10);
|
box-shadow: 0 8px 28px rgba(0,0,0,.45), inset 0 0 46px rgba(255,79,46,.06), 0 0 0 1px rgba(255,79,46,.10);
|
||||||
}
|
}
|
||||||
.sv-card.dragging { opacity: .5; }
|
.sv-card.dragging { transition: none; box-shadow: 0 16px 44px -12px #000, 0 0 0 1px var(--accent-dim); }
|
||||||
.sv-card.drag-over { border-color: var(--accent); }
|
.sv-card.free { box-shadow: 0 0 0 1px var(--accent-dim), 0 10px 30px -12px #000; }
|
||||||
|
#sv-cards.editing .sv-card { transition: none; }
|
||||||
|
#sv-cards.editing .sv-card:hover { transform: none; }
|
||||||
|
.sv-card-body { height: 100%; overflow: auto; }
|
||||||
.sv-card-title {
|
.sv-card-title {
|
||||||
font-family: var(--font-display); font-size: 13px; letter-spacing: .16em; text-transform: uppercase;
|
font-family: var(--font-display); font-size: 13px; letter-spacing: .16em; text-transform: uppercase;
|
||||||
color: var(--text); padding-bottom: 7px; margin-bottom: 12px;
|
color: var(--text); padding-bottom: 7px; margin-bottom: 12px;
|
||||||
@@ -485,15 +481,11 @@ ul.plain li:last-child { border-bottom: none; }
|
|||||||
/* ===== Collapsible chrome + responsive layout (Plan 6 polish) ===== */
|
/* ===== Collapsible chrome + responsive layout (Plan 6 polish) ===== */
|
||||||
:root { --sidebar-w-min: 0px; }
|
:root { --sidebar-w-min: 0px; }
|
||||||
#shell { transition: grid-template-columns .22s ease; }
|
#shell { transition: grid-template-columns .22s ease; }
|
||||||
#sidebar, #rightrail { transition: transform .22s ease; }
|
#sidebar { transition: transform .22s ease; }
|
||||||
|
|
||||||
/* Desktop collapse — shrink the grid columns */
|
/* Desktop collapse — shrink the sidebar column */
|
||||||
#shell.sidebar-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr var(--rail-w); }
|
#shell.sidebar-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr; }
|
||||||
#shell.sidebar-collapsed.rail-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr var(--rail-w-min); }
|
|
||||||
#shell.rail-collapsed { grid-template-columns: var(--sidebar-w) 1fr var(--rail-w-min); }
|
|
||||||
#shell.sidebar-collapsed #sidebar { overflow: hidden; border-right: none; }
|
#shell.sidebar-collapsed #sidebar { overflow: hidden; border-right: none; }
|
||||||
/* Hide chat body when the rail is collapsed so the thin strip stays clean */
|
|
||||||
#shell.rail-collapsed .rail-chat { display: none; }
|
|
||||||
|
|
||||||
/* Topbar toggle buttons */
|
/* Topbar toggle buttons */
|
||||||
.chrome-toggle {
|
.chrome-toggle {
|
||||||
@@ -516,13 +508,11 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
|||||||
/* ---- Narrow / mobile / vertical: off-canvas drawers, single-column main ---- */
|
/* ---- Narrow / mobile / vertical: off-canvas drawers, single-column main ---- */
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
#shell,
|
#shell,
|
||||||
#shell.sidebar-collapsed,
|
#shell.sidebar-collapsed {
|
||||||
#shell.rail-collapsed,
|
|
||||||
#shell.sidebar-collapsed.rail-collapsed {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-areas: "topbar" "main";
|
grid-template-areas: "topbar" "main";
|
||||||
}
|
}
|
||||||
#sidebar, #rightrail {
|
#sidebar {
|
||||||
position: fixed; top: var(--topbar-h); bottom: 0; z-index: 50;
|
position: fixed; top: var(--topbar-h); bottom: 0; z-index: 50;
|
||||||
}
|
}
|
||||||
#sidebar { left: 0; width: min(82vw, 300px); transform: translateX(-100%); border-right: 1px solid var(--border); }
|
#sidebar { left: 0; width: min(82vw, 300px); transform: translateX(-100%); border-right: 1px solid var(--border); }
|
||||||
@@ -562,9 +552,47 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
|||||||
}
|
}
|
||||||
.dv-tile .dv-nm { font-family: var(--font-ui); font-size: 13px; color: var(--text); }
|
.dv-tile .dv-nm { font-family: var(--font-ui); font-size: 13px; color: var(--text); }
|
||||||
.dv-tile .dv-ip { font-family: var(--font-mono); font-size: 12px; color: var(--muted); }
|
.dv-tile .dv-ip { font-family: var(--font-mono); font-size: 12px; color: var(--muted); }
|
||||||
|
.dv-tile .dv-mac { font-family: var(--font-mono); font-size: 10px; color: var(--muted); opacity: .6; letter-spacing: .02em; }
|
||||||
|
.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; }
|
||||||
|
/* touch devices have no hover — keep the ✎ edit button always visible there */
|
||||||
|
@media (hover: none) { .dv-edit-btn { opacity: .85; } }
|
||||||
|
/* Little Blue service-tile edit affordance */
|
||||||
|
.lb-tile-wrap { position: relative; }
|
||||||
|
.lb-edit-btn { position: absolute; top: 5px; right: 5px; z-index: 5; background: var(--panel-2); border: 1px solid var(--border); color: var(--muted); border-radius: 3px; font-size: 11px; line-height: 1; padding: 2px 5px; cursor: pointer; opacity: 0; }
|
||||||
|
.lb-tile-wrap:hover .lb-edit-btn { opacity: 1; }
|
||||||
|
.lb-edit-btn:hover { color: var(--accent); border-color: var(--accent-dim); }
|
||||||
|
@media (hover: none) { .lb-edit-btn { opacity: .85; } }
|
||||||
|
.lb-edit { display: flex; flex-direction: column; gap: 4px; padding: 8px; }
|
||||||
|
.lb-edit .dv-edit-name, .lb-edit .dv-edit-grp { width: 100%; margin: 0; }
|
||||||
|
.lb-edit-btns { display: flex; gap: 4px; margin-top: 2px; }
|
||||||
|
.lb-edit-btns button { font-size: 11px; padding: 2px 8px; }
|
||||||
|
.dv-edit-btn:hover { color: var(--accent); border-color: var(--accent-dim); }
|
||||||
|
.dv-tile .dv-edit-name, .dv-tile .dv-edit-grp { margin: 2px 0; width: 100%; }
|
||||||
|
.dv-tile .dv-add, .dv-tile .dv-ignore, .dv-tile .ghost { margin-top: 4px; margin-right: 4px; font-size: 11px; padding: 2px 8px; }
|
||||||
|
.dv-addtoggle { margin-left: auto; font-size: 11px; padding: 2px 8px; white-space: nowrap; }
|
||||||
|
.dv-scanbtn { font-size: 11px; padding: 2px 8px; white-space: nowrap; margin-left: 6px; }
|
||||||
|
.dv-scanbtn:disabled { opacity: .6; cursor: default; }
|
||||||
|
.dv-addform { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 8px 0; padding: 8px 10px; border: 1px solid var(--accent-dim); border-radius: 6px; background: var(--accent-soft); }
|
||||||
|
.dv-addform .dv-edit-name { flex: 1 1 9rem; }
|
||||||
.dv-tile .dv-vendor { font-family: var(--font-ui); font-size: 11px; color: var(--muted); opacity: .7; }
|
.dv-tile .dv-vendor { font-family: var(--font-ui); font-size: 11px; color: var(--muted); opacity: .7; }
|
||||||
.dv-tile.flag { border-color: var(--bad); background: #1a1012; }
|
.dv-tile.flag { border-color: var(--bad); background: #1a1012; }
|
||||||
.dv-tile.flag .dv-nm { color: var(--bad); }
|
.dv-tile.flag .dv-nm { color: var(--bad); }
|
||||||
|
.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) ===== */
|
/* ===== Discovered services + scan (Plan: DB-backed registry) ===== */
|
||||||
.lb-scan { background: var(--accent-soft); border: 1px solid var(--accent-dim); color: var(--accent);
|
.lb-scan { background: var(--accent-soft); border: 1px solid var(--accent-dim); color: var(--accent);
|
||||||
@@ -588,18 +616,38 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
|||||||
background: rgba(10,10,14,.88); border: 1px solid var(--border); border-radius: 6px; padding: 3px 5px; }
|
background: rgba(10,10,14,.88); border: 1px solid var(--border); border-radius: 6px; padding: 3px 5px; }
|
||||||
#sv-cards.editing .sv-card-edit { display: flex; }
|
#sv-cards.editing .sv-card-edit { display: flex; }
|
||||||
#sv-cards.editing .sv-card { outline: 1px dashed var(--accent-dim); }
|
#sv-cards.editing .sv-card { outline: 1px dashed var(--accent-dim); }
|
||||||
.sv-grip { cursor: grab; color: var(--muted); font-size: 14px; padding: 0 2px; user-select: none; }
|
.sv-grip { cursor: grab; color: var(--muted); font-size: 14px; padding: 0 2px; user-select: none; touch-action: none; }
|
||||||
.sv-grip:active { cursor: grabbing; }
|
.sv-grip:active { cursor: grabbing; }
|
||||||
.sv-ed-sizes { display: flex; gap: 2px; }
|
.sv-ed-free, .sv-ed-hide { width: 20px; height: 20px; border: 1px solid var(--border); background: transparent;
|
||||||
.sv-ed-size, .sv-ed-hide { width: 20px; height: 20px; border: 1px solid var(--border); background: transparent;
|
border-radius: 3px; font-size: 12px; cursor: pointer; padding: 0; line-height: 1; }
|
||||||
border-radius: 3px; font-size: 11px; cursor: pointer; padding: 0; line-height: 1; }
|
.sv-ed-free { color: var(--muted); }
|
||||||
.sv-ed-size { color: var(--muted); }
|
.sv-ed-free:hover { color: var(--accent); border-color: var(--accent-dim); }
|
||||||
.sv-ed-size:hover { color: var(--accent); border-color: var(--accent-dim); }
|
.sv-card.free .sv-ed-free { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||||
.sv-card[data-size="s"] .sv-ed-size[data-s="s"],
|
.sv-ed-hide { color: var(--bad); }
|
||||||
.sv-card[data-size="m"] .sv-ed-size[data-s="m"],
|
|
||||||
.sv-card[data-size="l"] .sv-ed-size[data-s="l"] { background: var(--accent-dim); color: var(--text); border-color: var(--accent); }
|
|
||||||
.sv-ed-hide { color: var(--bad); font-size: 12px; }
|
|
||||||
.sv-ed-hide:hover { background: var(--bad); color: var(--bg); }
|
.sv-ed-hide:hover { background: var(--bad); color: var(--bg); }
|
||||||
|
/* resize handle — bottom-right corner, edit mode only */
|
||||||
|
.sv-resize { display: none; position: absolute; right: 3px; bottom: 3px; width: 16px; height: 16px; z-index: 4;
|
||||||
|
cursor: nwse-resize; touch-action: none; border-radius: 0 0 9px 0;
|
||||||
|
background: linear-gradient(135deg, transparent 45%, var(--muted) 45% 55%, transparent 55%,
|
||||||
|
transparent 62%, var(--muted) 62% 72%, transparent 72%); opacity: .6; }
|
||||||
|
.sv-resize:hover { opacity: 1; }
|
||||||
|
#sv-cards.editing .sv-resize { display: block; }
|
||||||
|
.sv-tray-hint { font-family: var(--font-ui); font-size: 11px; margin-left: 6px; }
|
||||||
|
|
||||||
|
/* decorative cards (blank spacer / blackflame) — no chrome padding, full-bleed body */
|
||||||
|
.sv-card-decor { padding: 0; overflow: hidden; }
|
||||||
|
.sv-card-decor .sv-card-body { position: absolute; inset: 0; padding: 0; overflow: hidden; }
|
||||||
|
.sv-card-decor:hover { transform: none; }
|
||||||
|
.sv-blank { width: 100%; height: 100%;
|
||||||
|
background-image: repeating-linear-gradient(45deg, rgba(255,255,255,.025) 0 9px, transparent 9px 18px); }
|
||||||
|
.sv-flame-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; pointer-events: none; }
|
||||||
|
.sv-flame-crest { position: absolute; top: 46%; left: 50%; width: 22%; aspect-ratio: 1; transform: translate(-50%,-50%);
|
||||||
|
border-radius: 50%; pointer-events: none;
|
||||||
|
background: radial-gradient(circle at 50% 45%, #000 38%, #0a0a10 60%, transparent 72%);
|
||||||
|
box-shadow: 0 0 26px 10px #000, 0 0 60px 18px rgba(255,79,46,.13); }
|
||||||
|
.sv-flame-label { position: absolute; left: 0; right: 0; bottom: 14px; text-align: center; pointer-events: none;
|
||||||
|
font-family: var(--font-display); letter-spacing: .42em; text-indent: .42em; text-transform: uppercase;
|
||||||
|
font-size: 14px; color: #f4ece9; text-shadow: 0 0 18px rgba(255,79,46,.55), 0 0 4px #000; }
|
||||||
|
|
||||||
#sv-tray { flex-wrap: wrap; align-items: center; gap: 8px; margin: 2px 0 18px; padding: 10px 12px;
|
#sv-tray { flex-wrap: wrap; align-items: center; gap: 8px; margin: 2px 0 18px; padding: 10px 12px;
|
||||||
border: 1px dashed var(--border); border-radius: 8px; }
|
border: 1px dashed var(--border); border-radius: 8px; }
|
||||||
@@ -607,5 +655,147 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
|||||||
.sv-tray-chip { background: var(--panel-2); border: 1px solid var(--border); color: var(--text); border-radius: 14px;
|
.sv-tray-chip { background: var(--panel-2); border: 1px solid var(--border); color: var(--text); border-radius: 14px;
|
||||||
padding: 4px 10px; font-family: var(--font-ui); font-size: 12px; cursor: pointer; }
|
padding: 4px 10px; font-family: var(--font-ui); font-size: 12px; cursor: pointer; }
|
||||||
.sv-tray-chip:hover { border-color: var(--accent); color: var(--accent); }
|
.sv-tray-chip:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.sv-link { color: var(--muted); text-decoration: none; font-family: var(--font-ui); font-size: 11px; }
|
||||||
|
.sv-link:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ---- Speedtest page ---- */
|
||||||
|
.st-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||||
|
.st-actions { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.st-ranges { display: inline-flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; }
|
||||||
|
.st-range { background: transparent; border: 0; color: var(--muted); padding: 5px 12px; font-family: var(--font-ui); font-size: 12px; cursor: pointer; }
|
||||||
|
.st-range.on { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.st-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin-bottom: 8px; }
|
||||||
|
.st-kpi { border: 1px solid var(--border); border-left: 3px solid var(--border); border-radius: 8px; padding: 10px 12px; background: var(--panel); }
|
||||||
|
.st-kpi.down { border-left-color: var(--accent); }
|
||||||
|
.st-kpi.up { border-left-color: var(--ok); }
|
||||||
|
.st-kpi.bad { border-left-color: var(--bad); }
|
||||||
|
.st-kpi-l { display: block; font-family: var(--font-ui); font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .08em; }
|
||||||
|
.st-kpi-v { display: block; font-family: var(--font-mono); font-size: 26px; color: var(--text); line-height: 1.1; }
|
||||||
|
.st-kpi-s { display: block; font-size: 11px; color: var(--muted); }
|
||||||
|
.st-meta { font-size: 12px; margin: 4px 0 10px; }
|
||||||
|
.st-link { color: var(--accent); text-decoration: none; }
|
||||||
|
.st-warn { color: var(--warn); }
|
||||||
|
.st-fail td { color: var(--bad); opacity: .8; }
|
||||||
|
.st-stats { display: flex; flex-wrap: wrap; gap: 16px; font-family: var(--font-mono); font-size: 12px; color: var(--muted); margin-bottom: 18px; }
|
||||||
|
.st-h2 { font-family: var(--font-display); font-size: 14px; letter-spacing: .12em; text-transform: uppercase; color: var(--text); margin: 18px 0 6px; }
|
||||||
|
.st-chart { width: 100%; height: 180px; display: block; }
|
||||||
|
.st-legend { display: flex; gap: 16px; margin-bottom: 4px; }
|
||||||
|
.st-leg { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--muted); }
|
||||||
|
.st-leg i { width: 14px; height: 3px; border-radius: 2px; display: inline-block; }
|
||||||
|
.st-card { margin: 16px 0; }
|
||||||
|
.st-form { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; }
|
||||||
|
.st-lbl { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); }
|
||||||
|
.st-table-wrap { overflow-x: auto; }
|
||||||
|
.st-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 12px; }
|
||||||
|
.st-table th { text-align: left; color: var(--muted); font-weight: 400; font-family: var(--font-ui); border-bottom: 1px solid var(--border); padding: 6px 10px; position: sticky; top: 0; background: var(--bg); }
|
||||||
|
.st-table td { padding: 5px 10px; border-bottom: 1px solid #ffffff08; }
|
||||||
|
.st-table td.num { text-align: right; }
|
||||||
|
|
||||||
|
/* ---- Theming panel ---- */
|
||||||
|
.theme-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px 18px; margin-bottom: 14px; }
|
||||||
|
.theme-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 12px; color: var(--muted); }
|
||||||
|
.theme-row input[type=color] { width: 40px; height: 24px; padding: 0; border: 1px solid var(--border); border-radius: 4px; background: none; cursor: pointer; }
|
||||||
|
.theme-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* Storage card (sv-cluster container) — warn dot + capacity meter + subheader */
|
||||||
|
.sv-cluster .status-warn .dot { background: var(--warn); box-shadow: 0 0 7px var(--warn); }
|
||||||
|
.sv-cluster .ok { color: var(--ok); }
|
||||||
|
.sv-cluster .bad { color: var(--bad); }
|
||||||
|
.sv-cluster .st-meter { height: 3px; background: var(--accent-soft); border-radius: 2px; margin: 3px 0 9px; overflow: hidden; }
|
||||||
|
.sv-cluster .st-fill { height: 100%; border-radius: 2px; }
|
||||||
|
.sv-cluster .st-fill.ok { background: var(--ok); }
|
||||||
|
.sv-cluster .st-fill.warn { background: var(--warn); }
|
||||||
|
.sv-cluster .st-fill.bad { background: var(--bad); }
|
||||||
|
.sv-cluster .sv-subhdr { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; margin: 11px 0 5px; font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
.dv-icon { width: 30px; height: 30px; object-fit: contain; opacity: .95; }
|
||||||
|
.dv-icon-fb { width: 30px; height: 30px; display: grid; place-items: center; font-size: 14px; color: var(--text); background: var(--panel-2, #1b1b22); border-radius: 4px; }
|
||||||
|
.dv-seen { font-size: 11px; color: var(--muted, #8a8a94); }
|
||||||
|
.icon-picker { border: 1px solid var(--border, #2a2a36); border-radius: 6px; padding: 6px; margin-top: 6px; max-width: 320px; }
|
||||||
|
.ip-tabs { display: flex; gap: 4px; margin-bottom: 6px; }
|
||||||
|
.ip-tab.active { color: var(--accent, #ff4f2e); border-bottom: 1px solid var(--accent, #ff4f2e); }
|
||||||
|
.ip-grid { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.ip-icon { width: 40px; height: 40px; display: grid; place-items: center; background: transparent; border: 1px solid var(--border, #2a2a36); border-radius: 4px; cursor: pointer; }
|
||||||
|
.ip-icon img { width: 28px; height: 28px; object-fit: contain; }
|
||||||
|
.ip-set-hd, .isp-hd { font-size: 12px; margin: 6px 0 3px; text-transform: capitalize; }
|
||||||
|
.isp-upload { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||||
|
|
||||||
|
/* ---- Dross floating chat ---- */
|
||||||
|
:root{ --dross:#a86adf; --dross-dim:#5a2e8a; --dross-soft:#1e1030; --dross-glow:#c79bff; }
|
||||||
|
.dross-orb{position:relative;border-radius:50%;display:grid;place-items:center;overflow:hidden;flex:none;
|
||||||
|
background:radial-gradient(circle at 38% 30%, #2a1640, #1a0f2a 70%, #120a1e);
|
||||||
|
box-shadow:0 0 0 1px #ffffff12, 0 6px 22px -6px #000, 0 0 26px -4px var(--dross-dim)}
|
||||||
|
.dross-fab{position:fixed;right:20px;bottom:20px;z-index:40;cursor:grab;touch-action:none;animation:dross-bob 5s ease-in-out infinite}
|
||||||
|
.dross-fab:active{cursor:grabbing}
|
||||||
|
@keyframes dross-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
|
||||||
|
.dross-fab .dross-orb{width:60px;height:60px}
|
||||||
|
.dross-ping{position:absolute;right:-2px;top:-2px;width:17px;height:17px;border-radius:50%;background:var(--accent);
|
||||||
|
color:#0a0a0e;font-size:10px;display:grid;place-items:center;box-shadow:0 0 0 2px var(--bg);z-index:2;font-family:var(--font-ui)}
|
||||||
|
.av-eye{width:54%;height:54%;border-radius:50%;background:radial-gradient(circle at 50% 40%, #2a1c3a, #140b20);display:grid;place-items:center;box-shadow:inset 0 0 10px #000}
|
||||||
|
.av-pupil{width:44%;height:44%;border-radius:50%;position:relative;background:radial-gradient(circle at 38% 32%, #fff, var(--dross-glow) 50%, var(--dross) 100%);box-shadow:0 0 10px var(--dross-glow);animation:dross-look 7s ease-in-out infinite}
|
||||||
|
.av-pupil::after{content:"";position:absolute;right:14%;bottom:18%;width:26%;height:26%;border-radius:50%;background:#fff;opacity:.8}
|
||||||
|
@keyframes dross-look{0%,45%{transform:translate(0,0)}58%{transform:translate(3px,-2px)}72%{transform:translate(-2px,1px)}88%,100%{transform:translate(0,0)}}
|
||||||
|
.b-core{position:absolute;inset:13%;border-radius:50%;filter:blur(3px);animation:dross-spin 7s linear infinite;
|
||||||
|
background:conic-gradient(from 0deg, var(--dross-dim), var(--dross-glow), var(--dross), var(--dross-soft), var(--dross-dim))}
|
||||||
|
.b-bright{position:absolute;inset:32%;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,transparent 75%);animation:dross-pulse 3s ease-in-out infinite}
|
||||||
|
@keyframes dross-spin{to{transform:rotate(360deg)}}
|
||||||
|
@keyframes dross-pulse{0%,100%{opacity:.55;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
|
||||||
|
.d-core{width:22%;height:22%;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,var(--dross));box-shadow:0 0 12px var(--dross-glow)}
|
||||||
|
.d-ring{position:absolute;inset:0;animation:dross-spin 5s linear infinite}
|
||||||
|
.d-ring.r2{animation-duration:8s;animation-direction:reverse}
|
||||||
|
.d-mote{position:absolute;top:11%;left:50%;width:11%;height:11%;margin-left:-5.5%;border-radius:50%;background:var(--dross-glow);box-shadow:0 0 8px var(--dross-glow)}
|
||||||
|
.d-ring.r2 .d-mote{top:auto;bottom:14%;background:var(--dross);width:8%;height:8%}
|
||||||
|
.dross-panel{position:fixed;right:20px;bottom:20px;width:340px;max-width:calc(100vw - 24px);height:480px;max-height:calc(100vh - 24px);
|
||||||
|
display:none;flex-direction:column;z-index:41;border:1px solid var(--dross-dim);border-radius:16px;overflow:hidden;
|
||||||
|
background:linear-gradient(180deg, rgba(30,16,48,.6), rgba(20,20,28,.96) 22%);
|
||||||
|
box-shadow:0 24px 70px -18px #000, 0 0 0 1px #00000060, 0 0 40px -16px var(--dross-dim);backdrop-filter:blur(6px)}
|
||||||
|
.dross-panel.open{display:flex}
|
||||||
|
.dross-hd{display:flex;align-items:center;gap:10px;padding:11px 12px;cursor:grab;touch-action:none;
|
||||||
|
background:linear-gradient(180deg, var(--dross-soft), transparent);border-bottom:1px solid var(--border)}
|
||||||
|
.dross-hd .dross-orb{width:30px;height:30px}
|
||||||
|
.dross-who{font-family:var(--font-display);letter-spacing:.16em;text-transform:uppercase;font-size:13px;color:#efe9f6;flex:1}
|
||||||
|
.dross-who small{display:block;font-family:var(--font-mono);letter-spacing:0;text-transform:none;font-size:10px;color:var(--dross-glow);opacity:.85}
|
||||||
|
.dross-x{background:none;border:0;color:var(--muted);font-size:18px;cursor:pointer;padding:4px 6px}
|
||||||
|
.dross-x:hover{color:var(--text)}
|
||||||
|
.dross-log{flex:1;overflow:auto;padding:12px 12px;display:flex;flex-direction:column;gap:10px}
|
||||||
|
.dross-inwrap{padding:10px;border-top:1px solid var(--border);background:#0d0a12;display:flex;flex-direction:column;gap:9px}
|
||||||
|
.dross-inwrap textarea{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:10px;padding:10px 12px;color:var(--text);font-family:var(--font-mono);font-size:13px;resize:none;height:46px;max-height:96px}
|
||||||
|
.dross-btnrow{display:flex;gap:10px}
|
||||||
|
.dross-mic{flex:1;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:var(--dross-soft);color:var(--dross-glow);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:9px;font-family:var(--font-ui);font-size:13px}
|
||||||
|
.dross-mic[disabled]{opacity:.5;cursor:not-allowed}
|
||||||
|
.dross-mic.rec{background:#3a1010;border-color:var(--accent);color:#fff;animation:dross-rec 1.2s infinite}
|
||||||
|
@keyframes dross-rec{0%,100%{box-shadow:0 0 0 0 rgba(255,79,46,.5)}50%{box-shadow:0 0 0 8px rgba(255,79,46,0)}}
|
||||||
|
.dross-send{width:64px;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:linear-gradient(180deg,var(--dross),var(--dross-dim));color:#fff;cursor:pointer;display:grid;place-items:center}
|
||||||
|
.dross-collapse{display:flex;align-items:center;justify-content:center;gap:8px;height:34px;cursor:pointer;color:var(--muted);
|
||||||
|
font-family:var(--font-ui);font-size:11px;letter-spacing:.12em;text-transform:uppercase;background:#0b0810;border-top:1px solid var(--border)}
|
||||||
|
.dross-collapse:hover{color:var(--dross-glow)}
|
||||||
|
.dross-collapse .grip{width:42px;height:4px;border-radius:3px;background:var(--border)}
|
||||||
|
.dross-pick{display:flex;gap:12px;margin-bottom:12px;flex-wrap:wrap}
|
||||||
|
.dross-avopt{display:flex;flex-direction:column;align-items:center;gap:6px;padding:10px 14px;border:1px solid var(--border);border-radius:12px;background:var(--panel);color:var(--muted);cursor:pointer;font-size:11px}
|
||||||
|
.dross-avopt.on{border-color:var(--dross);color:var(--dross-glow);background:var(--dross-soft)}
|
||||||
|
.dross-persona{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:8px;padding:10px;color:var(--text);font-family:var(--font-mono);font-size:12px;resize:vertical;margin:10px 0}
|
||||||
|
.dross-clips{display:flex;flex-direction:column;gap:2px}
|
||||||
|
.dross-clip{display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid #ffffff08}
|
||||||
|
.dross-clip-txt{flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.dross-clip audio{display:none}
|
||||||
|
|
||||||
|
/* voice 2.14.1: amplitude meter. .metered kills the keyframe pulse — CSS animations
|
||||||
|
override normal declarations, so the old dross-rec box-shadow was masking the meter. */
|
||||||
|
.dross-mic.rec.metered{animation:none;position:relative;
|
||||||
|
box-shadow:0 0 0 calc(2px + 22px * var(--voicelevel, 0)) rgba(255,79,46,calc(0.15 + 0.5 * var(--voicelevel, 0)));
|
||||||
|
transition:box-shadow 70ms linear}
|
||||||
|
.dross-mic.rec.metered svg{transform:scale(calc(1 + 0.5 * var(--voicelevel, 0)));transition:transform 70ms linear}
|
||||||
|
.dross-inwrap textarea{overflow-y:auto;max-height:120px;transition:height 120ms ease}
|
||||||
|
.dross-inwrap textarea.flash{border-color:var(--dross-glow);box-shadow:0 0 0 2px var(--dross-soft)}
|
||||||
|
|
||||||
|
/* dross improvements (2.14) */
|
||||||
|
.imp-row{display:flex;align-items:center;gap:12px;padding:9px 0;border-bottom:1px solid var(--border)}
|
||||||
|
.imp-row:last-child{border-bottom:0}
|
||||||
|
.imp-status{font-family:var(--font-mono);font-size:11px;white-space:nowrap;min-width:96px}
|
||||||
|
.imp-status.s-active{color:var(--ok)}.imp-status.s-pending{color:var(--warn)}.imp-status.s-rolled_back{color:var(--muted)}.imp-status.s-rejected{color:var(--bad)}
|
||||||
|
.imp-main{flex:1;min-width:0}
|
||||||
|
.imp-actions{display:flex;gap:6px}
|
||||||
|
button.sm{padding:4px 10px;font-size:12px}
|
||||||
|
button.danger{border-color:var(--bad);color:var(--bad)}
|
||||||
|
|||||||
77
public/theme.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// Theming: a small map of palette-var overrides persisted in app_settings and
|
||||||
|
// applied to :root on boot. The whole UI is CSS-custom-property driven, so
|
||||||
|
// setting these vars recolours everything live. (Canvas-drawn colours — the
|
||||||
|
// blackflame card — and a few inline rgba() literals don't follow the theme.)
|
||||||
|
import { api } from './api.js';
|
||||||
|
|
||||||
|
export const THEME_VARS = [
|
||||||
|
{ key: 'accent', css: '--accent', label: 'Accent (flame)' },
|
||||||
|
{ key: 'accent-dim', css: '--accent-dim', label: 'Accent · dim' },
|
||||||
|
{ key: 'accent-soft', css: '--accent-soft', label: 'Accent · soft' },
|
||||||
|
{ key: 'bg', css: '--bg', label: 'Background' },
|
||||||
|
{ key: 'panel', css: '--panel', label: 'Panel' },
|
||||||
|
{ key: 'panel-2', css: '--panel-2', label: 'Panel · raised' },
|
||||||
|
{ key: 'border', css: '--border', label: 'Border' },
|
||||||
|
{ key: 'text', css: '--text', label: 'Text' },
|
||||||
|
{ key: 'muted', css: '--muted', label: 'Muted text' },
|
||||||
|
{ key: 'ok', css: '--ok', label: 'OK / good' },
|
||||||
|
{ key: 'warn', css: '--warn', label: 'Warning' },
|
||||||
|
{ key: 'bad', css: '--bad', label: 'Bad / error' }
|
||||||
|
];
|
||||||
|
const BY_KEY = Object.fromEntries(THEME_VARS.map(v => [v.key, v]));
|
||||||
|
|
||||||
|
// Named alternates. Blackflame = {} (clear overrides → CSS defaults).
|
||||||
|
export const PRESETS = {
|
||||||
|
Blackflame: {},
|
||||||
|
Ember: { accent: '#ff7a1a', 'accent-dim': '#8a3a10', 'accent-soft': '#3a1a0a', bg: '#0c0907', panel: '#171008', 'panel-2': '#20160c' },
|
||||||
|
Frost: { accent: '#4aa3ff', 'accent-dim': '#1e5a8a', 'accent-soft': '#0e2230', bg: '#070a0e', panel: '#0f141c', 'panel-2': '#161d28', ok: '#5fb0c4' },
|
||||||
|
Verdant: { accent: '#5fc46a', 'accent-dim': '#2a6a30', 'accent-soft': '#10240f', bg: '#070b08', panel: '#0f160f', 'panel-2': '#161f16' },
|
||||||
|
Amethyst: { accent: '#a86adf', 'accent-dim': '#5a2e8a', 'accent-soft': '#1e1030', bg: '#0a0810', panel: '#140f1c', 'panel-2': '#1c1528' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export function applyTheme(vars = {}) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const [k, val] of Object.entries(vars)) {
|
||||||
|
const def = BY_KEY[k];
|
||||||
|
if (def && val) root.style.setProperty(def.css, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTheme() {
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const v of THEME_VARS) root.style.removeProperty(v.css);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current effective value of a var (override or CSS default), normalised to #rrggbb.
|
||||||
|
export function effectiveHex(key) {
|
||||||
|
const def = BY_KEY[key];
|
||||||
|
if (!def) return '#000000';
|
||||||
|
const raw = getComputedStyle(document.documentElement).getPropertyValue(def.css).trim();
|
||||||
|
return toHex6(raw) || '#000000';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toHex6(v) {
|
||||||
|
if (!v) return '';
|
||||||
|
v = v.trim();
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(v)) return v.toLowerCase();
|
||||||
|
if (/^#[0-9a-fA-F]{8}$/.test(v)) return v.slice(0, 7).toLowerCase(); // drop alpha
|
||||||
|
if (/^#[0-9a-fA-F]{3}$/.test(v)) return '#' + v.slice(1).split('').map(c => c + c).join('').toLowerCase();
|
||||||
|
const m = v.match(/rgba?\(\s*(\d+)\D+(\d+)\D+(\d+)/i);
|
||||||
|
if (m) return '#' + [m[1], m[2], m[3]].map(n => (+n).toString(16).padStart(2, '0')).join('');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = {};
|
||||||
|
export function currentTheme() { return { ...current }; }
|
||||||
|
|
||||||
|
export async function loadTheme() {
|
||||||
|
try { current = (await api.get('/api/theme')) || {}; applyTheme(current); }
|
||||||
|
catch { /* defaults */ }
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTheme(vars) {
|
||||||
|
current = (await api.put('/api/theme', vars)) || {};
|
||||||
|
clearTheme(); applyTheme(current);
|
||||||
|
return current;
|
||||||
|
}
|
||||||
50
public/views/cards/backups.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// public/views/cards/backups.js — offsite DR backup status (Core-4 -> Farm/Won).
|
||||||
|
// Fed by /usr/local/bin/offsite-backup.sh which POSTs each run to /api/backups.
|
||||||
|
import { el, mount } from '../../dom.js';
|
||||||
|
import { api } from '../../api.js';
|
||||||
|
|
||||||
|
let body, timer;
|
||||||
|
|
||||||
|
const gb = b => (b == null ? '–'
|
||||||
|
: b >= 1e12 ? (b / 1e12).toFixed(1) + 'T'
|
||||||
|
: b >= 1e9 ? (b / 1e9).toFixed(1) + 'G'
|
||||||
|
: Math.round(b / 1e6) + 'M');
|
||||||
|
function ago(ts) {
|
||||||
|
const s = Math.max(0, (Date.now() - Date.parse(ts)) / 1000);
|
||||||
|
if (s < 3600) return Math.floor(s / 60) + 'm';
|
||||||
|
if (s < 86400) return Math.floor(s / 3600) + 'h';
|
||||||
|
return Math.floor(s / 86400) + 'd';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!body) return;
|
||||||
|
try {
|
||||||
|
const d = await api.get('/api/backups');
|
||||||
|
const r = d.latest;
|
||||||
|
if (!r) { mount(body, el('span', { class: 'muted' }, 'No offsite backups yet.')); return; }
|
||||||
|
const stale = (Date.now() - Date.parse(r.ran_at)) > 8 * 86400000; // >8d overdue
|
||||||
|
const status = (!r.ok || stale) ? 'bad' : 'ok';
|
||||||
|
const kids = [];
|
||||||
|
kids.push(el('div', { class: 'sv-row' },
|
||||||
|
el('span', { class: 'k' }, 'Last run'),
|
||||||
|
el('span', { class: 'cl-badge ' + status }, r.ok ? ago(r.ran_at) + ' ago' : 'FAILED')));
|
||||||
|
kids.push(el('div', { class: 'sv-row' },
|
||||||
|
el('span', { class: 'k' }, 'Pushed to Farm'), el('span', {}, gb(r.total_bytes))));
|
||||||
|
for (const g of (r.guests || []))
|
||||||
|
kids.push(el('div', { class: 'sv-row' },
|
||||||
|
el('span', { class: 'k' }, 'CT ' + g.vmid + ' ' + g.name),
|
||||||
|
el('span', { class: 'muted' }, gb(g.bytes))));
|
||||||
|
kids.push(el('div', { class: 'sv-row' },
|
||||||
|
el('span', { class: 'k' }, 'Farm free'), el('span', {}, gb(r.won_free_bytes))));
|
||||||
|
kids.push(el('div', { class: 'sv-row' },
|
||||||
|
el('span', { class: 'k' }, 'Schedule'), el('span', { class: 'muted' }, d.schedule || 'weekly')));
|
||||||
|
mount(body, el('div', { class: 'sv-cluster' }, ...kids));
|
||||||
|
} catch { mount(body, el('span', { class: 'muted' }, 'Backups unavailable')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: 'backups', title: 'Backups · offsite', size: 's',
|
||||||
|
mount(e) { body = e; load(); },
|
||||||
|
start() { timer = setInterval(load, 60000); },
|
||||||
|
stop() { clearInterval(timer); body = null; }
|
||||||
|
};
|
||||||
114
public/views/cards/blackflame.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// Decorative blackflame card — the animated centrepiece (dark heart, crimson
|
||||||
|
// licks, rising embers). Canvas particle flame ported from the approved mock.
|
||||||
|
// Instanceable via the factory; stop() tears down the rAF loop + observer.
|
||||||
|
import { el } from '../../dom.js';
|
||||||
|
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
|
||||||
|
function startFlame(canvas) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let W = 0, H = 0, DPR = 1, raf = 0, t = 0;
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
DPR = Math.min(2, window.devicePixelRatio || 1);
|
||||||
|
const r = canvas.getBoundingClientRect();
|
||||||
|
W = r.width; H = r.height;
|
||||||
|
canvas.width = Math.max(1, Math.round(W * DPR));
|
||||||
|
canvas.height = Math.max(1, Math.round(H * DPR));
|
||||||
|
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tongues = Array.from({ length: 5 }, (_, i) => ({
|
||||||
|
phase: Math.random() * TAU, speed: 0.6 + Math.random() * 0.5,
|
||||||
|
x: 0.5 + (i - 2) * 0.085, sway: 0.03 + Math.random() * 0.04, w: 0.34 - Math.abs(i - 2) * 0.05
|
||||||
|
}));
|
||||||
|
const spawn = () => ({
|
||||||
|
x: 0.5 + (Math.random() - 0.5) * 0.5, y: 1 + Math.random() * 0.2,
|
||||||
|
vy: 0.0016 + Math.random() * 0.0030, vx: (Math.random() - 0.5) * 0.0012,
|
||||||
|
r: 0.6 + Math.random() * 1.8, life: 0, max: 120 + Math.random() * 120, hot: Math.random() < 0.5
|
||||||
|
});
|
||||||
|
const embers = Array.from({ length: 46 }, spawn);
|
||||||
|
|
||||||
|
function blob(x, y, r, c0, c1) {
|
||||||
|
if (r <= 0) return;
|
||||||
|
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||||
|
g.addColorStop(0, c0); g.addColorStop(1, c1);
|
||||||
|
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame() {
|
||||||
|
t += 0.016;
|
||||||
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
ctx.globalCompositeOperation = 'lighter';
|
||||||
|
const baseY = H * 0.92;
|
||||||
|
for (const tg of tongues) {
|
||||||
|
const cx = W * (tg.x + Math.sin(t * tg.speed + tg.phase) * tg.sway);
|
||||||
|
const height = H * (0.62 + 0.08 * Math.sin(t * 1.7 + tg.phase));
|
||||||
|
const steps = 26;
|
||||||
|
for (let s = 0; s < steps; s++) {
|
||||||
|
const f = s / steps;
|
||||||
|
const y = baseY - f * height;
|
||||||
|
const flick = Math.sin(t * 3.2 + tg.phase + f * 7) * W * 0.012 * (0.4 + f);
|
||||||
|
const x = cx + flick + Math.sin(t * tg.speed * 1.3 + f * 4) * W * tg.sway * 0.6;
|
||||||
|
const r = W * tg.w * (1 - f * 0.78) * (0.9 + 0.1 * Math.sin(t * 5 + f * 9));
|
||||||
|
const a = (1 - f) * 0.5;
|
||||||
|
if (f < 0.18) blob(x, y, r * 1.1, `rgba(60,12,6,${a * 0.7})`, 'rgba(60,12,6,0)');
|
||||||
|
else if (f < 0.62) blob(x, y, r, `rgba(255,79,46,${a * 0.5})`, 'rgba(122,39,22,0)');
|
||||||
|
else blob(x, y, r * 0.8, `rgba(255,150,90,${a * 0.5})`, 'rgba(255,79,46,0)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of embers) {
|
||||||
|
e.life++; e.y -= e.vy; e.x += e.vx + Math.sin(t * 2 + e.y * 8) * 0.0004;
|
||||||
|
if (e.y < 0.18 || e.life > e.max) Object.assign(e, spawn());
|
||||||
|
const px = e.x * W, py = e.y * H, fade = 1 - e.life / e.max;
|
||||||
|
const col = e.hot ? `rgba(255,170,110,${fade * 0.9})` : `rgba(255,79,46,${fade * 0.8})`;
|
||||||
|
ctx.shadowBlur = 8; ctx.shadowColor = '#ff4f2e';
|
||||||
|
blob(px, py, e.r * 1.6, col, 'rgba(255,79,46,0)');
|
||||||
|
}
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// carve the dark heart back in (the "black" of black-flame)
|
||||||
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
|
const cx = W * 0.5, cy = H * 0.46, cr = W * 0.20;
|
||||||
|
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, cr);
|
||||||
|
core.addColorStop(0, 'rgba(4,4,8,0.96)');
|
||||||
|
core.addColorStop(0.55, 'rgba(6,5,10,0.7)');
|
||||||
|
core.addColorStop(1, 'rgba(8,7,12,0)');
|
||||||
|
ctx.fillStyle = core; ctx.beginPath(); ctx.arc(cx, cy, cr, 0, TAU); ctx.fill();
|
||||||
|
|
||||||
|
ctx.globalCompositeOperation = 'lighter';
|
||||||
|
blob(W * 0.5, H * 0.95, W * 0.4, 'rgba(255,79,46,0.10)', 'rgba(255,79,46,0)');
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
resize();
|
||||||
|
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
|
||||||
|
ro?.observe(canvas);
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
return () => { cancelAnimationFrame(raf); ro?.disconnect(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blackflameCard(id) {
|
||||||
|
let teardown = null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'blackflame',
|
||||||
|
title: '',
|
||||||
|
decorative: true,
|
||||||
|
size: 'l',
|
||||||
|
mount(body) {
|
||||||
|
const canvas = el('canvas', { class: 'sv-flame-canvas' });
|
||||||
|
const crest = el('div', { class: 'sv-flame-crest' });
|
||||||
|
const label = el('div', { class: 'sv-flame-label' }, 'The Void');
|
||||||
|
body.append(canvas, crest, label);
|
||||||
|
// defer one frame so the canvas has a measured size
|
||||||
|
requestAnimationFrame(() => { teardown = startFlame(canvas); });
|
||||||
|
},
|
||||||
|
start() {},
|
||||||
|
stop() { teardown && teardown(); teardown = null; }
|
||||||
|
};
|
||||||
|
}
|
||||||
16
public/views/cards/blank.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// A decorative blank spacer card — deliberate empty space / grouping on the
|
||||||
|
// Sacred Valley canvas. Instanceable: each gets a unique id via the factory.
|
||||||
|
import { el } from '../../dom.js';
|
||||||
|
|
||||||
|
export function blankCard(id) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'blank',
|
||||||
|
title: '',
|
||||||
|
decorative: true,
|
||||||
|
size: 'm',
|
||||||
|
mount(body) { body.appendChild(el('div', { class: 'sv-blank' })); },
|
||||||
|
start() {},
|
||||||
|
stop() {}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// public/views/cards/speedtest.js
|
// public/views/cards/speedtest.js — at-a-glance summary; full history at #/speedtest
|
||||||
import { el, mount } from '../../dom.js';
|
import { el, mount } from '../../dom.js';
|
||||||
import { api } from '../../api.js';
|
import { api } from '../../api.js';
|
||||||
|
|
||||||
@@ -6,17 +6,21 @@ let body;
|
|||||||
async function load() {
|
async function load() {
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
try {
|
try {
|
||||||
const hist = await api.get('/api/speedtest/history');
|
const hist = (await api.get('/api/speedtest/history?limit=30')).filter(h => h.ok !== false);
|
||||||
const latest = hist[0];
|
const latest = hist[0];
|
||||||
const max = Math.max(1, ...hist.map(h => Number(h.down_mbps)));
|
const max = Math.max(1, ...hist.map(h => Number(h.down_mbps)));
|
||||||
const bars = el('div', { style: { display: 'flex', gap: '2px', alignItems: 'flex-end', height: '40px', marginTop: '8px' } },
|
const bars = el('div', { style: { display: 'flex', gap: '2px', alignItems: 'flex-end', height: '38px', marginTop: '8px' } },
|
||||||
hist.slice(0, 30).reverse().map(h =>
|
hist.slice(0, 30).reverse().map(h =>
|
||||||
el('div', { style: { flex: '1', background: 'var(--accent-dim)',
|
el('div', { style: { flex: '1', background: 'var(--accent-dim)',
|
||||||
height: (Number(h.down_mbps) / max * 100) + '%' } })));
|
height: (Number(h.down_mbps) / max * 100) + '%' } })));
|
||||||
mount(body,
|
mount(body,
|
||||||
el('div', { class: 'sv-row', style: { fontSize: '20px' } },
|
el('div', { class: 'sv-row', style: { fontSize: '20px' } },
|
||||||
el('span', { style: { fontFamily: 'var(--font-mono)' } }, latest ? `${Number(latest.down_mbps).toFixed(0)}↓ ${Number(latest.up_mbps).toFixed(0)}↑` : '—'),
|
el('span', { style: { fontFamily: 'var(--font-mono)' } },
|
||||||
|
latest ? `${Number(latest.down_mbps).toFixed(0)}↓ ${Number(latest.up_mbps).toFixed(0)}↑` : '—'),
|
||||||
el('button', { class: 'sv-run', onclick: runNow }, 'Run')),
|
el('button', { class: 'sv-run', onclick: runNow }, 'Run')),
|
||||||
|
latest ? el('div', { class: 'sv-row', style: { fontSize: '11px' } },
|
||||||
|
el('span', { class: 'k' }, `ping ${latest.ping_ms == null ? '—' : Number(latest.ping_ms).toFixed(0)} ms · jitter ${latest.jitter_ms == null ? '—' : Number(latest.jitter_ms).toFixed(1)}`),
|
||||||
|
el('a', { href: '#/speedtest', class: 'sv-link' }, 'history ↗')) : null,
|
||||||
bars);
|
bars);
|
||||||
} catch { mount(body, el('span', { class: 'muted' }, 'No speedtest data')); }
|
} catch { mount(body, el('span', { class: 'muted' }, 'No speedtest data')); }
|
||||||
}
|
}
|
||||||
|
|||||||
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,152 @@
|
|||||||
// Network Devices band — IoT / personal / unknown LAN devices, kept SEPARATE
|
// Network Devices band — DB-backed (GET /api/devices). Shows IP+MAC+vendor,
|
||||||
// from Little Blue's homelab-service health band. Read-only, static source
|
// a randomized-MAC badge, and an owner "Discovered" review panel to name/promote
|
||||||
// (public/devices.json), no health probing. Live discovery comes later.
|
// newly-seen devices. Kept SEPARATE from Little Blue's homelab-service band.
|
||||||
import { el, mount } from '../dom.js';
|
import { el, mount, clear } from '../dom.js';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { resolveIcon, relativeTime, autoDefaultIcon } from './icon_util.js';
|
||||||
|
import { iconPicker } from './icon_picker.js';
|
||||||
|
|
||||||
let host;
|
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;
|
||||||
|
const ref = d.icon || autoDefaultIcon(d.grp);
|
||||||
|
const src = resolveIcon(ref);
|
||||||
|
const img = el('img', { class: 'dv-icon', src, alt: '' });
|
||||||
|
img.onerror = () => {
|
||||||
|
if (src && src.endsWith('.svg')) { img.src = src.replace(/\.svg$/, '.png'); return; }
|
||||||
|
img.replaceWith(el('div', { class: 'dv-icon-fb' }, (d.name?.[0] || '?').toUpperCase()));
|
||||||
|
};
|
||||||
|
const seen = d.present === false && d.last_seen
|
||||||
|
? el('span', { class: 'dv-seen' }, 'seen ' + relativeTime(d.last_seen)) : null;
|
||||||
|
mount(t, img,
|
||||||
|
el('span', { class: 'dv-nm' }, d.name || 'Unknown'),
|
||||||
|
el('span', { class: 'dv-ip' }, d.ip || ''),
|
||||||
|
d.mac ? el('span', { class: 'dv-mac' }, d.mac) : null,
|
||||||
|
el('span', { class: 'dv-vendor' },
|
||||||
|
(d.vendor || '') + (d.randomized ? ' · randomized' : '') + (d.present === false ? ' · absent' : '')),
|
||||||
|
seen,
|
||||||
|
d.mac ? edit : null);
|
||||||
|
}
|
||||||
|
function editMode() {
|
||||||
|
clear(t);
|
||||||
|
let chosenIcon = d.icon || null;
|
||||||
|
const nameI = el('input', { class: 'dv-edit-name', value: d.name || '' });
|
||||||
|
const grpS = el('select', { class: 'dv-edit-grp' }, ...GROUPS.map(g => el('option', { value: g }, g)));
|
||||||
|
grpS.value = d.grp || 'Flagged';
|
||||||
|
const pickerWrap = el('div', { class: 'dv-picker-wrap' });
|
||||||
|
pickerWrap.style.display = 'none';
|
||||||
|
const iconBtn = el('button', { class: 'ghost' }, 'Icon');
|
||||||
|
iconBtn.onclick = () => {
|
||||||
|
if (pickerWrap.style.display === 'none') {
|
||||||
|
clear(pickerWrap);
|
||||||
|
pickerWrap.append(iconPicker(chosenIcon, ref => { chosenIcon = ref; iconBtn.textContent = 'Icon ✓'; pickerWrap.style.display = 'none'; }));
|
||||||
|
pickerWrap.style.display = 'block';
|
||||||
|
} else pickerWrap.style.display = 'none';
|
||||||
|
};
|
||||||
|
const save = el('button', { class: 'dv-add' }, 'Save');
|
||||||
|
save.onclick = async () => {
|
||||||
|
await api.patch('/api/devices/' + d.mac, { name: nameI.value.trim() || null, grp: grpS.value, icon: chosenIcon });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const del = el('button', { class: 'ghost dv-ignore' }, 'Delete');
|
||||||
|
del.onclick = async () => { await api.del('/api/devices/' + d.mac); load(); };
|
||||||
|
const cancel = el('button', { class: 'ghost' }, 'Cancel');
|
||||||
|
cancel.onclick = view;
|
||||||
|
mount(t, el('span', { class: 'dv-mac' }, d.mac), nameI, grpS, iconBtn, save, del, cancel, pickerWrap);
|
||||||
|
}
|
||||||
|
view();
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoveredRow(d, onDone) {
|
||||||
|
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() {
|
async function load() {
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
try {
|
let data, discovered = [];
|
||||||
const res = await fetch('/devices.json');
|
try { data = await api.get('/api/devices'); } catch { mount(host, el('div', { class: 'dv-note' }, 'Devices unavailable')); return; }
|
||||||
const data = await res.json();
|
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 =>
|
const total = data.groups.reduce((n, g) => n + g.devices.length, 0);
|
||||||
el('div', { class: 'dv-section' },
|
const sections = data.groups.map(g =>
|
||||||
el('div', { class: 'dv-group' },
|
el('div', { class: 'dv-section' },
|
||||||
el('span', { class: 'gname' }, g.name),
|
el('div', { class: 'dv-group' },
|
||||||
el('span', { class: 'gcount' }, String(g.devices.length)),
|
el('span', { class: 'gname' }, g.name),
|
||||||
el('span', { class: 'line' })),
|
el('span', { class: 'gcount' }, String(g.devices.length)),
|
||||||
el('div', { class: 'dv-tiles' }, g.devices.map(d =>
|
el('span', { class: 'line' })),
|
||||||
el('div', { class: 'dv-tile' + (d.flag ? ' flag' : '') },
|
el('div', { class: 'dv-tiles' }, g.devices.map(tile))));
|
||||||
el('span', { class: 'dv-nm' }, d.name),
|
|
||||||
el('span', { class: 'dv-ip' }, d.ip),
|
const discPanel = discovered.length
|
||||||
el('span', { class: 'dv-vendor' }, d.vendor || ''))))));
|
? el('div', { class: 'dv-discovered' },
|
||||||
mount(host,
|
el('div', { class: 'dv-disc-hd' }, `Discovered · ${discovered.length} awaiting review`),
|
||||||
el('div', { class: 'dv-hd' },
|
...discovered.map(d => discoveredRow(d, load)))
|
||||||
el('div', { class: 'dv-title' }, 'Network · Devices'),
|
: null;
|
||||||
el('span', { class: 'dv-count' }, `${total} on the LAN`)),
|
|
||||||
el('div', { class: 'dv-note' }, data.note || ''),
|
const addForm = manualAddForm();
|
||||||
sections);
|
addForm.style.display = 'none';
|
||||||
} catch {
|
const addToggle = el('button', { class: 'ghost dv-addtoggle' }, '+ Manual Add');
|
||||||
mount(host, el('span', { class: 'muted' }, 'Device list unavailable'));
|
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; }
|
export function stopDevicesBand() { host = null; }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { serviceTile } from '../components/service_tile.js';
|
|||||||
import { isRemoteHost } from './service_url.js';
|
import { isRemoteHost } from './service_url.js';
|
||||||
|
|
||||||
const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' };
|
const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' };
|
||||||
|
const CATS = ['agents', 'infrastructure', 'media', 'other'];
|
||||||
let host, timer, scanning = false;
|
let host, timer, scanning = false;
|
||||||
|
|
||||||
async function promote(id) {
|
async function promote(id) {
|
||||||
@@ -17,6 +18,36 @@ function scan() {
|
|||||||
setTimeout(() => { scanning = false; load(); }, 30000); // LAN sweep ~25s
|
setTimeout(() => { scanning = false; load(); }, 30000); // LAN sweep ~25s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inline edit form for a service (name / category / url / icon) — PATCH or DELETE.
|
||||||
|
function editForm(s) {
|
||||||
|
const nameI = el('input', { class: 'dv-edit-name', value: s.name || '', placeholder: 'name' });
|
||||||
|
const catS = el('select', { class: 'dv-edit-grp' }, ...CATS.map(c => el('option', { value: c }, TITLE[c])));
|
||||||
|
catS.value = s.category || 'other';
|
||||||
|
const urlI = el('input', { class: 'dv-edit-name', value: s.url || '', placeholder: 'http://host:port' });
|
||||||
|
const iconI = el('input', { class: 'dv-edit-name', value: s.icon || '', placeholder: 'icon slug e.g. plex' });
|
||||||
|
const save = el('button', { class: 'dv-add' }, 'Save');
|
||||||
|
save.onclick = async () => {
|
||||||
|
const patch = { name: nameI.value.trim(), category: catS.value, url: urlI.value.trim() };
|
||||||
|
const ic = iconI.value.trim(); if (ic) patch.icon = ic;
|
||||||
|
try { await api.patch('/api/health/services/' + s.id, patch); load(); } catch { /* */ }
|
||||||
|
};
|
||||||
|
const del = el('button', { class: 'ghost dv-ignore' }, 'Delete');
|
||||||
|
del.onclick = async () => { try { await api.del('/api/health/services/' + s.id); load(); } catch { /* */ } };
|
||||||
|
const cancel = el('button', { class: 'ghost' }, 'Cancel');
|
||||||
|
cancel.onclick = load;
|
||||||
|
return el('div', { class: 'tile lb-edit' }, nameI, catS, urlI, iconI,
|
||||||
|
el('div', { class: 'lb-edit-btns' }, save, del, cancel));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A service tile wrapped with an ✎ edit button that swaps to the edit form.
|
||||||
|
function tileWithEdit(s, remote) {
|
||||||
|
const wrap = el('div', { class: 'lb-tile-wrap' });
|
||||||
|
const edit = el('button', { class: 'lb-edit-btn', title: 'Edit service' }, '✎');
|
||||||
|
edit.onclick = (e) => { e.preventDefault(); e.stopPropagation(); mount(wrap, editForm(s)); };
|
||||||
|
mount(wrap, serviceTile(s, remote), edit);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
// Owner-only; returns a section element or null (skipped for non-owner / none).
|
// Owner-only; returns a section element or null (skipped for non-owner / none).
|
||||||
async function discoveredSection() {
|
async function discoveredSection() {
|
||||||
let cand;
|
let cand;
|
||||||
@@ -30,8 +61,8 @@ async function discoveredSection() {
|
|||||||
el('div', { class: 'tiles' }, cand.map(c =>
|
el('div', { class: 'tiles' }, cand.map(c =>
|
||||||
el('div', { class: 'tile disc' },
|
el('div', { class: 'tile disc' },
|
||||||
el('div', { class: 'tile-main' },
|
el('div', { class: 'tile-main' },
|
||||||
el('div', { class: 'tile-nm' }, c.name),
|
el('div', { class: 'tile-nm' }, c.device || c.name),
|
||||||
el('div', { class: 'tile-host' }, c.url)),
|
el('div', { class: 'tile-host' }, c.device ? `${c.name} · ${c.url}` : c.url)),
|
||||||
el('button', { class: 'disc-add', title: 'Add to the band', onclick: () => promote(c.id) }, '+')))));
|
el('button', { class: 'disc-add', title: 'Add to the band', onclick: () => promote(c.id) }, '+')))));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +77,7 @@ async function load() {
|
|||||||
el('span', { class: 'gname' }, TITLE[g.category] || g.category),
|
el('span', { class: 'gname' }, TITLE[g.category] || g.category),
|
||||||
el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`),
|
el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`),
|
||||||
el('span', { class: 'line' })),
|
el('span', { class: 'line' })),
|
||||||
el('div', { class: 'tiles' }, g.services.map(s => serviceTile(s, remote)))));
|
el('div', { class: 'tiles' }, g.services.map(s => tileWithEdit(s, remote)))));
|
||||||
const disc = await discoveredSection();
|
const disc = await discoveredSection();
|
||||||
mount(host,
|
mount(host,
|
||||||
el('div', { class: 'lbwrap' }, littleblueAvatar(),
|
el('div', { class: 'lbwrap' }, littleblueAvatar(),
|
||||||
|
|||||||
56
public/views/icon_picker.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// public/views/icon_picker.js — inline picker with Type + Brand tabs.
|
||||||
|
import { el, mount, clear } from '../dom.js';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
|
||||||
|
// onPick(ref) called with 'set:<set>:<name>' or 'brand:<slug>'. Returns an element.
|
||||||
|
export function iconPicker(currentRef, onPick) {
|
||||||
|
const box = el('div', { class: 'icon-picker' });
|
||||||
|
const tabs = el('div', { class: 'ip-tabs' });
|
||||||
|
const body = el('div', { class: 'ip-body' });
|
||||||
|
const typeTab = el('button', { class: 'ip-tab active' }, 'Type');
|
||||||
|
const brandTab = el('button', { class: 'ip-tab' }, 'Brand');
|
||||||
|
typeTab.onclick = () => { typeTab.classList.add('active'); brandTab.classList.remove('active'); showType(); };
|
||||||
|
brandTab.onclick = () => { brandTab.classList.add('active'); typeTab.classList.remove('active'); showBrand(); };
|
||||||
|
|
||||||
|
async function showType() {
|
||||||
|
clear(body);
|
||||||
|
body.append(el('div', { class: 'muted' }, 'Loading…'));
|
||||||
|
let list = [];
|
||||||
|
try { list = await api.get('/api/icon-sets'); } catch { /* ignore */ }
|
||||||
|
clear(body);
|
||||||
|
for (const s of list) {
|
||||||
|
const grid = el('div', { class: 'ip-grid' }, s.icons.map(file => {
|
||||||
|
const name = file.replace(/\.[a-z]+$/, '');
|
||||||
|
const ref = `set:${s.set}:${name}`;
|
||||||
|
const b = el('button', { class: 'ip-icon', title: name },
|
||||||
|
el('img', { src: `/api/icon-sets/${s.set}/${file}` }));
|
||||||
|
b.onclick = () => onPick(ref);
|
||||||
|
return b;
|
||||||
|
}));
|
||||||
|
body.append(el('div', { class: 'ip-set' },
|
||||||
|
el('div', { class: 'ip-set-hd' }, s.set + (s.readonly ? '' : ' ·')),
|
||||||
|
grid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBrand() {
|
||||||
|
clear(body);
|
||||||
|
const inp = el('input', { class: 'dv-edit-name', placeholder: 'brand slug e.g. apple, google-nest' });
|
||||||
|
const prev = el('div', { class: 'ip-grid' });
|
||||||
|
inp.oninput = () => {
|
||||||
|
const slug = inp.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
|
||||||
|
clear(prev);
|
||||||
|
if (!slug) return;
|
||||||
|
const b = el('button', { class: 'ip-icon' },
|
||||||
|
el('img', { src: `/api/icons/${slug}.png` }));
|
||||||
|
b.onclick = () => onPick(`brand:${slug}`);
|
||||||
|
prev.append(b);
|
||||||
|
};
|
||||||
|
body.append(inp, prev);
|
||||||
|
}
|
||||||
|
|
||||||
|
mount(tabs, typeTab, brandTab);
|
||||||
|
mount(box, tabs, body);
|
||||||
|
showType();
|
||||||
|
return box;
|
||||||
|
}
|
||||||