From 7cbfa144046c02602ae7463ec52826f7339a7531 Mon Sep 17 00:00:00 2001 From: christian Date: Wed, 14 Oct 2026 18:37:01 -0400 Subject: [PATCH] Gear can be added, edited and removed on Computers > Gear, with an optional watching key; Check now sits on the Map tab Co-Authored-By: Claude Fable 5.1 --- settings/server.py | 20 +++++++++++++++++-- settings/ui/app.js | 49 ++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/settings/server.py b/settings/server.py index 9fc6ce8..fa424e7 100644 --- a/settings/server.py +++ b/settings/server.py @@ -497,6 +497,16 @@ class Site: for s in cfg.get("dante_switches", []): rows.setdefault("Netgear " + s, {"name": "Netgear " + s, "kind": "Dante switch", "address": s, "watched_by": {"dante"}, "light": "unknown", "reason": "", "location": gear.get("Netgear " + s, {}).get("location", ""), "notes": ""}) + for name, g in gear.items(): # gear added by hand, watched by no key + if not isinstance(g, dict): + continue + row = rows.setdefault(name, {"name": name, "kind": g.get("kind") or "device", "address": g.get("address"), "watched_by": set(), "light": "not_needed", + "reason": "not watched", "location": g.get("location", ""), "notes": g.get("notes", "")}) + if g.get("kind"): + row["kind"] = g["kind"] + if g.get("address") and not row.get("address"): + row["address"] = g["address"] + row["own"] = True out = [] for r in rows.values(): r["watched_by"] = sorted(r["watched_by"]) @@ -877,8 +887,14 @@ class Handler(http.server.BaseHTTPRequestHandler): if path == "/api/gear": cfg = site.load() gear = dict(cfg.get("gear") or {}) - name = str(body.get("name", ""))[:80] - gear[name] = {"location": str(body.get("location", ""))[:120], "notes": str(body.get("notes", ""))[:300]} + name = str(body.get("name", "")).strip()[:80] + if not name: + return self.send(400, {"error": "a name is needed"}) + if body.get("remove"): + gear.pop(name, None) + else: + entry = {k: str(body.get(k, "")).strip()[:300] for k in ("location", "notes", "kind", "address")} + gear[name] = {k: v for k, v in entry.items() if v} draft = site.draft.read() or {"config": cfg} draft_cfg = dict(draft["config"], gear=gear) d, _ = site.draft.update(draft_cfg, who, site.cfg_sha) diff --git a/settings/ui/app.js b/settings/ui/app.js index 9ba7ac4..3ec9c56 100644 --- a/settings/ui/app.js +++ b/settings/ui/app.js @@ -491,13 +491,15 @@ const selected = rest[0] && rest[0] !== "gear" && rest[0] !== "setup" ? rest[0] : null; compTab = rest[0] === "gear" || rest[0] === "setup" ? rest[0] : "map"; const tabs = `
- ${editing() ? `` : ""}
`; + ${editing() ? `` : ""}`; let body = ""; if (compTab === "gear") body = await gearHtml(); else if (compTab === "setup") body = setupHtml(m, (await api("/api/setup").catch(() => ({ computers: [] }))).computers); else { const rows = m.computers.slice().sort((a, b) => (a.word === "Problem" ? 0 : 1) - (b.word === "Problem" ? 0 : 1)).map((c) => `${wordOf(c.word)}${esc(c.name)}${c.self ? ' (this Mac)' : ""}${esc(c.role)}${(c.keys || []).map((k) => `${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}`).join(" ")}${esc(c.reason)}${c.looked ? `
looked ${esc(ago(Date.now() / 1000 - c.looked))}` : ""}`).join(""); - body = `
${rows}
statuscomputerdoeskeys${hint("The keys that turn red if it dies")}
`; + const looked = Math.max(0, ...m.computers.map((c) => c.looked || 0)); + body = `
${looked ? "looked " + esc(ago(Date.now() / 1000 - looked)) : "not looked yet"}
+
${rows}
statuscomputerdoeskeys${hint("The keys that turn red if it dies")}
`; const c = m.computers.find((x) => x.alias === selected); if (c) body += computerCard(c); } @@ -507,7 +509,10 @@ for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job); const pb = document.getElementById("probe"); if (pb) pb.onclick = async () => { pb.disabled = true; pb.textContent = "Looking…"; try { await post("/api/machines/probe"); } catch (e) { alertBox(e.message); } setTimeout(() => computers(rest, true).then(() => wireInputs(main)), 4000); }; - for (const b of main.querySelectorAll("[data-gear]")) b.onclick = async () => { const row = b.closest("tr"); try { await post("/api/gear", { name: b.dataset.gear, location: row.querySelector("[data-loc]").value, notes: row.querySelector("[data-notes]").value }); const d = await api("/api/draft"); state.draft = d.draft; badge(); b.textContent = "Saved"; } catch (e) { alertBox(e.message); } }; + const ag = main.querySelector("#addgear"); if (ag) ag.onclick = () => gearDialog(null); + for (const a of main.querySelectorAll("[data-gearedit]")) a.onclick = (e) => { e.preventDefault(); gearDialog(a.dataset.gearedit); }; + for (const b of main.querySelectorAll("[data-geardel]")) b.onclick = () => confirmBox(`Remove ${b.dataset.geardel} from the gear list?`, "Remove", async () => { try { const r = await post("/api/gear", { name: b.dataset.geardel, remove: true }); state.draft = r.draft; badge(); route(); } catch (e) { alertBox(e.message); } }); + for (const b of main.querySelectorAll("[data-gear]")) b.onclick = async () => { const row = b.closest("tr"); try { const prev = (cfg().gear || {})[b.dataset.gear] || {}; await post("/api/gear", { name: b.dataset.gear, location: row.querySelector("[data-loc]").value, notes: row.querySelector("[data-notes]").value, kind: prev.kind || "", address: prev.address || "" }); const d = await api("/api/draft"); state.draft = d.draft; badge(); b.textContent = "Saved"; } catch (e) { alertBox(e.message); } }; const runs = main.querySelector("#runs"); if (runs) runs.onchange = () => { if (!ensureDraft()) return; try { state.draft.config.hosts[selected].runs = runs.value.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => JSON.parse(l)); saveDraft(); } catch (err) { alertBox("Each line must be a valid {...} entry."); } }; } function computerCard(c) { @@ -549,10 +554,46 @@ saveDraft(); location.hash = "#computers"; }); } + const GEAR_KINDS = ["recorder", "switcher", "Dante device", "Dante switch", "wireless receiver", "in-ear transmitter", "LED processor", "camera", "network switch", "display", "device"]; async function gearHtml() { const g = await api("/api/gear"); return `
- ${g.map((r) => ``).join("")}
devicekindaddresswhere${hint("Where it is in the building, and notes")}watched by
${lightDot(r.light, lightWord[r.light])}${esc(r.name)}${esc(r.kind)}${esc(r.address || "")}${r.detail ? `
${esc(r.detail)}` : ""}
${editing() ? ` ` : `${esc(r.location || "")} ${esc(r.notes || "")}`} ${esc(r.reason || "")}${(r.watched_by || []).map((k) => `${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}`).join(", ")}
`; + ${g.map((r) => `${lightDot(r.light, lightWord[r.light])}${editing() ? `${esc(r.name)}` : esc(r.name)}${esc(r.kind)}${esc(r.address || "")}${r.detail ? `
${esc(r.detail)}` : ""}${editing() ? ` ` : `${esc(r.location || "")} ${esc(r.notes || "")}`} ${esc(r.reason || "")}${(r.watched_by || []).length ? r.watched_by.map((k) => `${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}`).join(", ") : editing() && r.own ? `` : ""}`).join("")} + ${editing() ? `
` : ""}`; + } + async function gearDialog(existing) { + const c = cfg(); + const cur = existing ? Object.assign({ name: existing }, (c.gear || {})[existing] || {}) : {}; + let conns = []; + try { const cc = await api("/api/companion"); conns = (cc.connections || []).filter((r) => !(r.watched_by || []).length).map((r) => r.label); } catch (e) {} + const plainKeys = c.tiles.filter((t) => !t.members && t.kind !== "master"); + dialog(`

${existing ? "Gear" : "New gear"}${hint("A device that is not a computer. Give it a kind and an address; choose whether a key watches it.")}

+ + + + + + +
`, (b) => { + b.querySelector("#dcancel").onclick = closeDialog; + b.querySelector("#dok").onclick = async () => { + const name = b.querySelector("#gname").value.trim(), kind = b.querySelector("#gkind").value, address = b.querySelector("#gaddr").value.trim(); + const location = b.querySelector("#gloc").value.trim(), notes = b.querySelector("#gnotes").value.trim(), watch = b.querySelector("#gwatch").value, keyId = b.querySelector("#gkey").value; + if (!name) return alertBox("A name, please."); + if (watch === "ping" && !/^[\w.:-]+$/.test(address)) return alertBox("To ping it, give it an address."); + if (!ensureDraft()) return; + try { const r = await post("/api/gear", { name, kind, address, location, notes }); state.draft = r.draft; } catch (e) { return alertBox(e.message); } + if (watch) { + const d = state.draft.config; + const chk = watch === "ping" ? { type: "ping", host: address, name: tagOf(name) } : { type: "conn", conn: watch.slice(5), fix: "restart-connection" }; + let t = d.tiles.find((x) => x.id === keyId); + if (!t) { let id = slug(name) || "gear", n = 2; while (d.tiles.some((x) => x.id === id)) id = slug(name) + "_" + n++; t = { id, label: tagOf(name), checks: [] }; const pos = firstEmpty(d, "status"); if (pos) t.pos = pos; d.tiles.push(t); } + t.checks = t.checks || []; t.checks.push(chk); + saveDraft(); closeDialog(); location.hash = "#keys/" + t.id; return; + } + badge(); closeDialog(); route(); + }; + }); } function setupHtml(m, generated) { return m.computers.map((c) => {