Compare commits
71 Commits
feat/lan-d
...
bc86d3e282
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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.
|
||||
27
CHANGELOG.md
@@ -3,6 +3,33 @@
|
||||
All notable changes to Void 2.0 are documented here.
|
||||
Format: [Keep a Changelog](https://keepachangelog.com).
|
||||
|
||||
## 2.4.0 — Storage · capacity card (Sacred Valley)
|
||||
- **New "Storage · capacity" card** (`public/views/cards/storage.js`, `/api/storage`, `lib/proxmox/storage.js`) — read-only Proxmox health via the same `PROXMOX_RO_TOKEN` as the cluster card. Shows: **ZFS pools** (health + usage meter), **dropped pools** (a configured zfspool storage that's no longer `available` — the donatello/leonardo SATA-bus signal, rendered red), and **per-container disk fill** (top LXC by rootfs %), with a HEALTHY/WATCH/ATTENTION roll-up badge. Thresholds: 80% warn, 90% crit; a non-ONLINE or dropped pool is always crit.
|
||||
- Closes the monitoring gap from the 2026-06-09 audit (the Void couldn't previously see C1 = pools offline or H2 = a container at 95%). Pure `normalizeStorage()` is unit-tested.
|
||||
|
||||
## 2.3.0 — MagicMirror² as a Void app
|
||||
- **New "MagicMirror" Apps view** (`#/mirror`, `public/views/mirror.js`) — embeds the smart-mirror dashboard (CT 111) via the shared `embedView` factory, like Timelapse / AI Usage.
|
||||
- **Exposure:** MagicMirror (LAN-only `192.168.1.224:8080`) is now published at **mirror.hynesy.com** through Traefik + the `*.hynesy.com` tunnel, private behind **CF Access** (Farm policy / Google IdP). A Traefik `mirror-frame` middleware replaces MM's `X-Frame-Options: SAMEORIGIN` with a CSP `frame-ancestors` allowing the Void origins so the iframe renders.
|
||||
- Unrelated to the Void code: CT 111 itself was updated **MagicMirror 2.25.0 → 2.36.0** on **Node 22**.
|
||||
|
||||
## 2.2.0 — Links: self-hosted URL shortener (Kutt) as a Void app
|
||||
- **New "Links" Apps view** (`#/links`, `public/views/links.js`) — a Void-native card (Kutt **version / update tracker** + one-field **quick-add shortener**) on top of the blackflame-themed **Kutt** UI embedded via iframe (`link.hynesy.com`). Hybrid model: native convenience + the full Kutt UI in one tab.
|
||||
- **`/api/kutt` proxy** (`lib/api/routes/kutt.js`, `lib/links/kutt.js`) — owner-gated server-side proxy that holds the Kutt API key (`GET /version` vs latest GitHub release, cached 6h; `POST /` create; `GET /recent`). The key never reaches the browser. *(Mounted at `/api/kutt`, not `/api/links` — the latter is the Void's existing internal cross-entity linking router.)*
|
||||
- **Infra:** Kutt runs bare-metal in **LXC 113** (`192.168.1.226:3000`), sharing the **void-db** Postgres (own `kutt` DB/role), private-first behind CF Access at `link.hynesy.com`. Theme served as custom CSS; registration locked after admin creation. Env wired in void-app (`KUTT_API_URL`/`KUTT_API_KEY`/`KUTT_VERSION`).
|
||||
|
||||
## 2.1.4 — Devices band: Scan Now + richer Manual Add
|
||||
- **"Scan Now" button** in the Network·Devices header — triggers the scheduled scan on demand (`POST /api/devices/scan`) and refreshes the band.
|
||||
- **"+ Add by MAC" → "+ Manual Add"**, now with an optional **IP** field (`POST /api/devices` + `lan_devices.addManual` accept `ip`), and the **MAC field auto-inserts the colons** as you type.
|
||||
|
||||
## 2.1.3 — Manually add a device by MAC
|
||||
- **"+ Add by MAC" in the Network·Devices band** (`POST /api/devices`, `lan_devices.addManual`, `devices_band.js`): pre-register an **offline** device by typing its MAC (+ optional name/group). Lands as `status='known'`, `present=false`; it gets enriched (IP/vendor/present) automatically the next time it's seen by the scan. Idempotent.
|
||||
|
||||
## 2.1.2 — Edit known network devices
|
||||
- **Edit devices in the Network·Devices band** (`public/views/devices_band.js`): known tiles get a ✎ edit affordance — rename, re-group, or delete a device (PATCH/DELETE `/api/devices/:mac`, which already existed). Previously a device could only be named when first promoted from Discovered.
|
||||
|
||||
## 2.1.1 — OBD2 Apps rail placeholder
|
||||
- **OBD2 Apps rail item** (`public/views/obd2.js`, router/app/sidebar): a placeholder launchpad under **Apps** for the parked OBD2 Telemetry project — links to the project + tasks and the research/wiki page. Swap to an `embedView` once a records UI (LubeLogger/Tracktor) is deployed.
|
||||
|
||||
## 2.1.0 — LAN device discovery
|
||||
- **`lan_devices` store + hourly `arp-scan`** (`migration 024`, `lib/infra/scan.js`,
|
||||
`lib/db/repos/lan_devices.js`, `lib/cron`): the Devices band is now DB-backed and
|
||||
|
||||
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"
|
||||
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.
|
||||
```
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -34,6 +34,12 @@ import { router as littleblueRouter } from './routes/littleblue.js';
|
||||
import { router as aiUsageRouter } from './routes/ai_usage.js';
|
||||
import { router as infraRouter } from './routes/infra.js';
|
||||
import { router as clusterRouter } from './routes/cluster.js';
|
||||
import { router as storageRouter } from './routes/storage.js';
|
||||
import { router as backupsRouter } from './routes/backups.js';
|
||||
import { router as kuttRouter } from './routes/kutt.js';
|
||||
import { router as themeRouter } from './routes/theme.js';
|
||||
import { router as drossRouter } from './routes/dross.js';
|
||||
import { router as voiceRouter } from './routes/voice.js';
|
||||
|
||||
export function mountApi(app) {
|
||||
const api = Router();
|
||||
@@ -49,6 +55,8 @@ export function mountApi(app) {
|
||||
api.use('/actions', actionsRouter);
|
||||
api.use('/infra', infraRouter);
|
||||
api.use('/cluster', clusterRouter);
|
||||
api.use('/storage', storageRouter);
|
||||
api.use('/backups', backupsRouter);
|
||||
api.use('/little-blue', littleblueRouter);
|
||||
api.use('/ai-usage', aiUsageRouter);
|
||||
api.use('/projects', projectsRouter);
|
||||
@@ -65,6 +73,10 @@ export function mountApi(app) {
|
||||
api.use('/conversations/:conversation_id/messages', messagesByConvRouter);
|
||||
api.use('/tags', tagsRouter);
|
||||
api.use('/links', linksRouter);
|
||||
api.use('/kutt', kuttRouter);
|
||||
api.use('/theme', themeRouter);
|
||||
api.use('/dross', drossRouter);
|
||||
api.use('/voice', voiceRouter);
|
||||
api.use('/pending-changes', pendingChangesRouter);
|
||||
api.use('/audit', auditRouter);
|
||||
api.use('/search', searchRouter);
|
||||
|
||||
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();
|
||||
router.use(requireOwner);
|
||||
|
||||
const geomCell = z.object({
|
||||
x: z.number(), y: z.number(),
|
||||
w: z.number().positive(), h: z.number().positive(),
|
||||
free: z.boolean().optional()
|
||||
});
|
||||
const layoutSchema = z.object({
|
||||
card_order: z.array(z.string()).default([]),
|
||||
hidden: z.array(z.string()).default([]),
|
||||
// Per-card width: an integer column span 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) => {
|
||||
|
||||
@@ -4,32 +4,10 @@ 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 * as agents from '../../db/repos/agents.js';
|
||||
import { timingSafeStrEqual } from '../../auth/safe_compare.js';
|
||||
import { accessOwnerEmail } from '../../auth/cf_access.js';
|
||||
import { isRandomizedMac } from '../../infra/scan.js';
|
||||
import { softAuth } from '../soft_auth.js';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
// Soft auth: identifies the actor if auth is present but never blocks the request.
|
||||
// Owner-only sub-routes enforce 401/403 via requireOwner.
|
||||
async function softAuth(req, _res, next) {
|
||||
try {
|
||||
const cfEmail = await accessOwnerEmail(req);
|
||||
if (cfEmail) { req.actor = { kind: 'user', id: null }; return next(); }
|
||||
const auth = req.headers.authorization || '';
|
||||
const [scheme, token] = auth.split(' ');
|
||||
if (scheme === 'Bearer' && token) {
|
||||
if (process.env.OWNER_TOKEN && timingSafeStrEqual(token, process.env.OWNER_TOKEN)) {
|
||||
req.actor = { kind: 'user', id: null }; return next();
|
||||
}
|
||||
try {
|
||||
const agent = await agents.verifyToken(token);
|
||||
if (agent) req.actor = { kind: 'agent', id: agent.id, capabilities: agent.capabilities || {}, scopes: agent.scopes || {} };
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
next();
|
||||
}
|
||||
const GROUP_ORDER = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
|
||||
|
||||
router.use(softAuth);
|
||||
@@ -52,12 +30,14 @@ router.get('/discovered', requireOwner, asyncWrap(async (_req, res) => {
|
||||
}));
|
||||
|
||||
const macParam = z.object({ mac: z.string().regex(/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i) });
|
||||
export const iconRef = z.string().regex(/^(set:[a-z0-9-]+:[a-z0-9-]+|brand:[a-z0-9-]+)$/).nullable();
|
||||
const patchBody = z.object({
|
||||
name: z.string().max(120).optional(),
|
||||
grp: z.enum(['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']).optional(),
|
||||
status: z.enum(['new', 'known', 'ignored']).optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
flagged: z.boolean().optional()
|
||||
flagged: z.boolean().optional(),
|
||||
icon: iconRef.optional()
|
||||
});
|
||||
|
||||
// PATCH /devices/:mac — name / edit / promote (owner). This is "add from discovered".
|
||||
@@ -67,6 +47,20 @@ router.patch('/:mac', requireOwner, validate({ params: macParam, body: patchBody
|
||||
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' } });
|
||||
|
||||
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 { grouped, iconSlug } from '../../health/registry.js';
|
||||
import * as services from '../../db/repos/monitored_services.js';
|
||||
import * as devices from '../../db/repos/lan_devices.js';
|
||||
import * as statusRepo from '../../db/repos/service_status.js';
|
||||
import { enqueue } from '../../jobs/queue.js';
|
||||
|
||||
@@ -29,7 +30,13 @@ router.get('/services', asyncWrap(async (_req, res) => {
|
||||
|
||||
// GET /services/discovered — candidates from a LAN scan, awaiting review (owner).
|
||||
router.get('/services/discovered', requireOwner, asyncWrap(async (_req, res) => {
|
||||
res.json((await services.listDiscovered()).map(s => ({ ...s, icon: iconSlug(s) })));
|
||||
// Cross-reference each candidate's host IP with the Network Devices band so the
|
||||
// tile can show a known device name instead of a bare IP:port.
|
||||
const byIp = Object.fromEntries(
|
||||
(await devices.listKnown()).filter(d => d.ip).map(d => [d.ip, d.name]));
|
||||
res.json((await services.listDiscovered()).map(s => ({
|
||||
...s, icon: iconSlug(s), device: byIp[s.host] || null
|
||||
})));
|
||||
}));
|
||||
|
||||
const checkCfg = z.object({ type: z.enum(['http', 'tcp']).optional(), path: z.string().max(200).optional() });
|
||||
|
||||
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);
|
||||
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 { z } from 'zod';
|
||||
import { asyncWrap } from '../errors.js';
|
||||
import { requireOwner } from '../cap.js';
|
||||
import { validate } from '../validate.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 { setSpeedtestSchedule } from '../../cron/index.js';
|
||||
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 id = await enqueue('speedtest', {});
|
||||
res.status(202).json({ enqueued: id });
|
||||
|
||||
const DEFAULT_CFG = { interval_min: 60, threshold_down_mbps: 0 };
|
||||
async function getCfg() { return { ...DEFAULT_CFG, ...(await settings.get('speedtest', {})) }; }
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -6,6 +6,26 @@ import { checkAll } from '../health/checker.js';
|
||||
import * as statusRepo from '../db/repos/service_status.js';
|
||||
import * as services from '../db/repos/monitored_services.js';
|
||||
import { runDeviceScanCycle } from '../infra/scan_cycle.js';
|
||||
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() {
|
||||
// Daily at 03:00 local time
|
||||
@@ -18,11 +38,10 @@ export function startCron() {
|
||||
}
|
||||
});
|
||||
|
||||
// Hourly speedtest
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
try { await enqueue('speedtest', {}); log.info('cron speedtest enqueued'); }
|
||||
catch (e) { log.error({ err: e }, 'cron speedtest failed'); }
|
||||
});
|
||||
// Speedtest — interval from the saved config (default 60 min), reschedulable.
|
||||
settings.get('speedtest', {})
|
||||
.then(cfg => setSpeedtestSchedule(cfg?.interval_min || 60))
|
||||
.catch(e => { log.error({ err: e }, 'speedtest schedule init failed'); setSpeedtestSchedule(60); });
|
||||
|
||||
// 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
|
||||
|
||||
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);
|
||||
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';
|
||||
|
||||
const DEFAULTS = { card_order: [], hidden: [], sizes: {} };
|
||||
const DEFAULTS = { card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] };
|
||||
|
||||
export async function get() {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT card_order, hidden, sizes FROM dashboard_layout WHERE owner_key = 'owner'`
|
||||
`SELECT card_order, hidden, sizes, geom, extras
|
||||
FROM dashboard_layout WHERE owner_key = 'owner'`
|
||||
);
|
||||
return rows[0] || { ...DEFAULTS };
|
||||
}
|
||||
|
||||
export async function put({ card_order = [], hidden = [], sizes = {} }) {
|
||||
export async function put({ card_order = [], hidden = [], sizes = {}, geom = {}, extras = [] }) {
|
||||
await pool.query(
|
||||
`INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, updated_at)
|
||||
VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, now())
|
||||
`INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, geom, extras, updated_at)
|
||||
VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, $4::jsonb, $5::jsonb, now())
|
||||
ON CONFLICT (owner_key) DO UPDATE
|
||||
SET card_order = EXCLUDED.card_order,
|
||||
hidden = EXCLUDED.hidden,
|
||||
sizes = EXCLUDED.sizes,
|
||||
geom = EXCLUDED.geom,
|
||||
extras = EXCLUDED.extras,
|
||||
updated_at = now()`,
|
||||
[JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes)]
|
||||
[JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes),
|
||||
JSON.stringify(geom), JSON.stringify(extras)]
|
||||
);
|
||||
return get();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { pool } from '../pool.js';
|
||||
|
||||
const COLS = 'mac, ip, vendor, name, grp, note, status, randomized, flagged, first_seen, last_seen, present';
|
||||
const COLS = 'mac, ip, vendor, name, grp, note, status, randomized, flagged, first_seen, last_seen, present, icon';
|
||||
|
||||
export async function listKnown() {
|
||||
const { rows } = await pool.query(
|
||||
@@ -19,6 +19,22 @@ export async function get(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) {
|
||||
@@ -54,7 +70,7 @@ export async function prune() {
|
||||
return rowCount;
|
||||
}
|
||||
|
||||
const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged'];
|
||||
const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged', 'icon'];
|
||||
export async function update(mac, patch) {
|
||||
const sets = [], vals = [];
|
||||
for (const k of PATCHABLE) {
|
||||
|
||||
@@ -1,12 +1,51 @@
|
||||
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(
|
||||
`INSERT INTO speedtest_results (down_mbps, up_mbps, ping_ms) VALUES ($1,$2,$3) RETURNING *`,
|
||||
[down_mbps, up_mbps, ping_ms]);
|
||||
`INSERT INTO speedtest_results
|
||||
(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];
|
||||
}
|
||||
|
||||
export async function history(limit = 30) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM speedtest_results ORDER BY ran_at DESC LIMIT $1`, [limit]);
|
||||
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 });
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
import net from 'node:net';
|
||||
import * as services from '../../db/repos/monitored_services.js';
|
||||
import * as devices from '../../db/repos/lan_devices.js';
|
||||
import { log } from '../../log.js';
|
||||
|
||||
export const NAME = 'discover.lan';
|
||||
|
||||
// Well-known homelab ports → likely service, so candidates get a real name.
|
||||
const PORT_SVC = {
|
||||
2424: 'Void', 5055: 'Overseerr', 6767: 'Bazarr', 7878: 'Radarr', 8006: 'Proxmox VE',
|
||||
8096: 'Jellyfin', 8123: 'Home Assistant', 8265: 'Tdarr', 8384: 'Syncthing', 8989: 'Sonarr',
|
||||
9000: 'Portainer', 9090: 'Cockpit', 9696: 'Prowlarr', 11434: 'Ollama', 19999: 'Netdata',
|
||||
32400: 'Plex'
|
||||
};
|
||||
|
||||
// Common homelab web/service ports to probe.
|
||||
const PORTS = [80, 81, 443, 2424, 3000, 3001, 5000, 5055, 6767, 6875, 7878, 8000,
|
||||
8006, 8080, 8081, 8096, 8123, 8265, 8384, 8443, 8989, 9000, 9090, 9696, 11434, 19999, 32400, 60072];
|
||||
@@ -55,13 +64,18 @@ export async function handler(job) {
|
||||
// 1) TCP sweep → live host:ports
|
||||
const open = (await mapPool(targets, 120, async (t) => (await _tcp(t.host, t.port)) ? t : null)).filter(Boolean);
|
||||
|
||||
// 2) HTTP-probe each, build + upsert discovered candidates (no-clobber in the repo)
|
||||
// 2) HTTP-probe each, build + upsert discovered candidates (no-clobber in the repo).
|
||||
// Cross-reference the Network Devices band so candidates are named by service+device.
|
||||
const deviceByIp = Object.fromEntries(
|
||||
(await devices.listKnown()).filter(d => d.ip).map(d => [d.ip, d.name]));
|
||||
let added = 0;
|
||||
for (const { host, port } of open) {
|
||||
const scheme = HTTPS_PORTS.has(port) ? 'https' : 'http';
|
||||
const url = `${scheme}://${host}:${port}`;
|
||||
const probe = await _http(url);
|
||||
const name = (probe && probe.title) || `${host}:${port}`;
|
||||
const dev = deviceByIp[host];
|
||||
const svc = PORT_SVC[port] || (probe && probe.title) || null;
|
||||
const name = svc ? (dev ? `${svc} · ${dev}` : svc) : (dev ? `${dev} :${port}` : `${host}:${port}`);
|
||||
const id = `disc-${host.replace(/\./g, '-')}-${port}`;
|
||||
const check = scheme === 'https' ? { type: 'tcp' } : { type: 'http' };
|
||||
const r = await services.upsertDiscovered({ id, name, category: 'other', host, url, check });
|
||||
|
||||
@@ -6,18 +6,42 @@ const pexec = promisify(execFile);
|
||||
|
||||
export const NAME = 'speedtest';
|
||||
|
||||
// Default runner uses speedtest-cli --json (bits/s → Mbps). Swap binary/flags
|
||||
// here if the box has the Ookla `speedtest -f json` CLI instead.
|
||||
async function defaultRunner() {
|
||||
const { stdout } = await pexec('speedtest-cli', ['--json'], { timeout: 120000 });
|
||||
// Ookla CLI gives the full metric set (jitter, packet loss, server, ISP,
|
||||
// shareable result URL). Override the binary via SPEEDTEST_BIN if needed.
|
||||
const OOKLA_BIN = process.env.SPEEDTEST_BIN || 'ookla-speedtest';
|
||||
|
||||
async function ooklaRunner() {
|
||||
const { stdout } = await pexec(OOKLA_BIN,
|
||||
['-f', 'json', '--accept-license', '--accept-gdpr'], { timeout: 120000 });
|
||||
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 async function handler(_job) {
|
||||
const r = await runner();
|
||||
await repo.record(r);
|
||||
log.info(r, 'speedtest recorded');
|
||||
try {
|
||||
const r = await runner();
|
||||
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",
|
||||
"version": "2.0.0-alpha.16",
|
||||
"version": "2.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "void-server",
|
||||
"version": "2.0.0-alpha.16",
|
||||
"version": "2.13.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"adm-zip": "^0.5.17",
|
||||
"bcrypt": "^6.0.0",
|
||||
"dompurify": "^3.4.7",
|
||||
"dotenv": "^17.4.2",
|
||||
@@ -965,6 +966,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/adm-zip": {
|
||||
"version": "0.5.17",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
|
||||
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.1.0",
|
||||
"version": "2.13.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"adm-zip": "^0.5.17",
|
||||
"bcrypt": "^6.0.0",
|
||||
"dompurify": "^3.4.7",
|
||||
"dotenv": "^17.4.2",
|
||||
|
||||
@@ -11,11 +11,14 @@ function token() { return localStorage.getItem(TOKEN_KEY) || ''; }
|
||||
|
||||
async function call(method, path, body) {
|
||||
const headers = { 'Authorization': 'Bearer ' + token() };
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
// FormData bodies: let the browser set the multipart/form-data boundary
|
||||
// automatically — do NOT set Content-Type or JSON.stringify.
|
||||
const isFormData = body instanceof FormData;
|
||||
if (body !== undefined && !isFormData) headers['Content-Type'] = 'application/json';
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
body: body === undefined ? undefined : (isFormData ? body : JSON.stringify(body))
|
||||
});
|
||||
if (res.status === 401) { await promptForToken(); return call(method, path, body); }
|
||||
if (res.status === 204) return null;
|
||||
@@ -61,11 +64,14 @@ function promptForToken() {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (p) => call('GET', p),
|
||||
post: (p, body) => call('POST', p, body ?? {}),
|
||||
put: (p, body) => call('PUT', p, body ?? {}),
|
||||
patch: (p, body) => call('PATCH', p, body ?? {}),
|
||||
del: (p) => call('DELETE', p),
|
||||
get: (p) => call('GET', p),
|
||||
post: (p, body) => call('POST', p, body ?? {}),
|
||||
put: (p, body) => call('PUT', p, body ?? {}),
|
||||
patch: (p, body) => call('PATCH', p, body ?? {}),
|
||||
del: (p) => call('DELETE', p),
|
||||
// POST a FormData body (multipart/form-data). Content-Type is omitted so
|
||||
// the browser appends the correct multipart boundary automatically.
|
||||
postForm: (p, fd) => call('POST', p, fd),
|
||||
setToken: (v) => localStorage.setItem(TOKEN_KEY, v),
|
||||
hasToken: () => !!token()
|
||||
};
|
||||
|
||||
@@ -6,11 +6,12 @@ import { api } from './api.js';
|
||||
import { route, current, navigate } from './router.js';
|
||||
import { renderSidebar } from './components/sidebar.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 { el, mount } from './dom.js';
|
||||
import { attachDropzone } from './components/dropzone.js';
|
||||
import { initChrome } from './components/chrome.js';
|
||||
import { loadTheme } from './theme.js';
|
||||
|
||||
const VIEWS = {
|
||||
home: () => import('./views/home.js'),
|
||||
@@ -27,8 +28,12 @@ const VIEWS = {
|
||||
terminal: () => import('./views/terminal.js'),
|
||||
timelapse: () => import('./views/timelapse.js'),
|
||||
'ai-usage': () => import('./views/aiusage.js'),
|
||||
obd2: () => import('./views/obd2.js'),
|
||||
links: () => import('./views/links.js'),
|
||||
mirror: () => import('./views/mirror.js'),
|
||||
settings: () => import('./views/settings.js'),
|
||||
jobs: () => import('./views/jobs.js')
|
||||
jobs: () => import('./views/jobs.js'),
|
||||
speedtest: () => import('./views/speedtest.js')
|
||||
};
|
||||
|
||||
async function renderView(ctx) {
|
||||
@@ -76,9 +81,10 @@ async function init() {
|
||||
try { await api.get('/api/spaces'); }
|
||||
catch { /* api wrapper opens the modal on 401 */ }
|
||||
}
|
||||
await loadTheme(); // apply saved palette overrides before rendering chrome
|
||||
renderTopbar(document.getElementById('topbar'));
|
||||
renderSidebar(document.getElementById('sidebar'));
|
||||
renderRightrail(document.getElementById('rightrail'));
|
||||
renderDrossBubble();
|
||||
initChrome();
|
||||
attachDropzone(document.getElementById('main'));
|
||||
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);
|
||||
}
|
||||
130
public/components/dross_bubble.js
Normal file
@@ -0,0 +1,130 @@
|
||||
// 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) {
|
||||
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);
|
||||
|
||||
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);
|
||||
} 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; input.focus(); }
|
||||
// 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,6 +122,7 @@ export function renderSidebar(root) {
|
||||
el('div', { class: 'sb-section' },
|
||||
el('div', { class: 'sb-title' }, 'Navigate'),
|
||||
navItem('Sacred Valley', '/sacred-valley'),
|
||||
navItem('Speedtest', '/speedtest'),
|
||||
navItem('Terminal', '/terminal'),
|
||||
navItem('Search', '/search'),
|
||||
inboxItem,
|
||||
@@ -131,7 +132,10 @@ export function renderSidebar(root) {
|
||||
el('div', { class: 'sb-section' },
|
||||
el('div', { class: 'sb-title' }, 'Apps'),
|
||||
navItem('Timelapse', '/timelapse'),
|
||||
navItem('AI Usage', '/ai-usage')
|
||||
navItem('AI Usage', '/ai-usage'),
|
||||
navItem('OBD2', '/obd2'),
|
||||
navItem('Links', '/links'),
|
||||
navItem('MagicMirror', '/mirror')
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { el } from '../dom.js';
|
||||
|
||||
// Builds the refined-B chrome shell and returns { root, body }. The card module
|
||||
// fills `body` in its mount(); start()/stop() own its refresh timer.
|
||||
// fills `body` in its mount(); start()/stop() own its refresh timer. Position +
|
||||
// size are set by the Sacred Valley canvas (absolute geometry), not here.
|
||||
// Decorative cards (blank / blackflame) carry no title bar.
|
||||
export function svCard(def) {
|
||||
const body = el('div', { class: 'sv-card-body' });
|
||||
const root = el('div', {
|
||||
class: 'sv-card', dataset: { cardId: def.id },
|
||||
style: { gridColumn: 'span ' + (def.span || 6) } // 12-col grid; per-card width
|
||||
},
|
||||
el('div', { class: 'sv-card-title' }, def.title),
|
||||
const root = el('div', { class: 'sv-card', dataset: { cardId: def.id } },
|
||||
def.title ? el('div', { class: 'sv-card-title' }, def.title) : null,
|
||||
body
|
||||
);
|
||||
return { root, body };
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { el, mount, clear } from '../dom.js';
|
||||
import { navigate } from '../router.js';
|
||||
import { on } from '../state.js';
|
||||
import { toggleSidebar, toggleRail } from './chrome.js';
|
||||
import { toggleSidebar } from './chrome.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
// Cluster health → topbar pill. Returns [status, label, title].
|
||||
@@ -72,7 +72,7 @@ export function renderTopbar(root) {
|
||||
el('div', { class: 'topbar-spacer' }),
|
||||
clusterPill,
|
||||
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')
|
||||
);
|
||||
|
||||
|
||||
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 |
@@ -39,7 +39,6 @@
|
||||
<header id="topbar"></header>
|
||||
<aside id="sidebar"></aside>
|
||||
<main id="main"></main>
|
||||
<aside id="rightrail"></aside>
|
||||
</div>
|
||||
<div id="modal-root"></div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
|
||||
@@ -28,8 +28,12 @@ const ROUTES = [
|
||||
{ name: 'terminal', re: /^\/terminal$/, keys: [] },
|
||||
{ name: 'timelapse', re: /^\/timelapse$/, keys: [] },
|
||||
{ name: 'ai-usage', re: /^\/ai-usage$/, keys: [] },
|
||||
{ name: 'obd2', re: /^\/obd2$/, keys: [] },
|
||||
{ name: 'links', re: /^\/links$/, keys: [] },
|
||||
{ name: 'mirror', re: /^\/mirror$/, keys: [] },
|
||||
{ name: 'settings', re: /^\/settings$/, keys: [] },
|
||||
{ name: 'jobs', re: /^\/jobs$/, keys: [] },
|
||||
{ name: 'speedtest', re: /^\/speedtest$/, keys: [] },
|
||||
{ name: 'home', re: /^\/?$/, keys: [] }
|
||||
];
|
||||
|
||||
|
||||
234
public/style.css
@@ -29,20 +29,18 @@ html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color:
|
||||
|
||||
#shell {
|
||||
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-areas:
|
||||
"topbar topbar topbar"
|
||||
"sidebar main rail";
|
||||
"topbar topbar"
|
||||
"sidebar main";
|
||||
height: 100vh;
|
||||
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; }
|
||||
#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; }
|
||||
#rightrail{ grid-area: rail; border-left: 1px solid var(--border); background: var(--panel); overflow: hidden; display: flex; flex-direction: column; }
|
||||
|
||||
/* 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; }
|
||||
@@ -382,14 +380,9 @@ ul.plain li:last-child { border-bottom: none; }
|
||||
/* reserved for a future agent-output phase — unused now:
|
||||
--hue-dross: #ff4f2e; --hue-yerin: #c45a4a; --hue-orthos: #6fa86a; */
|
||||
}
|
||||
#sv-cards { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; align-items: start; }
|
||||
.sv-card { grid-column: span 6; } /* fallback; svCard sets an inline span (1–12) */
|
||||
@media (max-width: 700px) { #sv-cards { grid-template-columns: 1fr; } .sv-card { grid-column: 1 / -1 !important; } }
|
||||
.sv-ed-span { display: inline-flex; align-items: center; gap: 3px; }
|
||||
.sv-ed-step { width: 18px; height: 20px; border: 1px solid var(--border); background: transparent; color: var(--muted);
|
||||
border-radius: 3px; font-size: 14px; line-height: 1; cursor: pointer; padding: 0; }
|
||||
.sv-ed-step:hover { color: var(--accent); border-color: var(--accent-dim); }
|
||||
.sv-span-val { font-family: var(--font-mono); font-size: 12px; color: var(--text); min-width: 14px; text-align: center; }
|
||||
/* Hybrid canvas: cards are absolutely placed (JS sets left/top/width/height in
|
||||
12-col grid units); the board grows to fit its content. See sacred_valley.js. */
|
||||
#sv-cards { position: relative; width: 100%; min-height: 200px; }
|
||||
|
||||
.sv-card {
|
||||
position: relative; border: 1px solid #2c242a; border-radius: 10px; padding: 16px 18px;
|
||||
@@ -407,8 +400,11 @@ ul.plain li:last-child { border-bottom: none; }
|
||||
border-color: #4a2c28; transform: translateY(-2px);
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,.45), inset 0 0 46px rgba(255,79,46,.06), 0 0 0 1px rgba(255,79,46,.10);
|
||||
}
|
||||
.sv-card.dragging { opacity: .5; }
|
||||
.sv-card.drag-over { border-color: var(--accent); }
|
||||
.sv-card.dragging { transition: none; box-shadow: 0 16px 44px -12px #000, 0 0 0 1px var(--accent-dim); }
|
||||
.sv-card.free { box-shadow: 0 0 0 1px var(--accent-dim), 0 10px 30px -12px #000; }
|
||||
#sv-cards.editing .sv-card { transition: none; }
|
||||
#sv-cards.editing .sv-card:hover { transform: none; }
|
||||
.sv-card-body { height: 100%; overflow: auto; }
|
||||
.sv-card-title {
|
||||
font-family: var(--font-display); font-size: 13px; letter-spacing: .16em; text-transform: uppercase;
|
||||
color: var(--text); padding-bottom: 7px; margin-bottom: 12px;
|
||||
@@ -485,15 +481,11 @@ ul.plain li:last-child { border-bottom: none; }
|
||||
/* ===== Collapsible chrome + responsive layout (Plan 6 polish) ===== */
|
||||
:root { --sidebar-w-min: 0px; }
|
||||
#shell { transition: grid-template-columns .22s ease; }
|
||||
#sidebar, #rightrail { transition: transform .22s ease; }
|
||||
#sidebar { transition: transform .22s ease; }
|
||||
|
||||
/* Desktop collapse — shrink the grid columns */
|
||||
#shell.sidebar-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr var(--rail-w); }
|
||||
#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); }
|
||||
/* Desktop collapse — shrink the sidebar column */
|
||||
#shell.sidebar-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr; }
|
||||
#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 */
|
||||
.chrome-toggle {
|
||||
@@ -516,13 +508,11 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
||||
/* ---- Narrow / mobile / vertical: off-canvas drawers, single-column main ---- */
|
||||
@media (max-width: 860px) {
|
||||
#shell,
|
||||
#shell.sidebar-collapsed,
|
||||
#shell.rail-collapsed,
|
||||
#shell.sidebar-collapsed.rail-collapsed {
|
||||
#shell.sidebar-collapsed {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas: "topbar" "main";
|
||||
}
|
||||
#sidebar, #rightrail {
|
||||
#sidebar {
|
||||
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); }
|
||||
@@ -563,6 +553,35 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
||||
.dv-tile .dv-nm { font-family: var(--font-ui); font-size: 13px; color: var(--text); }
|
||||
.dv-tile .dv-ip { font-family: var(--font-mono); font-size: 12px; color: var(--muted); }
|
||||
.dv-tile .dv-mac { font-family: var(--font-mono); font-size: 10px; color: var(--muted); opacity: .6; letter-spacing: .02em; }
|
||||
.lk-card { max-width: 760px; }
|
||||
.lk-row { display: flex; gap: 12px; align-items: center; margin-bottom: 10px; }
|
||||
.lk-update a { color: var(--accent); }
|
||||
.lk-quickadd { display: flex; gap: 8px; }
|
||||
.lk-quickadd .lk-url { flex: 1; }
|
||||
.lk-out { display: block; margin-top: 8px; font-family: var(--font-mono); font-size: 13px; }
|
||||
.dv-tile { position: relative; }
|
||||
.dv-edit-btn { position: absolute; top: 5px; right: 5px; background: transparent; border: 1px solid var(--border); color: var(--muted); border-radius: 3px; font-size: 11px; line-height: 1; padding: 2px 5px; cursor: pointer; opacity: 0; }
|
||||
.dv-tile:hover .dv-edit-btn { opacity: 1; }
|
||||
/* 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.flag { border-color: var(--bad); background: #1a1012; }
|
||||
.dv-tile.flag .dv-nm { color: var(--bad); }
|
||||
@@ -597,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; }
|
||||
#sv-cards.editing .sv-card-edit { display: flex; }
|
||||
#sv-cards.editing .sv-card { outline: 1px dashed var(--accent-dim); }
|
||||
.sv-grip { cursor: grab; color: var(--muted); font-size: 14px; padding: 0 2px; user-select: none; }
|
||||
.sv-grip { cursor: grab; color: var(--muted); font-size: 14px; padding: 0 2px; user-select: none; touch-action: none; }
|
||||
.sv-grip:active { cursor: grabbing; }
|
||||
.sv-ed-sizes { display: flex; gap: 2px; }
|
||||
.sv-ed-size, .sv-ed-hide { width: 20px; height: 20px; border: 1px solid var(--border); background: transparent;
|
||||
border-radius: 3px; font-size: 11px; cursor: pointer; padding: 0; line-height: 1; }
|
||||
.sv-ed-size { color: var(--muted); }
|
||||
.sv-ed-size:hover { color: var(--accent); border-color: var(--accent-dim); }
|
||||
.sv-card[data-size="s"] .sv-ed-size[data-s="s"],
|
||||
.sv-card[data-size="m"] .sv-ed-size[data-s="m"],
|
||||
.sv-card[data-size="l"] .sv-ed-size[data-s="l"] { background: var(--accent-dim); color: var(--text); border-color: var(--accent); }
|
||||
.sv-ed-hide { color: var(--bad); font-size: 12px; }
|
||||
.sv-ed-free, .sv-ed-hide { width: 20px; height: 20px; border: 1px solid var(--border); background: transparent;
|
||||
border-radius: 3px; font-size: 12px; cursor: pointer; padding: 0; line-height: 1; }
|
||||
.sv-ed-free { color: var(--muted); }
|
||||
.sv-ed-free:hover { color: var(--accent); border-color: var(--accent-dim); }
|
||||
.sv-card.free .sv-ed-free { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.sv-ed-hide { color: var(--bad); }
|
||||
.sv-ed-hide:hover { background: var(--bad); color: var(--bg); }
|
||||
/* resize handle — bottom-right corner, edit mode only */
|
||||
.sv-resize { display: none; position: absolute; right: 3px; bottom: 3px; width: 16px; height: 16px; z-index: 4;
|
||||
cursor: nwse-resize; touch-action: none; border-radius: 0 0 9px 0;
|
||||
background: linear-gradient(135deg, transparent 45%, var(--muted) 45% 55%, transparent 55%,
|
||||
transparent 62%, var(--muted) 62% 72%, transparent 72%); opacity: .6; }
|
||||
.sv-resize:hover { opacity: 1; }
|
||||
#sv-cards.editing .sv-resize { display: block; }
|
||||
.sv-tray-hint { font-family: var(--font-ui); font-size: 11px; margin-left: 6px; }
|
||||
|
||||
/* decorative cards (blank spacer / blackflame) — no chrome padding, full-bleed body */
|
||||
.sv-card-decor { padding: 0; overflow: hidden; }
|
||||
.sv-card-decor .sv-card-body { position: absolute; inset: 0; padding: 0; overflow: hidden; }
|
||||
.sv-card-decor:hover { transform: none; }
|
||||
.sv-blank { width: 100%; height: 100%;
|
||||
background-image: repeating-linear-gradient(45deg, rgba(255,255,255,.025) 0 9px, transparent 9px 18px); }
|
||||
.sv-flame-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; pointer-events: none; }
|
||||
.sv-flame-crest { position: absolute; top: 46%; left: 50%; width: 22%; aspect-ratio: 1; transform: translate(-50%,-50%);
|
||||
border-radius: 50%; pointer-events: none;
|
||||
background: radial-gradient(circle at 50% 45%, #000 38%, #0a0a10 60%, transparent 72%);
|
||||
box-shadow: 0 0 26px 10px #000, 0 0 60px 18px rgba(255,79,46,.13); }
|
||||
.sv-flame-label { position: absolute; left: 0; right: 0; bottom: 14px; text-align: center; pointer-events: none;
|
||||
font-family: var(--font-display); letter-spacing: .42em; text-indent: .42em; text-transform: uppercase;
|
||||
font-size: 14px; color: #f4ece9; text-shadow: 0 0 18px rgba(255,79,46,.55), 0 0 4px #000; }
|
||||
|
||||
#sv-tray { flex-wrap: wrap; align-items: center; gap: 8px; margin: 2px 0 18px; padding: 10px 12px;
|
||||
border: 1px dashed var(--border); border-radius: 8px; }
|
||||
@@ -616,5 +655,128 @@ 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;
|
||||
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-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; }
|
||||
|
||||
/* 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}
|
||||
|
||||
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 { api } from '../../api.js';
|
||||
|
||||
@@ -6,17 +6,21 @@ let body;
|
||||
async function load() {
|
||||
if (!body) return;
|
||||
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 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 =>
|
||||
el('div', { style: { flex: '1', background: 'var(--accent-dim)',
|
||||
height: (Number(h.down_mbps) / max * 100) + '%' } })));
|
||||
mount(body,
|
||||
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')),
|
||||
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);
|
||||
} 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; }
|
||||
};
|
||||
@@ -3,17 +3,65 @@
|
||||
// newly-seen devices. Kept SEPARATE from Little Blue's homelab-service band.
|
||||
import { el, mount, clear } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
import { resolveIcon, relativeTime, autoDefaultIcon } from './icon_util.js';
|
||||
import { iconPicker } from './icon_picker.js';
|
||||
|
||||
let host;
|
||||
const GROUPS = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
|
||||
|
||||
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' : '')));
|
||||
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) {
|
||||
@@ -33,6 +81,30 @@ function discoveredRow(d, onDone) {
|
||||
nameI, grpS, add, ignore);
|
||||
}
|
||||
|
||||
// Manual add form — for offline devices (MAC required; IP optional). The MAC
|
||||
// field auto-inserts the colons as you type.
|
||||
function manualAddForm() {
|
||||
const macI = el('input', { class: 'dv-edit-name', placeholder: 'aa:bb:cc:dd:ee:ff' });
|
||||
macI.oninput = () => {
|
||||
const v = macI.value.replace(/[^0-9a-fA-F]/g, '').slice(0, 12).toLowerCase();
|
||||
macI.value = v.match(/.{1,2}/g)?.join(':') ?? v;
|
||||
};
|
||||
const ipI = el('input', { class: 'dv-edit-name', placeholder: 'IP (optional)' });
|
||||
const nameI = el('input', { class: 'dv-edit-name', placeholder: 'name (optional)' });
|
||||
const grpS = el('select', { class: 'dv-edit-grp' }, ...GROUPS.map(g => el('option', { value: g }, g)));
|
||||
const err = el('span', { class: 'muted', style: { fontSize: '11px' } }, '');
|
||||
const add = el('button', { class: 'dv-add' }, 'Add');
|
||||
add.onclick = async () => {
|
||||
const mac = macI.value.trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/.test(mac)) { err.textContent = 'MAC must look like aa:bb:cc:dd:ee:ff'; return; }
|
||||
const ip = ipI.value.trim();
|
||||
if (ip && !/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) { err.textContent = 'IP must look like 192.168.1.x'; return; }
|
||||
try { await api.post('/api/devices', { mac, ip: ip || undefined, name: nameI.value.trim() || undefined, grp: grpS.value }); load(); }
|
||||
catch { err.textContent = 'add failed'; }
|
||||
};
|
||||
return el('div', { class: 'dv-addform' }, macI, ipI, nameI, grpS, add, err);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!host) return;
|
||||
let data, discovered = [];
|
||||
@@ -54,11 +126,24 @@ async function load() {
|
||||
...discovered.map(d => discoveredRow(d, load)))
|
||||
: null;
|
||||
|
||||
const addForm = manualAddForm();
|
||||
addForm.style.display = 'none';
|
||||
const addToggle = el('button', { class: 'ghost dv-addtoggle' }, '+ Manual Add');
|
||||
addToggle.onclick = () => { addForm.style.display = addForm.style.display === 'none' ? 'flex' : 'none'; };
|
||||
const scanBtn = el('button', { class: 'ghost dv-scanbtn' }, 'Scan Now');
|
||||
scanBtn.onclick = async () => {
|
||||
scanBtn.textContent = 'Scanning…'; scanBtn.disabled = true;
|
||||
try { await api.post('/api/devices/scan'); } catch { /* ignore */ }
|
||||
load();
|
||||
};
|
||||
|
||||
clear(host);
|
||||
mount(host,
|
||||
el('div', { class: 'dv-hd' },
|
||||
el('div', { class: 'dv-title' }, 'Network · Devices'),
|
||||
el('span', { class: 'dv-count' }, `${total} known${discovered.length ? ` · ${discovered.length} new` : ''}`)),
|
||||
el('span', { class: 'dv-count' }, `${total} known${discovered.length ? ` · ${discovered.length} new` : ''}`),
|
||||
addToggle, scanBtn),
|
||||
addForm,
|
||||
...sections,
|
||||
discPanel);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { serviceTile } from '../components/service_tile.js';
|
||||
import { isRemoteHost } from './service_url.js';
|
||||
|
||||
const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' };
|
||||
const CATS = ['agents', 'infrastructure', 'media', 'other'];
|
||||
let host, timer, scanning = false;
|
||||
|
||||
async function promote(id) {
|
||||
@@ -17,6 +18,36 @@ function scan() {
|
||||
setTimeout(() => { scanning = false; load(); }, 30000); // LAN sweep ~25s
|
||||
}
|
||||
|
||||
// Inline edit form for a service (name / category / url / icon) — PATCH or DELETE.
|
||||
function editForm(s) {
|
||||
const nameI = el('input', { class: 'dv-edit-name', value: s.name || '', placeholder: 'name' });
|
||||
const catS = el('select', { class: 'dv-edit-grp' }, ...CATS.map(c => el('option', { value: c }, TITLE[c])));
|
||||
catS.value = s.category || 'other';
|
||||
const urlI = el('input', { class: 'dv-edit-name', value: s.url || '', placeholder: 'http://host:port' });
|
||||
const iconI = el('input', { class: 'dv-edit-name', value: s.icon || '', placeholder: 'icon slug e.g. plex' });
|
||||
const save = el('button', { class: 'dv-add' }, 'Save');
|
||||
save.onclick = async () => {
|
||||
const patch = { name: nameI.value.trim(), category: catS.value, url: urlI.value.trim() };
|
||||
const ic = iconI.value.trim(); if (ic) patch.icon = ic;
|
||||
try { await api.patch('/api/health/services/' + s.id, patch); load(); } catch { /* */ }
|
||||
};
|
||||
const del = el('button', { class: 'ghost dv-ignore' }, 'Delete');
|
||||
del.onclick = async () => { try { await api.del('/api/health/services/' + s.id); load(); } catch { /* */ } };
|
||||
const cancel = el('button', { class: 'ghost' }, 'Cancel');
|
||||
cancel.onclick = load;
|
||||
return el('div', { class: 'tile lb-edit' }, nameI, catS, urlI, iconI,
|
||||
el('div', { class: 'lb-edit-btns' }, save, del, cancel));
|
||||
}
|
||||
|
||||
// A service tile wrapped with an ✎ edit button that swaps to the edit form.
|
||||
function tileWithEdit(s, remote) {
|
||||
const wrap = el('div', { class: 'lb-tile-wrap' });
|
||||
const edit = el('button', { class: 'lb-edit-btn', title: 'Edit service' }, '✎');
|
||||
edit.onclick = (e) => { e.preventDefault(); e.stopPropagation(); mount(wrap, editForm(s)); };
|
||||
mount(wrap, serviceTile(s, remote), edit);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// Owner-only; returns a section element or null (skipped for non-owner / none).
|
||||
async function discoveredSection() {
|
||||
let cand;
|
||||
@@ -30,8 +61,8 @@ async function discoveredSection() {
|
||||
el('div', { class: 'tiles' }, cand.map(c =>
|
||||
el('div', { class: 'tile disc' },
|
||||
el('div', { class: 'tile-main' },
|
||||
el('div', { class: 'tile-nm' }, c.name),
|
||||
el('div', { class: 'tile-host' }, c.url)),
|
||||
el('div', { class: 'tile-nm' }, c.device || c.name),
|
||||
el('div', { class: 'tile-host' }, c.device ? `${c.name} · ${c.url}` : c.url)),
|
||||
el('button', { class: 'disc-add', title: 'Add to the band', onclick: () => promote(c.id) }, '+')))));
|
||||
}
|
||||
|
||||
@@ -46,7 +77,7 @@ async function load() {
|
||||
el('span', { class: 'gname' }, TITLE[g.category] || g.category),
|
||||
el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`),
|
||||
el('span', { class: 'line' })),
|
||||
el('div', { class: 'tiles' }, g.services.map(s => serviceTile(s, remote)))));
|
||||
el('div', { class: 'tiles' }, g.services.map(s => tileWithEdit(s, remote)))));
|
||||
const disc = await discoveredSection();
|
||||
mount(host,
|
||||
el('div', { class: 'lbwrap' }, littleblueAvatar(),
|
||||
|
||||
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;
|
||||
}
|
||||
70
public/views/icon_sets_panel.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// Icon sets management panel — list, upload, delete custom icon sets.
|
||||
import { el, mount, clear } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
export function iconSetsPanel() {
|
||||
const root = el('div', { class: 'icon-sets-panel' });
|
||||
|
||||
async function refresh() {
|
||||
clear(root);
|
||||
let list = [];
|
||||
try { list = await api.get('/api/icon-sets'); } catch {
|
||||
root.appendChild(el('div', { class: 'muted' }, 'unavailable'));
|
||||
return;
|
||||
}
|
||||
for (const s of list) {
|
||||
const grid = el('div', { class: 'ip-grid' },
|
||||
s.icons.map(f =>
|
||||
el('div', { class: 'ip-icon' },
|
||||
el('img', { src: `/api/icon-sets/${s.set}/${f}`, title: f })
|
||||
)
|
||||
)
|
||||
);
|
||||
const head = el('div', { class: 'isp-hd' },
|
||||
el('b', {}, s.set),
|
||||
el('span', { class: 'muted' }, ` ${s.icons.length}`)
|
||||
);
|
||||
if (!s.readonly) {
|
||||
const del = el('button', { class: 'ghost' }, 'Delete');
|
||||
del.addEventListener('click', async () => {
|
||||
await api.del('/api/icon-sets/' + s.set);
|
||||
refresh();
|
||||
});
|
||||
head.appendChild(del);
|
||||
}
|
||||
root.appendChild(el('div', { class: 'isp-set' }, head, grid));
|
||||
}
|
||||
root.appendChild(uploadForm(refresh));
|
||||
}
|
||||
|
||||
refresh();
|
||||
return root;
|
||||
}
|
||||
|
||||
function uploadForm(onDone) {
|
||||
const setI = el('input', { class: 'dv-edit-name', placeholder: 'new set name (a-z0-9-)' });
|
||||
const fileI = el('input', { type: 'file', accept: '.svg,.png,.zip', multiple: true });
|
||||
const urlI = el('input', { class: 'dv-edit-name', placeholder: 'or ingest from URL (image or .zip)' });
|
||||
const err = el('span', { class: 'muted', style: { fontSize: '11px' } }, '');
|
||||
const up = el('button', { class: 'dv-add' }, 'Upload');
|
||||
|
||||
up.addEventListener('click', async () => {
|
||||
const set = setI.value.trim().toLowerCase();
|
||||
if (!/^[a-z0-9-]+$/.test(set)) { err.textContent = 'set name: a-z 0-9 - only'; return; }
|
||||
if (!fileI.files.length && !urlI.value.trim()) { err.textContent = 'pick files or a URL'; return; }
|
||||
const fd = new FormData();
|
||||
for (const f of fileI.files) fd.append('files', f);
|
||||
if (urlI.value.trim()) fd.append('url', urlI.value.trim());
|
||||
up.textContent = 'Uploading…'; up.disabled = true;
|
||||
try {
|
||||
await api.postForm('/api/icon-sets/' + set, fd);
|
||||
onDone();
|
||||
} catch {
|
||||
err.textContent = 'upload failed';
|
||||
up.textContent = 'Upload';
|
||||
up.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
return el('div', { class: 'isp-upload' }, setI, fileI, urlI, up, err);
|
||||
}
|
||||
24
public/views/icon_util.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// public/views/icon_util.js — pure helpers (no DOM), unit-tested.
|
||||
const GROUP_DEFAULT = {
|
||||
Network: 'router', Entertainment: 'tv', 'Smart Home': 'plug', Personal: 'phone'
|
||||
};
|
||||
export function autoDefaultIcon(grp) {
|
||||
return `set:devices:${GROUP_DEFAULT[grp] || 'unknown'}`;
|
||||
}
|
||||
// Note: bundled 'devices' icons are .svg; brand icons are served .png by the proxy.
|
||||
export function resolveIcon(ref) {
|
||||
if (typeof ref !== 'string') return null;
|
||||
let m = ref.match(/^set:([a-z0-9-]+):([a-z0-9-]+)$/);
|
||||
if (m) return `/api/icon-sets/${m[1]}/${m[2]}.svg`;
|
||||
m = ref.match(/^brand:([a-z0-9-]+)$/);
|
||||
if (m) return `/api/icons/${m[1]}.png`;
|
||||
return null;
|
||||
}
|
||||
export function relativeTime(iso, now = Date.now()) {
|
||||
const t = typeof iso === 'number' ? iso : Date.parse(iso);
|
||||
const s = Math.max(0, Math.floor((now - t) / 1000));
|
||||
if (s < 60) return 'just now';
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||
return `${Math.floor(s / 86400)}d ago`;
|
||||
}
|
||||
39
public/views/links.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// #/links — Hybrid Apps view: a Void-native card (update-tracker + quick-add) on
|
||||
// top of the embedded themed Kutt UI. Reuses the .term-bar/.term-frame embed classes.
|
||||
import { el, mount } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const SRC = 'https://link.hynesy.com/';
|
||||
|
||||
export async function render(main) {
|
||||
const badge = el('span', { class: 'lk-badge muted' }, 'checking…');
|
||||
const out = el('span', { class: 'lk-out muted' }, '');
|
||||
const input = el('input', { class: 'lk-url', placeholder: 'https://long-url-to-shorten…' });
|
||||
const add = el('button', { class: 'primary' }, '◆ Shorten');
|
||||
add.onclick = async () => {
|
||||
const target = input.value.trim(); if (!target) return;
|
||||
out.textContent = 'creating…';
|
||||
try { const r = await api.post('/api/kutt', { target }); out.innerHTML = ''; out.appendChild(el('a', { href: r.link, target: '_blank', rel: 'noopener' }, r.link)); input.value = ''; }
|
||||
catch { out.textContent = 'failed (is Kutt reachable / API key set?)'; }
|
||||
};
|
||||
|
||||
mount(main,
|
||||
el('div', { class: 'term-bar' },
|
||||
el('span', { class: 'term-title' }, '◆ Links'),
|
||||
el('a', { class: 'ghost', style: { marginLeft: 'auto' }, href: SRC, target: '_blank', rel: 'noopener' }, '↗ Open Kutt')
|
||||
),
|
||||
el('div', { class: 'card lk-card' },
|
||||
el('div', { class: 'lk-row' }, el('span', { class: 'muted' }, 'Kutt version'), badge),
|
||||
el('div', { class: 'lk-quickadd' }, input, add),
|
||||
el('div', {}, out)
|
||||
),
|
||||
el('iframe', { id: 'embed-frame', src: SRC, class: 'term-frame' })
|
||||
);
|
||||
|
||||
try {
|
||||
const v = await api.get('/api/kutt/version');
|
||||
badge.classList.remove('muted');
|
||||
if (v.updateAvailable) { badge.classList.add('lk-update'); badge.innerHTML = ''; badge.appendChild(el('a', { href: v.url, target: '_blank', rel: 'noopener' }, `${v.running} → ${v.latest} · update available`)); }
|
||||
else badge.textContent = `${v.running} · up to date`;
|
||||
} catch { badge.textContent = 'version check unavailable'; }
|
||||
}
|
||||
6
public/views/mirror.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// public/views/mirror.js — #/mirror (MagicMirror² on CT 111)
|
||||
import { embedView } from './embed.js';
|
||||
export const render = embedView({
|
||||
title: 'MagicMirror', sub: 'smart mirror dashboard',
|
||||
src: 'https://mirror.hynesy.com/'
|
||||
});
|
||||
27
public/views/obd2.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// #/obd2 — Apps rail placeholder for the OBD2 Telemetry project (parked).
|
||||
// No records UI is deployed yet, so this links into the project + wiki instead of
|
||||
// embedding. Swap to embedView({ src: 'https://obd2.hynesy.com/' }) once the
|
||||
// LubeLogger/Tracktor dashboard is up.
|
||||
import { el, mount } from '../dom.js';
|
||||
import { navigate } from '../router.js';
|
||||
|
||||
const WIKI = '/page/bea9d582-44a2-4eec-a1ba-69ade15d3a73';
|
||||
const PROJECT = '/project/02fc5b4c-12f4-4d0c-8220-6b053da71c46';
|
||||
|
||||
export async function render(main) {
|
||||
mount(main,
|
||||
el('div', { class: 'term-bar' },
|
||||
el('span', { class: 'term-title' }, '◆ OBD2 Telemetry'),
|
||||
el('span', { class: 'muted', style: { fontSize: '11px' } }, 'project · parked, being set up')
|
||||
),
|
||||
el('div', { class: 'card', style: { maxWidth: '760px' } },
|
||||
el('h3', {}, 'OBD2 Telemetry — being set up'),
|
||||
el('p', { class: 'muted' }, 'Capture vehicle records from the car’s OBD2 port into the homelab (CT 112 · Postgres + TimescaleDB) with a maintenance/records UI. The capture pipeline is being rebuilt and the records UI isn’t deployed yet — nothing to embed here yet.'),
|
||||
el('p', {}, 'Plan: AndrOBD (F-Droid) + the BT ELM327 → CSV/MQTT → Timescale; WiCAN hardware later; LubeLogger / Tracktor for the UI (this tile will then embed it).'),
|
||||
el('div', { style: { display: 'flex', gap: '8px', marginTop: '14px' } },
|
||||
el('button', { class: 'primary', onclick: () => navigate(PROJECT) }, 'Project + tasks'),
|
||||
el('button', { class: 'ghost', onclick: () => navigate(WIKI) }, 'Research / wiki')
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import { api } from '../api.js';
|
||||
import { renderHealthBand, stopHealthBand } from './health_band.js';
|
||||
import { renderDevicesBand, stopDevicesBand } from './devices_band.js';
|
||||
import { svCard } from '../components/sv_card.js';
|
||||
import { moveId } from '../components/sv_reorder.js';
|
||||
import { orderCards } from './cards/registry.js';
|
||||
import clock from './cards/clock.js';
|
||||
import weather from './cards/weather.js';
|
||||
import hostPerf from './cards/host_perf.js';
|
||||
@@ -14,127 +12,230 @@ import search from './cards/search.js';
|
||||
import speedtest from './cards/speedtest.js';
|
||||
import aiUsage from './cards/ai_usage.js';
|
||||
import cluster from './cards/cluster.js';
|
||||
import storage from './cards/storage.js';
|
||||
import backups from './cards/backups.js';
|
||||
import { blankCard } from './cards/blank.js';
|
||||
import { blackflameCard } from './cards/blackflame.js';
|
||||
|
||||
const CARD_MODULES = [clock, weather, hostPerf, cluster, jobs, inbox, search, speedtest, aiUsage];
|
||||
const BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
|
||||
const CARD_MODULES = [clock, weather, hostPerf, cluster, storage, backups, jobs, inbox, search, speedtest, aiUsage];
|
||||
const BUILTIN_BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
|
||||
|
||||
// ---- hybrid canvas geometry ----
|
||||
// Cards are absolutely placed on a 12-column grid. {x,y,w,h} are in grid units
|
||||
// (x,w in columns; y,h in rows of ROW_H px). Snap mode keeps them integer; a
|
||||
// per-card `free` flag (or holding Alt while dragging) allows fractional
|
||||
// placement + overlap. Everything scales with board width, so x/w stay relative.
|
||||
const COLS = 12;
|
||||
const ROW_H = 28; // px per grid row
|
||||
const GUTTER = 12; // visual gap baked into each card's rendered size
|
||||
const SIZE_W = { s: 3, m: 4, l: 6 };
|
||||
const SIZE_H = { s: 6, m: 8, l: 10 };
|
||||
|
||||
let active = []; // mounted cards needing stop()
|
||||
let renderGen = 0; // guards overlapping async renders
|
||||
let editing = false;
|
||||
let layout = { card_order: [], hidden: [], sizes: {} };
|
||||
let mainEl;
|
||||
let layout = { hidden: [], geom: {}, extras: [] };
|
||||
|
||||
const grid = () => document.getElementById('sv-cards');
|
||||
|
||||
async function saveLayout() {
|
||||
try { await api.put('/api/dashboard/layout', layout); }
|
||||
catch (e) { console.error('save layout', e); }
|
||||
function defFor(extra) {
|
||||
if (extra.type === 'blank') return blankCard(extra.id);
|
||||
if (extra.type === 'blackflame') return blackflameCard(extra.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- per-card edit controls (drag grip + size + hide), shown only in edit mode via CSS
|
||||
const STR_SPAN = { s: 2, m: 6, l: 12 }; // legacy size → 12-col span (s = 1/6, m = 1/2, l = full)
|
||||
function spanOf(def) {
|
||||
const v = layout.sizes?.[def.id];
|
||||
if (typeof v === 'number') return Math.max(1, Math.min(12, v));
|
||||
if (typeof v === 'string') return STR_SPAN[v] || 6;
|
||||
return STR_SPAN[def.size] || 6;
|
||||
function visibleDefs() {
|
||||
const hidden = new Set(layout.hidden || []);
|
||||
const builtins = CARD_MODULES.filter(d => !hidden.has(d.id));
|
||||
const extras = (layout.extras || []).map(defFor).filter(Boolean);
|
||||
return [...builtins, ...extras];
|
||||
}
|
||||
function curSpan(id) {
|
||||
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
|
||||
const m = node && (node.style.gridColumn || '').match(/span (\d+)/);
|
||||
return m ? +m[1] : spanOf(BY_ID.get(id) || {});
|
||||
|
||||
function defaultSize(def) {
|
||||
if (def.type === 'blackflame') return { w: 6, h: 10 };
|
||||
if (def.type === 'blank') return { w: 3, h: 4 };
|
||||
return { w: SIZE_W[def.size] || 4, h: SIZE_H[def.size] || 8 };
|
||||
}
|
||||
function setSpan(id, delta) {
|
||||
const span = Math.max(1, Math.min(12, curSpan(id) + delta));
|
||||
layout.sizes = { ...layout.sizes, [id]: span };
|
||||
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
|
||||
if (node) {
|
||||
node.style.gridColumn = 'span ' + span;
|
||||
const lbl = node.querySelector('.sv-span-val');
|
||||
if (lbl) lbl.textContent = span;
|
||||
|
||||
function geomOf(def) {
|
||||
const g = (layout.geom || {})[def.id];
|
||||
if (g) return g;
|
||||
const { w, h } = defaultSize(def);
|
||||
return { x: 0, y: 0, w, h };
|
||||
}
|
||||
|
||||
async function saveLayout() {
|
||||
try {
|
||||
await api.put('/api/dashboard/layout', {
|
||||
card_order: [], sizes: {},
|
||||
hidden: layout.hidden || [], geom: layout.geom || {}, extras: layout.extras || []
|
||||
});
|
||||
} catch (e) { console.error('save layout', e); }
|
||||
}
|
||||
|
||||
// Backfill geometry for any visible card lacking it (first load / migration /
|
||||
// a newly-shipped built-in). New cards flow below whatever already has a spot.
|
||||
function autoPlaceMissing(defs) {
|
||||
const geom = { ...(layout.geom || {}) };
|
||||
let baseY = 0;
|
||||
for (const id in geom) baseY = Math.max(baseY, geom[id].y + geom[id].h);
|
||||
let cx = 0, cy = baseY, rowH = 0;
|
||||
for (const d of defs) {
|
||||
if (geom[d.id]) continue;
|
||||
const { w, h } = defaultSize(d);
|
||||
if (cx + w > COLS) { cx = 0; cy += rowH; rowH = 0; }
|
||||
geom[d.id] = { x: cx, y: cy, w, h };
|
||||
cx += w; rowH = Math.max(rowH, h);
|
||||
}
|
||||
layout.geom = geom;
|
||||
}
|
||||
|
||||
function cellW() { return grid().clientWidth / COLS; }
|
||||
|
||||
function applyGeom(node, g) {
|
||||
const cw = cellW();
|
||||
node.style.position = 'absolute';
|
||||
node.style.left = (g.x * cw) + 'px';
|
||||
node.style.top = (g.y * ROW_H) + 'px';
|
||||
node.style.width = Math.max(40, g.w * cw - GUTTER) + 'px';
|
||||
node.style.height = Math.max(40, g.h * ROW_H - GUTTER) + 'px';
|
||||
node.style.zIndex = g.free ? 5 : 1;
|
||||
node.classList.toggle('free', !!g.free);
|
||||
}
|
||||
|
||||
function fitBoard() {
|
||||
let max = 0;
|
||||
grid().querySelectorAll('.sv-card').forEach(n => { max = Math.max(max, n.offsetTop + n.offsetHeight); });
|
||||
grid().style.height = (max + GUTTER) + 'px';
|
||||
}
|
||||
|
||||
function relayout() {
|
||||
active.forEach(def => {
|
||||
const n = grid().querySelector(`.sv-card[data-card-id="${def.id}"]`);
|
||||
if (n) applyGeom(n, geomOf(def));
|
||||
});
|
||||
fitBoard();
|
||||
}
|
||||
|
||||
// ---- drag / resize (pointer-based; snap unless free or Alt held) ----
|
||||
function beginDrag(ev, def, mode) {
|
||||
if (!editing) return;
|
||||
ev.preventDefault(); ev.stopPropagation();
|
||||
const node = grid().querySelector(`.sv-card[data-card-id="${def.id}"]`);
|
||||
if (!node) return;
|
||||
const g = { ...geomOf(def) };
|
||||
const start = { px: ev.clientX, py: ev.clientY, x: g.x, y: g.y, w: g.w, h: g.h };
|
||||
node.classList.add('dragging');
|
||||
const cw = cellW();
|
||||
|
||||
function moveTo(e) {
|
||||
const dxc = (e.clientX - start.px) / cw;
|
||||
const dyc = (e.clientY - start.py) / ROW_H;
|
||||
const freeNow = g.free || e.altKey;
|
||||
if (mode === 'move') {
|
||||
let nx = start.x + dxc, ny = start.y + dyc;
|
||||
if (!freeNow) { nx = Math.round(nx); ny = Math.round(ny); }
|
||||
g.x = Math.max(0, Math.min(COLS - g.w, nx));
|
||||
g.y = Math.max(0, ny);
|
||||
} else {
|
||||
let nw = start.w + dxc, nh = start.h + dyc;
|
||||
if (!freeNow) { nw = Math.round(nw); nh = Math.round(nh); }
|
||||
g.w = Math.max(2, Math.min(COLS - g.x, nw));
|
||||
g.h = Math.max(2, nh);
|
||||
}
|
||||
applyGeom(node, g); fitBoard();
|
||||
}
|
||||
function end() {
|
||||
document.removeEventListener('pointermove', moveTo);
|
||||
document.removeEventListener('pointerup', end);
|
||||
node.classList.remove('dragging');
|
||||
layout.geom = { ...layout.geom, [def.id]: g };
|
||||
applyGeom(node, g);
|
||||
saveLayout();
|
||||
}
|
||||
document.addEventListener('pointermove', moveTo);
|
||||
document.addEventListener('pointerup', end);
|
||||
}
|
||||
|
||||
function toggleFree(id) {
|
||||
const g = { ...(layout.geom[id] || geomOf({ id })) };
|
||||
g.free = !g.free;
|
||||
layout.geom = { ...layout.geom, [id]: g };
|
||||
const n = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
|
||||
if (n) applyGeom(n, g);
|
||||
saveLayout();
|
||||
}
|
||||
|
||||
function editOverlay(def) {
|
||||
const grip = el('span', { class: 'sv-grip', draggable: true, title: 'Drag to reorder' }, '⠿');
|
||||
const stepper = el('span', { class: 'sv-ed-span' },
|
||||
el('button', { class: 'sv-ed-step', title: 'Narrower', onclick: () => setSpan(def.id, -1) }, '−'),
|
||||
el('span', { class: 'sv-span-val', title: 'Width (of 12)' }, String(spanOf(def))),
|
||||
el('button', { class: 'sv-ed-step', title: 'Wider', onclick: () => setSpan(def.id, +1) }, '+'));
|
||||
const hide = el('button', { class: 'sv-ed-hide', title: 'Hide card', onclick: () => hideCard(def.id) }, '✕');
|
||||
return el('div', { class: 'sv-card-edit' }, grip, stepper, hide);
|
||||
const grip = el('span', { class: 'sv-grip', title: 'Drag to move' }, '⠿');
|
||||
grip.addEventListener('pointerdown', e => beginDrag(e, def, 'move'));
|
||||
const free = el('button', { class: 'sv-ed-free', title: 'Free / snap placement', onclick: () => toggleFree(def.id) }, '⤢');
|
||||
const hide = el('button', { class: 'sv-ed-hide', title: def.decorative ? 'Delete card' : 'Hide card', onclick: () => removeCard(def) }, '✕');
|
||||
const resize = el('span', { class: 'sv-resize', title: 'Drag to resize' });
|
||||
resize.addEventListener('pointerdown', e => beginDrag(e, def, 'resize'));
|
||||
const frag = document.createDocumentFragment();
|
||||
frag.append(el('div', { class: 'sv-card-edit' }, grip, free, hide), resize);
|
||||
return frag;
|
||||
}
|
||||
|
||||
function mountOne(def) {
|
||||
const span = spanOf(def);
|
||||
const { root, body } = svCard({ ...def, span });
|
||||
const { root, body } = svCard(def);
|
||||
if (def.decorative) root.classList.add('sv-card-decor');
|
||||
root.appendChild(editOverlay(def));
|
||||
applyGeom(root, geomOf(def));
|
||||
grid().appendChild(root);
|
||||
try { def.mount(body); def.start && def.start(); active.push(def); }
|
||||
catch (e) { body.appendChild(el('span', { class: 'muted' }, 'card failed')); console.error(def.id, e); }
|
||||
}
|
||||
|
||||
function setSize(id, s) {
|
||||
layout.sizes = { ...layout.sizes, [id]: s };
|
||||
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
|
||||
if (node) node.dataset.size = s;
|
||||
saveLayout();
|
||||
function placeNew(def) {
|
||||
let maxBottom = 0;
|
||||
for (const id in layout.geom) { const g = layout.geom[id]; maxBottom = Math.max(maxBottom, g.y + g.h); }
|
||||
const { w, h } = defaultSize(def);
|
||||
layout.geom = { ...layout.geom, [def.id]: { x: 0, y: maxBottom, w, h } };
|
||||
}
|
||||
|
||||
function hideCard(id) {
|
||||
if (!layout.hidden.includes(id)) layout.hidden = [...layout.hidden, id];
|
||||
const def = BY_ID.get(id);
|
||||
if (def?.stop) def.stop();
|
||||
active = active.filter(d => d.id !== id);
|
||||
grid().querySelector(`.sv-card[data-card-id="${id}"]`)?.remove();
|
||||
renderTray();
|
||||
saveLayout();
|
||||
function addBuiltin(id) {
|
||||
layout.hidden = (layout.hidden || []).filter(x => x !== id);
|
||||
const def = BUILTIN_BY_ID.get(id);
|
||||
if (!def) return;
|
||||
placeNew(def); mountOne(def); relayout(); renderTray(); saveLayout();
|
||||
}
|
||||
|
||||
function showCard(id) {
|
||||
layout.hidden = layout.hidden.filter(x => x !== id);
|
||||
const def = BY_ID.get(id);
|
||||
if (def) { mountOne(def); wireDrag(); }
|
||||
renderTray();
|
||||
saveLayout();
|
||||
function addDecor(type) {
|
||||
const id = `${type}-${Date.now().toString(36)}`;
|
||||
const def = defFor({ id, type });
|
||||
if (!def) return;
|
||||
layout.extras = [...(layout.extras || []), { id, type }];
|
||||
placeNew(def); mountOne(def); relayout(); renderTray(); saveLayout();
|
||||
}
|
||||
|
||||
function onReorder(newOrder) {
|
||||
const frag = document.createDocumentFragment();
|
||||
newOrder.forEach(id => { const n = grid().querySelector(`.sv-card[data-card-id="${id}"]`); if (n) frag.appendChild(n); });
|
||||
grid().appendChild(frag);
|
||||
layout.card_order = newOrder;
|
||||
saveLayout();
|
||||
}
|
||||
|
||||
// Drag from the grip only; the rest of the card is inert so the search box etc. work.
|
||||
function wireDrag() {
|
||||
let dragId = null;
|
||||
grid().querySelectorAll('.sv-card').forEach(card => {
|
||||
if (card._wired) return; card._wired = true;
|
||||
const g = card.querySelector('.sv-grip');
|
||||
if (g) {
|
||||
g.addEventListener('dragstart', (e) => { dragId = card.dataset.cardId; card.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; });
|
||||
g.addEventListener('dragend', () => { card.classList.remove('dragging'); dragId = null; });
|
||||
}
|
||||
card.addEventListener('dragover', (e) => { if (dragId) { e.preventDefault(); card.classList.add('drag-over'); } });
|
||||
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
|
||||
card.addEventListener('drop', (e) => {
|
||||
e.preventDefault(); card.classList.remove('drag-over');
|
||||
if (!dragId || dragId === card.dataset.cardId) return;
|
||||
const ids = [...grid().querySelectorAll('.sv-card')].map(c => c.dataset.cardId);
|
||||
onReorder(moveId(ids, dragId, card.dataset.cardId));
|
||||
});
|
||||
});
|
||||
function removeCard(def) {
|
||||
const d = active.find(a => a.id === def.id);
|
||||
if (d && d.stop) d.stop();
|
||||
active = active.filter(a => a.id !== def.id);
|
||||
grid().querySelector(`.sv-card[data-card-id="${def.id}"]`)?.remove();
|
||||
if (def.decorative) {
|
||||
layout.extras = (layout.extras || []).filter(e => e.id !== def.id);
|
||||
const g = { ...layout.geom }; delete g[def.id]; layout.geom = g;
|
||||
} else if (!(layout.hidden || []).includes(def.id)) {
|
||||
layout.hidden = [...(layout.hidden || []), def.id];
|
||||
}
|
||||
renderTray(); fitBoard(); saveLayout();
|
||||
}
|
||||
|
||||
function renderTray() {
|
||||
const tray = document.getElementById('sv-tray');
|
||||
if (!tray) return;
|
||||
const hidden = layout.hidden.map(id => BY_ID.get(id)).filter(Boolean);
|
||||
const hidden = (layout.hidden || []).map(id => BUILTIN_BY_ID.get(id)).filter(Boolean);
|
||||
mount(tray,
|
||||
hidden.length ? el('span', { class: 'sv-tray-label' }, 'Hidden:') : el('span', { class: 'muted' }, 'No hidden cards'),
|
||||
...hidden.map(def => el('button', { class: 'sv-tray-chip', onclick: () => showCard(def.id) }, '+ ' + def.title)));
|
||||
el('span', { class: 'sv-tray-label' }, 'Add card:'),
|
||||
el('button', { class: 'sv-tray-chip', onclick: () => addDecor('blank') }, '+ Blank'),
|
||||
el('button', { class: 'sv-tray-chip', onclick: () => addDecor('blackflame') }, '+ Blackflame'),
|
||||
hidden.length ? el('span', { class: 'sv-tray-label', style: { marginLeft: '8px' } }, 'Restore:') : null,
|
||||
...hidden.map(def => el('button', { class: 'sv-tray-chip', onclick: () => addBuiltin(def.id) }, '+ ' + def.title)),
|
||||
el('span', { class: 'sv-tray-hint muted' }, 'drag ⠿ to move · corner to resize · ⤢ = free/overlap · Alt = no-snap'));
|
||||
tray.style.display = editing ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
@@ -148,9 +249,8 @@ function toggleEdit() {
|
||||
renderTray();
|
||||
}
|
||||
|
||||
let mainEl;
|
||||
async function resetLayout() {
|
||||
layout = { card_order: [], hidden: [], sizes: {} };
|
||||
layout = { hidden: [], geom: {}, extras: [] };
|
||||
await saveLayout();
|
||||
render(mainEl);
|
||||
}
|
||||
@@ -173,12 +273,21 @@ export async function render(main) {
|
||||
el('div', { id: 'sv-devices' })
|
||||
);
|
||||
|
||||
layout = { card_order: [], hidden: [], sizes: {} };
|
||||
try { layout = await api.get('/api/dashboard/layout'); } catch { /* defaults */ }
|
||||
layout = { hidden: [], geom: {}, extras: [] };
|
||||
try {
|
||||
const l = await api.get('/api/dashboard/layout');
|
||||
layout = { hidden: l.hidden || [], geom: l.geom || {}, extras: l.extras || [] };
|
||||
} catch { /* defaults */ }
|
||||
if (myGen !== renderGen) return;
|
||||
|
||||
for (const def of orderCards(CARD_MODULES, layout)) mountOne(def);
|
||||
wireDrag();
|
||||
const defs = visibleDefs();
|
||||
autoPlaceMissing(defs);
|
||||
for (const def of defs) mountOne(def);
|
||||
relayout();
|
||||
renderTray();
|
||||
window.removeEventListener('resize', relayout);
|
||||
window.addEventListener('resize', relayout);
|
||||
|
||||
renderHealthBand(document.getElementById('sv-health'));
|
||||
renderDevicesBand(document.getElementById('sv-devices'));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,59 @@
|
||||
// #/settings — API tokens, agents, and a placeholder for Orthos Mode.
|
||||
import { el, mount } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
import { iconSetsPanel } from './icon_sets_panel.js';
|
||||
import { THEME_VARS, PRESETS, applyTheme, clearTheme, saveTheme, currentTheme, effectiveHex, toHex6 } from '../theme.js';
|
||||
import { drossAvatar } from '../components/dross_avatar.js';
|
||||
|
||||
// Theming — colour pickers for the palette, live-preview on input, presets +
|
||||
// reset. Persists to /api/theme (app_settings); applied app-wide on next boot.
|
||||
function themingBody() {
|
||||
const cur = currentTheme(); // saved overrides (subset of vars)
|
||||
const grid = el('div', { class: 'theme-grid' });
|
||||
const out = el('span', { class: 'muted', style: { marginLeft: '8px' } });
|
||||
|
||||
function rebuild() {
|
||||
mount(grid, THEME_VARS.map(v => {
|
||||
const inp = el('input', { type: 'color', value: cur[v.key] ? toHex6(cur[v.key]) : effectiveHex(v.key) });
|
||||
inp.addEventListener('input', () => {
|
||||
cur[v.key] = inp.value;
|
||||
document.documentElement.style.setProperty(v.css, inp.value); // live preview
|
||||
});
|
||||
return el('label', { class: 'theme-row' }, el('span', {}, v.label), inp);
|
||||
}));
|
||||
}
|
||||
rebuild();
|
||||
|
||||
const preset = el('select', { class: 'pm-input', style: { maxWidth: '160px' } },
|
||||
el('option', { value: '' }, 'Apply preset…'),
|
||||
...Object.keys(PRESETS).map(n => el('option', { value: n }, n)));
|
||||
preset.addEventListener('change', () => {
|
||||
if (!preset.value) return;
|
||||
clearTheme();
|
||||
for (const k of Object.keys(cur)) delete cur[k];
|
||||
Object.assign(cur, PRESETS[preset.value]);
|
||||
applyTheme(cur);
|
||||
rebuild();
|
||||
preset.value = '';
|
||||
});
|
||||
|
||||
const save = el('button', { class: 'primary' }, 'Save theme');
|
||||
save.onclick = async () => {
|
||||
try { await saveTheme(cur); out.textContent = 'Saved — applies everywhere.'; }
|
||||
catch { out.textContent = 'Save failed'; }
|
||||
};
|
||||
const reset = el('button', { class: 'ghost' }, 'Reset to Blackflame');
|
||||
reset.onclick = async () => {
|
||||
for (const k of Object.keys(cur)) delete cur[k];
|
||||
clearTheme();
|
||||
try { await saveTheme({}); rebuild(); out.textContent = 'Reset to default.'; }
|
||||
catch { out.textContent = 'Reset failed'; }
|
||||
};
|
||||
|
||||
return el('div', { class: 'settings-body' },
|
||||
grid,
|
||||
el('div', { class: 'theme-actions' }, preset, save, reset, out));
|
||||
}
|
||||
|
||||
function section(title, sub, bodyEl) {
|
||||
return el('div', { class: 'card settings-card' },
|
||||
@@ -96,13 +149,107 @@ async function renderAgents(c) {
|
||||
}
|
||||
}
|
||||
|
||||
function drossBody() {
|
||||
const out = el('span', { class: 'muted', style: { marginLeft: '8px' } });
|
||||
let cur = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review', keepClips: false };
|
||||
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)'));
|
||||
const keep = el('input', { type: 'checkbox' });
|
||||
const clipsWrap = el('div', { class: 'dross-clips' });
|
||||
|
||||
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 function loadClips() {
|
||||
if (!keep.checked) { mount(clipsWrap); return; }
|
||||
let rows = [];
|
||||
try { rows = await api.get('/api/voice/clips'); } catch { mount(clipsWrap, el('span', { class: 'muted' }, 'Clips unavailable')); return; }
|
||||
mount(clipsWrap, el('div', { class: 'st-lbl', style: { margin: '10px 0 4px' } }, `Saved clips (${rows.length})`),
|
||||
...rows.map(c => {
|
||||
const audio = el('audio');
|
||||
const play = el('button', { class: 'ghost' }, '▶');
|
||||
play.onclick = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/voice/clips/' + c.id + '/audio',
|
||||
{ headers: { Authorization: 'Bearer ' + (localStorage.getItem('void_token') || '') } });
|
||||
audio.src = URL.createObjectURL(await res.blob()); audio.play();
|
||||
} catch { /* */ }
|
||||
};
|
||||
const del = el('button', { class: 'ghost dv-ignore' }, '✕');
|
||||
del.onclick = async () => { try { await api.del('/api/voice/clips/' + c.id); loadClips(); } catch { /* */ } };
|
||||
return el('div', { class: 'dross-clip' }, play, audio,
|
||||
el('span', { class: 'dross-clip-txt' }, c.transcript || '(no transcript)'),
|
||||
el('span', { class: 'muted', style: { fontSize: '10px' } },
|
||||
new Date(c.created_at).toLocaleString('en-AU', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })),
|
||||
del);
|
||||
}));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try { cur = { ...cur, ...(await api.get('/api/dross/settings')) }; } catch {}
|
||||
accent.value = cur.accent; persona.value = cur.persona; mode.value = cur.voiceMode;
|
||||
keep.checked = !!cur.keepClips; paintAvatars(); loadClips();
|
||||
})();
|
||||
accent.addEventListener('input', () => { cur.accent = accent.value; paintAvatars(); });
|
||||
keep.addEventListener('change', loadClips);
|
||||
|
||||
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, keepClips: keep.checked
|
||||
});
|
||||
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('label', { class: 'st-lbl' }, keep, ' Keep voice clips (saves audio + transcript, owner-only)'),
|
||||
el('div', { class: 'theme-actions' }, mode, save, out),
|
||||
clipsWrap);
|
||||
}
|
||||
|
||||
export async function render(main) {
|
||||
const tokensBody = el('div', { class: 'settings-body' });
|
||||
const agentsBody = el('div', { class: 'settings-body' });
|
||||
|
||||
// Icon sets — collapsible; panel is lazy-created on first expand so
|
||||
// /api/icon-sets is not fetched while the section is collapsed.
|
||||
let isPanel = null;
|
||||
const iconSetsWrap = el('div', { class: 'settings-body' });
|
||||
const isToggle = el('button', { class: 'ghost' }, '▸ Icon sets');
|
||||
isToggle.addEventListener('click', () => {
|
||||
if (!isPanel) {
|
||||
// First expand: create and append the panel.
|
||||
isPanel = iconSetsPanel();
|
||||
iconSetsWrap.appendChild(isPanel);
|
||||
}
|
||||
const open = isPanel.style.display !== 'none';
|
||||
isPanel.style.display = open ? 'none' : 'block';
|
||||
isToggle.textContent = (open ? '▸' : '▾') + ' Icon sets';
|
||||
});
|
||||
iconSetsWrap.appendChild(isToggle);
|
||||
|
||||
mount(main,
|
||||
el('h1', { class: 'view-h1' }, '◆ Settings'),
|
||||
section('Theming', 'Recolour the interface. Pick a colour to preview it live, choose a preset, then Save to persist. Reset returns to the default Blackflame palette.', themingBody()),
|
||||
section('Dross', "Your companion's look and voice. Avatar, accent colour, his personality (system prompt), and how voice clips behave.", drossBody()),
|
||||
section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody),
|
||||
section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody),
|
||||
section('Icon Sets', 'Upload or delete custom icon packs for device and service icons.', iconSetsWrap),
|
||||
section('Orthos Mode', 'Local-first answering — Orthos answers first, Claude escalates when needed.',
|
||||
el('div', { class: 'muted' }, 'Paused for a future project (arrives with the local-agent layer).'))
|
||||
);
|
||||
|
||||
185
public/views/speedtest.js
Normal file
@@ -0,0 +1,185 @@
|
||||
import { el, mount, safeHref } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const SVG = 'http://www.w3.org/2000/svg';
|
||||
const RANGES = [{ h: 24, l: '24h' }, { h: 168, l: '7d' }, { h: 720, l: '30d' }];
|
||||
const INTERVALS = [
|
||||
{ v: 15, l: 'Every 15 min' }, { v: 30, l: 'Every 30 min' }, { v: 60, l: 'Hourly' },
|
||||
{ v: 120, l: 'Every 2 h' }, { v: 360, l: 'Every 6 h' }, { v: 720, l: 'Every 12 h' },
|
||||
{ v: 1440, l: 'Daily' }
|
||||
];
|
||||
|
||||
let hours = 168;
|
||||
let timer = null;
|
||||
|
||||
const n1 = v => v == null ? '—' : Number(v).toFixed(1);
|
||||
const n0 = v => v == null ? '—' : Math.round(Number(v));
|
||||
function ago(ts) {
|
||||
if (!ts) return '—';
|
||||
const s = (Date.now() - new Date(ts).getTime()) / 1000;
|
||||
if (s < 90) return Math.round(s) + 's ago';
|
||||
if (s < 5400) return Math.round(s / 60) + 'm ago';
|
||||
if (s < 172800) return Math.round(s / 3600) + 'h ago';
|
||||
return Math.round(s / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
function mkSvg(tag, attrs) {
|
||||
const e = document.createElementNS(SVG, tag);
|
||||
for (const k in attrs) e.setAttribute(k, attrs[k]);
|
||||
return e;
|
||||
}
|
||||
|
||||
// Multi-series line chart over a shared time axis. rows ascending by ran_at.
|
||||
function chart(rows, series, h = 170) {
|
||||
if (!rows.length) return el('div', { class: 'muted', style: { padding: '24px 0' } }, 'No data in this window.');
|
||||
const W = 1000, H = h, padL = 42, padR = 10, padT = 10, padB = 18;
|
||||
const xs = rows.map(r => new Date(r.ran_at).getTime());
|
||||
const x0 = xs[0], x1 = xs[xs.length - 1] || x0 + 1;
|
||||
let vmax = 1;
|
||||
series.forEach(s => rows.forEach(r => { if (r[s.key] != null) vmax = Math.max(vmax, Number(r[s.key])); }));
|
||||
vmax *= 1.12;
|
||||
const X = t => padL + (x1 === x0 ? 0 : (t - x0) / (x1 - x0)) * (W - padL - padR);
|
||||
const Y = v => H - padB - (v / vmax) * (H - padT - padB);
|
||||
const svg = mkSvg('svg', { viewBox: `0 0 ${W} ${H}`, class: 'st-chart', preserveAspectRatio: 'none' });
|
||||
[0, 0.5, 1].forEach(f => {
|
||||
const y = Y(vmax * f);
|
||||
svg.appendChild(mkSvg('line', { x1: padL, x2: W - padR, y1: y, y2: y, stroke: '#ffffff10' }));
|
||||
const t = mkSvg('text', { x: 4, y: y + 3, fill: '#888094', 'font-size': 11 });
|
||||
t.textContent = Math.round(vmax * f); svg.appendChild(t);
|
||||
});
|
||||
series.forEach(s => {
|
||||
const pts = rows.filter(r => r[s.key] != null)
|
||||
.map(r => `${X(new Date(r.ran_at).getTime())},${Y(Number(r[s.key]))}`).join(' ');
|
||||
if (pts) svg.appendChild(mkSvg('polyline',
|
||||
{ points: pts, fill: 'none', stroke: s.color, 'stroke-width': 2, 'stroke-linejoin': 'round' }));
|
||||
});
|
||||
return svg;
|
||||
}
|
||||
|
||||
function legend(series) {
|
||||
return el('div', { class: 'st-legend' }, series.map(s =>
|
||||
el('span', { class: 'st-leg' }, el('i', { style: { background: s.color } }), s.label)));
|
||||
}
|
||||
|
||||
function kpi(label, value, sub, cls) {
|
||||
return el('div', { class: 'st-kpi' + (cls ? ' ' + cls : '') },
|
||||
el('span', { class: 'st-kpi-l' }, label),
|
||||
el('span', { class: 'st-kpi-v' }, value),
|
||||
sub ? el('span', { class: 'st-kpi-s' }, sub) : null);
|
||||
}
|
||||
|
||||
async function load(main) {
|
||||
let latest, statsd, rows, cfg;
|
||||
try {
|
||||
[latest, statsd, rows, cfg] = await Promise.all([
|
||||
api.get('/api/speedtest/latest'),
|
||||
api.get('/api/speedtest/stats?hours=' + hours),
|
||||
api.get('/api/speedtest/results?hours=' + hours),
|
||||
api.get('/api/speedtest/config')
|
||||
]);
|
||||
} catch { mount(main, el('span', { class: 'muted' }, 'Speedtest data unavailable')); return; }
|
||||
|
||||
const thr = Number(cfg.threshold_down_mbps) || 0;
|
||||
const lowNow = latest && thr > 0 && Number(latest.down_mbps) < thr;
|
||||
|
||||
const rangeBtns = el('div', { class: 'st-ranges' }, RANGES.map(r =>
|
||||
el('button', { class: 'st-range' + (r.h === hours ? ' on' : ''), onclick: () => { hours = r.h; load(main); } }, r.l)));
|
||||
|
||||
const runBtn = el('button', { class: 'primary' }, 'Run now');
|
||||
runBtn.onclick = async () => {
|
||||
runBtn.disabled = true; runBtn.textContent = 'Running…';
|
||||
try { await api.post('/api/speedtest/run', {}); } catch {}
|
||||
setTimeout(() => load(main), 35000);
|
||||
};
|
||||
|
||||
// schedule + threshold form
|
||||
const intSel = el('select', { class: 'pm-input' }, INTERVALS.map(i =>
|
||||
el('option', { value: String(i.v) }, i.l)));
|
||||
intSel.value = String(cfg.interval_min);
|
||||
const thrIn = el('input', { class: 'pm-input', type: 'number', min: '0', step: '10', value: String(thr), style: { maxWidth: '110px' } });
|
||||
const saveBtn = el('button', {}, 'Save');
|
||||
const saveOut = el('span', { class: 'muted', style: { marginLeft: '8px' } });
|
||||
saveBtn.onclick = async () => {
|
||||
try {
|
||||
await api.put('/api/speedtest/config', {
|
||||
interval_min: Number(intSel.value), threshold_down_mbps: Number(thrIn.value) || 0
|
||||
});
|
||||
saveOut.textContent = 'Saved'; setTimeout(() => load(main), 500);
|
||||
} catch { saveOut.textContent = 'Failed'; }
|
||||
};
|
||||
|
||||
mount(main,
|
||||
el('div', { class: 'st-head' },
|
||||
el('div', {},
|
||||
el('h1', { class: 'view-h1', style: { margin: 0 } }, 'Speedtest'),
|
||||
el('p', { class: 'view-sub', style: { margin: '2px 0 0' } },
|
||||
'Automated Ookla speed tests — history, trends & schedule.')),
|
||||
el('div', { class: 'st-actions' }, rangeBtns, runBtn)),
|
||||
|
||||
// latest snapshot
|
||||
el('div', { class: 'st-kpis' },
|
||||
kpi('Download', latest ? n0(latest.down_mbps) : '—', 'Mbps', lowNow ? 'bad' : 'down'),
|
||||
kpi('Upload', latest ? n0(latest.up_mbps) : '—', 'Mbps', 'up'),
|
||||
kpi('Ping', latest ? n1(latest.ping_ms) : '—', 'ms'),
|
||||
kpi('Jitter', latest ? n1(latest.jitter_ms) : '—', 'ms'),
|
||||
kpi('Packet loss', latest ? n1(latest.packet_loss) : '—', '%'),
|
||||
kpi('Last run', ago(latest && latest.ran_at), latest && latest.isp ? latest.isp : '')),
|
||||
|
||||
latest ? el('div', { class: 'st-meta muted' },
|
||||
latest.server_name ? `Server: ${latest.server_name}` : '',
|
||||
latest.result_url ? el('a', { href: safeHref(latest.result_url), target: '_blank', rel: 'noopener', class: 'st-link' }, ' view result ↗') : null,
|
||||
lowNow ? el('span', { class: 'st-warn' }, ` ⚠ below ${thr} Mbps threshold`) : null) : null,
|
||||
|
||||
// stats for the window
|
||||
el('div', { class: 'st-stats' },
|
||||
el('span', {}, `Avg ↓ ${n0(statsd.avg_down)}`),
|
||||
el('span', {}, `min ${n0(statsd.min_down)} · max ${n0(statsd.max_down)}`),
|
||||
el('span', {}, `Avg ↑ ${n0(statsd.avg_up)}`),
|
||||
el('span', {}, `Avg ping ${n1(statsd.avg_ping)} ms`),
|
||||
el('span', {}, `${n0(statsd.n)} tests`),
|
||||
Number(statsd.failures) ? el('span', { class: 'st-warn' }, `${statsd.failures} failed`) : null),
|
||||
|
||||
// charts
|
||||
el('h2', { class: 'st-h2' }, 'Throughput'),
|
||||
legend([{ color: 'var(--accent)', label: 'Download' }, { color: '#6fa86a', label: 'Upload' }]),
|
||||
chart(rows, [{ key: 'down_mbps', color: 'var(--accent)' }, { key: 'up_mbps', color: '#6fa86a' }], 180),
|
||||
el('h2', { class: 'st-h2' }, 'Latency'),
|
||||
legend([{ color: '#d4a04a', label: 'Ping (ms)' }, { color: '#7a7390', label: 'Jitter (ms)' }]),
|
||||
chart(rows, [{ key: 'ping_ms', color: '#d4a04a' }, { key: 'jitter_ms', color: '#7a7390' }], 130),
|
||||
|
||||
// schedule
|
||||
el('div', { class: 'card st-card' },
|
||||
el('h3', {}, 'Schedule & alert'),
|
||||
el('div', { class: 'st-form' },
|
||||
el('label', { class: 'st-lbl' }, 'Run', intSel),
|
||||
el('label', { class: 'st-lbl' }, 'Alert if ↓ below (Mbps)', thrIn),
|
||||
saveBtn, saveOut)),
|
||||
|
||||
// history table
|
||||
el('h2', { class: 'st-h2' }, 'History'),
|
||||
el('div', { class: 'st-table-wrap' },
|
||||
el('table', { class: 'st-table' },
|
||||
el('thead', {}, el('tr', {},
|
||||
...['Time', 'Down', 'Up', 'Ping', 'Jitter', 'Loss', 'Server', ''].map(h => el('th', {}, h)))),
|
||||
el('tbody', {}, [...rows].reverse().slice(0, 100).map(r =>
|
||||
el('tr', { class: r.ok ? '' : 'st-fail' },
|
||||
el('td', {}, new Date(r.ran_at).toLocaleString('en-AU', { hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })),
|
||||
el('td', { class: 'num' }, r.ok ? n0(r.down_mbps) : '✕'),
|
||||
el('td', { class: 'num' }, r.ok ? n0(r.up_mbps) : ''),
|
||||
el('td', { class: 'num' }, n1(r.ping_ms)),
|
||||
el('td', { class: 'num' }, n1(r.jitter_ms)),
|
||||
el('td', { class: 'num' }, r.packet_loss == null ? '—' : n1(r.packet_loss) + '%'),
|
||||
el('td', { class: 'muted' }, r.server_name || ''),
|
||||
el('td', {}, r.result_url ? el('a', { href: safeHref(r.result_url), target: '_blank', rel: 'noopener', class: 'st-link' }, '↗') : ''))))))
|
||||
);
|
||||
}
|
||||
|
||||
export async function render(main) {
|
||||
hours = hours || 168;
|
||||
await load(main);
|
||||
if (timer) clearInterval(timer);
|
||||
timer = setInterval(() => {
|
||||
if (!document.querySelector('.st-kpis')) { clearInterval(timer); timer = null; return; }
|
||||
load(main);
|
||||
}, 30000);
|
||||
}
|
||||
11
server.js
@@ -7,14 +7,19 @@ import * as queue from './lib/jobs/queue.js';
|
||||
import { registerWorkers } from './lib/jobs/index.js';
|
||||
import { router as ingestRouter } from './lib/api/routes/ingest.js';
|
||||
import { router as iconsRouter } from './lib/api/routes/icons.js';
|
||||
import { router as iconSetsRouter } from './lib/api/routes/icon_sets.js';
|
||||
import { router as devicesRouter } from './lib/api/routes/devices.js';
|
||||
import { startCron } from './lib/cron/index.js';
|
||||
import { seedFromConfig } from './lib/health/registry.js';
|
||||
import { mcpAuth } from './lib/api/middleware/mcp_auth.js';
|
||||
import { handleMcp } from './lib/mcp/http.js';
|
||||
import httpProxy from 'http-proxy';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const VERSION = '2.1.0';
|
||||
// Read the version from package.json so a deploy never serves a stale /health
|
||||
// version (the old hardcoded const had to be bumped by hand and caused the
|
||||
// health-gated deploy to roll back 3x when forgotten).
|
||||
const VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url))).version;
|
||||
|
||||
// Proxy /terminal (+ its WebSocket) to ttyd on CT 300, so the embedded terminal
|
||||
// works whether the Void is reached via Traefik (void2-app.hynesy.com) OR the
|
||||
@@ -53,6 +58,10 @@ export function createApp() {
|
||||
// slugs are sanitized to [a-z0-9-] to prevent path traversal.
|
||||
app.use('/api/icons', iconsRouter);
|
||||
|
||||
// /api/icon-sets/* — GET routes are open (same <img> reason as above);
|
||||
// POST/DELETE are protected by requireOwner inside the router.
|
||||
app.use('/api/icon-sets', iconSetsRouter);
|
||||
|
||||
// /api/devices — band data is public (like the static devices.json it replaces);
|
||||
// discovered/edit/scan sub-routes use requireOwner (401/403) internally.
|
||||
app.use('/api/devices', devicesRouter);
|
||||
|
||||
21
tests/api/backups.test.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ingest } from '../../lib/api/routes/backups.js';
|
||||
|
||||
describe('backups ingest schema', () => {
|
||||
it('accepts a valid run', () => {
|
||||
const r = ingest.safeParse({
|
||||
ok: true, total_bytes: 2400000000, won_free_bytes: 33000000000,
|
||||
guests: [{ vmid: 310, name: 'void-db', bytes: 518000000 }], duration_sec: 950
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
it('accepts an empty body (all fields optional)', () => {
|
||||
expect(ingest.safeParse({}).success).toBe(true);
|
||||
});
|
||||
it('rejects negative bytes', () => {
|
||||
expect(ingest.safeParse({ total_bytes: -5 }).success).toBe(false);
|
||||
});
|
||||
it('rejects malformed guests', () => {
|
||||
expect(ingest.safeParse({ guests: [{ vmid: 1 }] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ describe('dashboard layout api', () => {
|
||||
it('GET returns defaults', async () => {
|
||||
const res = await request(app).get('/api/dashboard/layout').set(ownerHeaders);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ card_order: [], hidden: [], sizes: {} });
|
||||
expect(res.body).toEqual({ card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] });
|
||||
});
|
||||
|
||||
it('PUT persists and GET reflects it', async () => {
|
||||
|
||||
@@ -38,4 +38,21 @@ describe('/api/devices', () => {
|
||||
it('PATCH rejects a bad MAC', async () => {
|
||||
expect((await owner(request(app).patch('/api/devices/not-a-mac')).send({ name: 'x' })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST / manually adds an offline device by MAC (owner, lowercased, status=known, absent)', async () => {
|
||||
expect((await request(app).post('/api/devices').send({ mac: 'aa:bb:cc:dd:ee:ff' })).status).toBe(401);
|
||||
const res = await owner(request(app).post('/api/devices')).send({ mac: 'AA:BB:CC:DD:EE:FF', ip: '192.168.1.77', name: 'Garage door', grp: 'Smart Home' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.mac).toBe('aa:bb:cc:dd:ee:ff');
|
||||
expect(res.body.ip).toBe('192.168.1.77');
|
||||
expect(res.body.status).toBe('known');
|
||||
expect(res.body.present).toBe(false);
|
||||
const band = await request(app).get('/api/devices');
|
||||
expect(band.body.groups.find(g => g.name === 'Smart Home').devices.some(d => d.name === 'Garage door')).toBe(true);
|
||||
});
|
||||
|
||||
it('POST / rejects a bad MAC and a bad IP', async () => {
|
||||
expect((await owner(request(app).post('/api/devices')).send({ mac: 'nope' })).status).toBe(400);
|
||||
expect((await owner(request(app).post('/api/devices')).send({ mac: 'aa:bb:cc:dd:ee:ff', ip: 'not-an-ip' })).status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
72
tests/api/icon_sets.test.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// tests/api/icon_sets.test.js
|
||||
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../server.js';
|
||||
import * as sets from '../../lib/icons/sets.js';
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
|
||||
|
||||
let app;
|
||||
let setsDir;
|
||||
let bundledDir;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.OWNER_TOKEN = 'test-token';
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Set up fresh temp dirs with a fake bundled device icon so the bundled set is non-empty.
|
||||
setsDir = mkdtempSync(path.join(tmpdir(), 'iconsets-'));
|
||||
bundledDir = path.join(setsDir, '__bundled');
|
||||
mkdirSync(bundledDir, { recursive: true });
|
||||
writeFileSync(path.join(bundledDir, 'router.svg'), '<svg><path/></svg>');
|
||||
sets._setDirs({ setsDir, bundledDir });
|
||||
});
|
||||
|
||||
const owner = r => r.set('Authorization', 'Bearer test-token');
|
||||
|
||||
describe('GET /api/icon-sets', () => {
|
||||
it('returns 200 with an array including the bundled devices set', async () => {
|
||||
const res = await request(app).get('/api/icon-sets');
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
const dev = res.body.find(s => s.set === 'devices');
|
||||
expect(dev).toBeDefined();
|
||||
expect(dev.readonly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/icon-sets/:set', () => {
|
||||
it('returns 401 (not 500) without auth', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/icon-sets/mytest')
|
||||
.attach('files', PNG, { filename: 'router.png', contentType: 'image/png' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 200 and uploads the icon with owner auth', async () => {
|
||||
const res = await owner(
|
||||
request(app).post('/api/icon-sets/mytest')
|
||||
).attach('files', PNG, { filename: 'router.png', contentType: 'image/png' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.set).toBe('mytest');
|
||||
expect(res.body.icons).toContain('router.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/icon-sets/:set/:file', () => {
|
||||
it('serves a previously uploaded icon with image/png content-type', async () => {
|
||||
// Upload first
|
||||
await owner(
|
||||
request(app).post('/api/icon-sets/mytest')
|
||||
).attach('files', PNG, { filename: 'router.png', contentType: 'image/png' });
|
||||
|
||||
const res = await request(app).get('/api/icon-sets/mytest/router.png');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/png');
|
||||
});
|
||||
});
|
||||
40
tests/api/kutt.test.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
|
||||
vi.mock('../../lib/links/kutt.js', () => ({
|
||||
compareVersions: (r, l) => ({ running: r, latest: l, updateAvailable: r !== l }),
|
||||
fetchLatestKuttRelease: async () => ({ latest: 'v9.9.9', url: 'https://x' }),
|
||||
createLink: async (b) => ({ link: 'https://link.hynesy.com/abc', address: 'abc', target: b.target }),
|
||||
recentLinks: async () => ({ data: [] })
|
||||
}));
|
||||
|
||||
let app;
|
||||
const owner = r => r.set('Authorization', 'Bearer test-token');
|
||||
beforeAll(async () => {
|
||||
process.env.OWNER_TOKEN = 'test-token';
|
||||
process.env.KUTT_API_URL = 'http://10.0.0.1:3000';
|
||||
process.env.KUTT_API_KEY = 'K';
|
||||
process.env.KUTT_VERSION = 'v3.2.5';
|
||||
({ createApp } = await import('../../server.js'));
|
||||
app = createApp();
|
||||
});
|
||||
let createApp;
|
||||
|
||||
describe('/api/kutt', () => {
|
||||
it('GET /version returns running/latest/updateAvailable (owner)', async () => {
|
||||
expect((await request(app).get('/api/kutt/version')).status).toBe(401);
|
||||
const res = await owner(request(app).get('/api/kutt/version'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ running: 'v3.2.5', latest: 'v9.9.9', updateAvailable: true });
|
||||
});
|
||||
|
||||
it('POST / creates a link via Kutt (owner)', async () => {
|
||||
const res = await owner(request(app).post('/api/kutt')).send({ target: 'https://example.com' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.link).toBe('https://link.hynesy.com/abc');
|
||||
});
|
||||
|
||||
it('POST / rejects a non-URL target', async () => {
|
||||
expect((await owner(request(app).post('/api/kutt')).send({ target: 'not a url' })).status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -6,15 +6,19 @@ 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 } ] } ] };
|
||||
{ mac: 'bc:a5:11:3e:06:88', ip: '192.168.1.13', name: 'Orbi Satellite', grp: 'Network', vendor: 'Netgear', randomized: false, present: true } ] } ] };
|
||||
if (p === '/api/devices/discovered') return [
|
||||
{ mac: '24:4b:fe:8e:09:a4', ip: '192.168.1.15', vendor: 'ASUSTek', randomized: false, present: true } ];
|
||||
return {};
|
||||
}),
|
||||
patch: vi.fn(async () => ({}))
|
||||
patch: vi.fn(async () => ({})),
|
||||
post: vi.fn(async () => ({})),
|
||||
del: vi.fn(async () => ({}))
|
||||
}
|
||||
}));
|
||||
|
||||
import { api } from '../../public/api.js';
|
||||
|
||||
let renderDevicesBand;
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="h"></div></body></html>', { url: 'http://localhost/' });
|
||||
@@ -33,4 +37,44 @@ describe('devices band', () => {
|
||||
expect(host.querySelector('.dv-discovered')).not.toBeNull(); // review affordance present
|
||||
expect(host.textContent).toMatch(/Discovered/i);
|
||||
});
|
||||
|
||||
it('lets you edit a known device (✎ → name/group → Save patches)', async () => {
|
||||
const host = document.getElementById('h');
|
||||
await renderDevicesBand(host);
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const t = host.querySelector('.dv-tile');
|
||||
t.querySelector('.dv-edit-btn').click();
|
||||
const nameI = t.querySelector('.dv-edit-name');
|
||||
expect(nameI.value).toBe('Orbi Satellite');
|
||||
nameI.value = 'Orbi RBS50';
|
||||
t.querySelector('.dv-add').click(); // Save
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
expect(api.patch).toHaveBeenCalledWith('/api/devices/bc:a5:11:3e:06:88',
|
||||
expect.objectContaining({ name: 'Orbi RBS50', grp: 'Network' }));
|
||||
});
|
||||
|
||||
it('Manual Add reveals a form (with IP) and POSTs the new device; MAC field auto-inserts colons', async () => {
|
||||
const host = document.getElementById('h');
|
||||
await renderDevicesBand(host);
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
expect(host.querySelector('.dv-addtoggle').textContent).toBe('+ Manual Add');
|
||||
host.querySelector('.dv-addtoggle').click(); // reveal the form
|
||||
const [macI, ipI] = host.querySelectorAll('.dv-addform .dv-edit-name');
|
||||
macI.value = 'aabbccddeeff';
|
||||
macI.dispatchEvent(new window.Event('input')); // colon-mask
|
||||
expect(macI.value).toBe('aa:bb:cc:dd:ee:ff');
|
||||
ipI.value = '192.168.1.50';
|
||||
host.querySelector('.dv-addform .dv-add').click();
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
expect(api.post).toHaveBeenCalledWith('/api/devices', expect.objectContaining({ mac: 'aa:bb:cc:dd:ee:ff', ip: '192.168.1.50' }));
|
||||
});
|
||||
|
||||
it('Scan Now triggers the scheduled scan', async () => {
|
||||
const host = document.getElementById('h');
|
||||
await renderDevicesBand(host);
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
host.querySelector('.dv-scanbtn').click();
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
expect(api.post).toHaveBeenCalledWith('/api/devices/scan');
|
||||
});
|
||||
});
|
||||
|
||||
28
tests/frontend/links_view.test.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
vi.mock('../../public/api.js', () => ({ api: {
|
||||
get: vi.fn(async (p) => p.endsWith('/version')
|
||||
? { running: 'v3.2.5', latest: 'v3.2.6', updateAvailable: true, url: 'https://x' }
|
||||
: { data: [] }),
|
||||
post: vi.fn(async () => ({ link: 'https://link.hynesy.com/abc' }))
|
||||
} }));
|
||||
|
||||
let render;
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="main"></div></body></html>', { url: 'http://localhost/' });
|
||||
global.window = dom.window; global.document = dom.window.document; global.Node = dom.window.Node;
|
||||
({ render } = await import('../../public/views/links.js'));
|
||||
});
|
||||
afterAll(() => { delete global.window; delete global.document; delete global.Node; });
|
||||
|
||||
describe('links view', () => {
|
||||
it('renders the update badge + quick-add + the Kutt iframe', async () => {
|
||||
const main = document.getElementById('main');
|
||||
await render(main);
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
expect(main.querySelector('iframe.term-frame').getAttribute('src')).toBe('https://link.hynesy.com/');
|
||||
expect(main.textContent).toMatch(/update available/i);
|
||||
expect(main.querySelector('.lk-quickadd')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
15
tests/icons/devices_icon.test.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { iconRef } from '../../lib/api/routes/devices.js';
|
||||
|
||||
describe('icon ref validation', () => {
|
||||
it('accepts set + brand refs and null', () => {
|
||||
expect(iconRef.safeParse('set:devices:router').success).toBe(true);
|
||||
expect(iconRef.safeParse('brand:apple').success).toBe(true);
|
||||
expect(iconRef.safeParse(null).success).toBe(true);
|
||||
});
|
||||
it('rejects junk', () => {
|
||||
expect(iconRef.safeParse('set:bad').success).toBe(false);
|
||||
expect(iconRef.safeParse('javascript:alert').success).toBe(false);
|
||||
});
|
||||
});
|
||||