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 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 18:37:01 -04:00
co-authored by Claude Fable 5.1
parent 9a3746e6a5
commit 7cbfa14404
2 changed files with 63 additions and 6 deletions
+45 -4
View File
@@ -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 = `<div class="tabs"><button class="${compTab === "map" ? "on" : ""}" onclick="location.hash='#computers'">Map</button><button class="${compTab === "gear" ? "on" : ""}" onclick="location.hash='#computers/gear'" data-hint="Devices that are not computers: switcher, decks, wireless, LED">Gear</button><button class="${compTab === "setup" ? "on" : ""}" onclick="location.hash='#computers/setup'" data-hint="The one-time set-up per computer, with the scripts">Setup</button>
<button id="probe" ${m.probing ? "disabled" : ""} data-hint="Look at every computer over the network now (read-only)">${m.probing ? "Looking…" : "Check now"}</button>${editing() ? `<button class="small" data-job="computer" style="margin-left:auto">+ Computer</button>` : ""}</div>`;
${editing() ? `<button class="small" data-job="computer" style="margin-left:auto">+ Computer</button>` : ""}</div>`;
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) => `<tr class="click${selected === c.alias ? " sel" : ""}" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${wordOf(c.word)}</td><td>${esc(c.name)}${c.self ? ' <span class="muted small">(this Mac)</span>' : ""}</td><td class="small">${esc(c.role)}</td><td class="small">${(c.keys || []).map((k) => `<span class="pill">${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}</span>`).join(" ")}</td><td class="muted small">${esc(c.reason)}${c.looked ? `<br>looked ${esc(ago(Date.now() / 1000 - c.looked))}` : ""}</td></tr>`).join("");
body = `<div class="tablewrap"><table><tr><th>status</th><th>computer</th><th>does</th><th>keys${hint("The keys that turn red if it dies")}</th><th></th></tr>${rows}</table></div>`;
const looked = Math.max(0, ...m.computers.map((c) => c.looked || 0));
body = `<div class="row" style="justify-content:flex-end;margin-bottom:6px"><span class="muted small">${looked ? "looked " + esc(ago(Date.now() / 1000 - looked)) : "not looked yet"}</span><button id="probe" class="small" ${m.probing ? "disabled" : ""} data-hint="Log in to every computer now and refresh the lights (read-only)">${m.probing ? "Looking…" : "Check now"}</button></div>
<div class="tablewrap"><table><tr><th>status</th><th>computer</th><th>does</th><th>keys${hint("The keys that turn red if it dies")}</th><th></th></tr>${rows}</table></div>`;
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 `<div class="tablewrap"><table><tr><th></th><th>device</th><th>kind</th><th>address</th><th>where${hint("Where it is in the building, and notes")}</th><th>watched by</th></tr>
${g.map((r) => `<tr><td>${lightDot(r.light, lightWord[r.light])}</td><td>${esc(r.name)}</td><td class="small">${esc(r.kind)}</td><td class="mono small">${esc(r.address || "")}${r.detail ? `<br><span class="muted">${esc(r.detail)}</span>` : ""}</td><td class="small">${editing() ? `<input data-loc value="${esc(r.location || "")}" placeholder="where it is" style="width:11em"> <input data-notes value="${esc(r.notes || "")}" placeholder="notes" style="width:11em"> <button class="small" data-gear="${esc(r.name)}">Save</button>` : `${esc(r.location || "")} ${esc(r.notes || "")}`} <span class="muted">${esc(r.reason || "")}</span></td><td class="small">${(r.watched_by || []).map((k) => `<a href="#keys/${esc(k)}">${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}</a>`).join(", ")}</td></tr>`).join("")}</table></div>`;
${g.map((r) => `<tr><td>${lightDot(r.light, lightWord[r.light])}</td><td>${editing() ? `<a href="#" data-gearedit="${esc(r.name)}" data-hint="Change its kind, address or how it is watched">${esc(r.name)}</a>` : esc(r.name)}</td><td class="small">${esc(r.kind)}</td><td class="mono small">${esc(r.address || "")}${r.detail ? `<br><span class="muted">${esc(r.detail)}</span>` : ""}</td><td class="small">${editing() ? `<input data-loc value="${esc(r.location || "")}" placeholder="where it is" style="width:11em"> <input data-notes value="${esc(r.notes || "")}" placeholder="notes" style="width:11em"> <button class="small" data-gear="${esc(r.name)}">Save</button>` : `${esc(r.location || "")} ${esc(r.notes || "")}`} <span class="muted">${esc(r.reason || "")}</span></td><td class="small">${(r.watched_by || []).length ? r.watched_by.map((k) => `<a href="#keys/${esc(k)}">${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}</a>`).join(", ") : editing() && r.own ? `<button class="small danger" data-geardel="${esc(r.name)}">Remove</button>` : ""}</td></tr>`).join("")}</table></div>
${editing() ? `<div class="row" style="margin-top:8px"><button class="small" id="addgear">+ Gear</button></div>` : ""}`;
}
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(`<h3>${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.")}</h3>
<input id="gname" placeholder="name (Tascam, LED wall left, …)" value="${esc(cur.name || "")}" ${existing ? "readonly" : ""}>
<select id="gkind">${GEAR_KINDS.map((k) => `<option ${cur.kind === k ? "selected" : ""}>${k}</option>`).join("")}</select>
<input id="gaddr" placeholder="address (10.1.x.x), if it has one" value="${esc(cur.address || "")}">
<input id="gloc" placeholder="where it is" value="${esc(cur.location || "")}"><input id="gnotes" placeholder="notes" value="${esc(cur.notes || "")}">
<select id="gwatch"><option value="">not watched by a key</option><option value="ping">watch it: ping the address</option>${conns.map((n) => `<option value="conn:${esc(n)}">watch its Companion connection ${esc(n)}</option>`).join("")}</select>
<select id="gkey"><option value="__new">on a new key</option>${plainKeys.map((t) => `<option value="${esc(t.id)}">on the ${esc(t.label)} key</option>`).join("")}</select>
<div class="row"><button class="primary" id="dok">${existing ? "Save" : "Add"}</button><button id="dcancel">Cancel</button></div>`, (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) => {