Fix and check builders: every fix is built from fields; today's 27 fixes carry their templates
settings/builders.py renders a fix's command from a builder (template) and its fields; validate refuses a config whose stored command no longer equals what its fields build; the Fixes screen and the remote checks in the key editor rebuild through /api/build and /api/build-check. config.json adopts templates for all 27 fixes and 5 remote checks (ProPresenter's restart loop and Ableton's quoting normalised, same behaviour); tests/test_builders.py (174 tests pass). Deployed to NA Ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+81
-13
@@ -69,8 +69,8 @@
|
||||
|
||||
// ---- the draft ------------------------------------------------------------
|
||||
async function loadAll() {
|
||||
const [c, s, d] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft")]);
|
||||
state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending;
|
||||
const [c, s, d, b] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft"), api("/api/builders")]);
|
||||
state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; state.builders = b;
|
||||
document.getElementById("sitename").textContent = (state.live.site || {}).name || "";
|
||||
badge();
|
||||
}
|
||||
@@ -309,7 +309,13 @@
|
||||
const sentence = (kt.checks[j] || {}).sentence || chk.type;
|
||||
if (!editing()) return `<li>${esc(sentence)}<details><summary>Advanced</summary><dl class="kv small">${Object.entries(chk).map(([n, v]) => `<dt>${esc(n)}</dt><dd class="mono">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}</dl></details></li>`;
|
||||
const base = `tiles.${i}.checks.${j}.`;
|
||||
const fields = (spec.fields || []).filter((f) => f !== "fix" && f !== "type").map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("");
|
||||
let fields = (spec.fields || []).filter((f) => !["fix", "type", "template", "params"].includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("");
|
||||
if (chk.type === "remote") {
|
||||
const ctpls = state.builders.checks.concat([{ name: "custom", words: "Custom command (typed by hand)", params: [] }]);
|
||||
const ctName = chk.template || "custom", ct = ctpls.find((x) => x.name === ctName);
|
||||
fields = `<div class="field"><label>Built as</label><select class="ctplsel" data-check="${j}">${ctpls.map((x) => `<option value="${x.name}" ${x.name === ctName ? "selected" : ""}>${esc(x.words)}</option>`).join("")}</select></div><div class="ctplform" data-check="${j}">${paramsHtml(ct, chk.params || {}, "ck" + j)}</div>`
|
||||
+ (ctName === "custom" ? fields : inputHtml(base + "msg", F("tiles[].checks[].msg"), chk.msg) + (chk.cmd ? `<div class="field"><label>The command it makes (on ${esc((c.hosts[chk.host] || {}).name || chk.host)})</label><pre class="log small">${esc(chk.cmd)}</pre></div>` : ""));
|
||||
}
|
||||
const fixSel = typeof chk.fix === "object" && chk.fix ? `<div class="field"><label>Fix (per sub-check)</label><textarea data-path="${base}fix" rows="2" class="fixtable">${esc(JSON.stringify(chk.fix))}</textarea><div class="help">A table: sub-check → fix, "*" for everything else.</div></div>`
|
||||
: `<div class="field"><label>Fix: what holding runs for this check</label><select data-path="${base}fix" class="fixsel">${fixOptions(chk.fix)}</select></div>`;
|
||||
const run = typeof chk.fix === "string" && chk.fix && canRunNow(chk.fix) ? `<button class="small" data-run="${esc(t.id)}|${esc(chk.fix)}|${j}">Run this fix now</button>` : "";
|
||||
@@ -337,6 +343,19 @@
|
||||
for (const cb of main.querySelectorAll("[data-member]")) cb.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const set = new Set(t.members); cb.checked ? set.add(cb.dataset.member) : set.delete(cb.dataset.member); t.members = [...set]; saveDraft(); };
|
||||
const add = main.querySelector("#addcheck"); if (add) add.onchange = () => { if (!add.value || !ensureDraft()) return; const t = state.draft.config.tiles[i]; t.checks = t.checks || []; t.checks.push({ type: add.value }); saveDraft(); route(); };
|
||||
const pos = main.querySelector("#pos"); if (pos) pos.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const m = pos.value.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/); if (m) t.pos = [+m[1], +m[2]]; else if (!pos.value.trim()) delete t.pos; else return alertBox("Row, column: two numbers, like 2, 5."); saveDraft(); route(); };
|
||||
const rebuildCheck = async (j) => {
|
||||
if (!ensureDraft()) return;
|
||||
const t = state.draft.config.tiles[i], chk = t.checks[j];
|
||||
const tplName = main.querySelector(`select.ctplsel[data-check="${j}"]`).value;
|
||||
if (tplName === "custom") { delete chk.template; delete chk.params; saveDraft(); route(); return; }
|
||||
const ct = state.builders.checks.find((x) => x.name === tplName);
|
||||
const params = readParams(main.querySelector(`.ctplform[data-check="${j}"]`), ct);
|
||||
const r = await post("/api/build-check", { check: Object.assign({}, chk, { template: tplName, params }) });
|
||||
if (!r.ok) { chk.template = tplName; chk.params = params; saveDraft(); alertBox(r.error); return; }
|
||||
t.checks[j] = r.check; saveDraft(); route();
|
||||
};
|
||||
for (const sel3 of main.querySelectorAll("select.ctplsel")) sel3.onchange = () => rebuildCheck(+sel3.dataset.check);
|
||||
for (const form of main.querySelectorAll(".ctplform")) for (const el of form.querySelectorAll("[data-param]")) el.onchange = () => rebuildCheck(+form.dataset.check);
|
||||
for (const s of main.querySelectorAll("select.fixsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); };
|
||||
for (const ta of main.querySelectorAll("textarea.fixtable")) ta.onchange = () => { try { setPath(ta.dataset.path, JSON.parse(ta.value)); } catch (e) { alertBox("Not a valid table."); } };
|
||||
for (const s of main.querySelectorAll("select.holdsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); };
|
||||
@@ -609,6 +628,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---- builders: a fix or a check is made from fields ---------------------------------
|
||||
function paramsHtml(tpl, params, prefix) {
|
||||
const c = cfg();
|
||||
return tpl.params.map((p) => {
|
||||
const v = params[p.name] != null ? params[p.name] : p.default;
|
||||
const id = `${prefix}-${p.name}`;
|
||||
let input;
|
||||
if (p.type === "host") input = `<select id="${id}" data-param="${p.name}"><option value="">–</option>${Object.entries(c.hosts).filter(([, h]) => tpl.os === "self" ? h.self : tpl.os ? (h.self ? "mac" : h.os || "mac") === tpl.os : true).map(([a, h]) => `<option value="${esc(a)}" ${v === a ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select>`;
|
||||
else if (p.type === "bool") input = `<input type="checkbox" id="${id}" data-param="${p.name}" ${v ? "checked" : ""}>`;
|
||||
else if (p.type === "int") input = `<input type="number" id="${id}" data-param="${p.name}" value="${esc(v == null ? "" : v)}">`;
|
||||
else if (p.type === "list") input = `<input id="${id}" data-param="${p.name}" value="${esc(Array.isArray(v) ? v.join(", ") : v || "")}" placeholder="one or more, with commas">`;
|
||||
else input = `<input id="${id}" data-param="${p.name}" value="${esc(v == null ? "" : v)}">`;
|
||||
return `<div class="field"><label for="${id}">${esc(p.words)}</label>${input}<div class="help">${esc(p.help)}</div></div>`;
|
||||
}).join("");
|
||||
}
|
||||
function readParams(root, tpl) {
|
||||
const out = {};
|
||||
for (const p of tpl.params) {
|
||||
const el = root.querySelector(`[data-param="${p.name}"]`); if (!el) continue;
|
||||
if (p.type === "bool") out[p.name] = el.checked;
|
||||
else if (p.type === "int") out[p.name] = el.value === "" ? undefined : parseInt(el.value, 10);
|
||||
else if (p.type === "list") out[p.name] = el.value.split(",").map((x) => x.trim()).filter(Boolean);
|
||||
else out[p.name] = el.value.trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- Fixes -------------------------------------------------------------------------
|
||||
async function fixes(rest) {
|
||||
const c = cfg(), auto = new Set(c.auto_fix.fixes || []);
|
||||
@@ -621,20 +667,42 @@
|
||||
let detail = "";
|
||||
if (f) {
|
||||
const kind = f.kind || "ssh";
|
||||
const fields = ["label", "short"].concat((state.schema.fix_kind_fields || {})[kind] || []).concat(["restart_ok", "never_auto", "timeout"]);
|
||||
const inputs = editing() ? [...new Set(fields)].map((fl) => {
|
||||
const Fd = F("fixes.*." + fl); if (!Fd) return "";
|
||||
if (fl === "host") return `<div class="field"><label>Computer</label><select data-path="fixes.${esc(id)}.host"><option value="">–</option>${Object.entries(c.hosts).filter(([, h]) => !h.self).map(([a, h]) => `<option value="${esc(a)}" ${f.host === a ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select></div>`;
|
||||
if (fl === "run") return `<div class="field"><label>Command (shown as a command at Review; never run before Apply)</label><textarea data-path="fixes.${esc(id)}.run" rows="3" class="mono">${esc(f.run || "")}</textarea><div class="help">zsh on a Mac, cmd.exe on a PC. ${esc(Fd.help)}</div></div>`;
|
||||
return inputHtml(`fixes.${id}.${fl}`, Fd, f[fl]);
|
||||
}).join("") : "";
|
||||
const kindSel = editing() ? `<div class="field"><label>Built as</label><select data-path="fixes.${esc(id)}.kind" class="kindsel">${(state.schema.fix_kinds || ["ssh", "local"]).map((k) => `<option ${kind === k ? "selected" : ""}>${k}</option>`).join("")}</select></div>` : "";
|
||||
const tpls = state.builders.fixes, tplName = f.template || "custom", tpl = tpls.find((t) => t.name === tplName) || tpls.find((t) => t.name === "custom");
|
||||
const kindOnly = !!tpl.kind;
|
||||
const keys = [...(used[id] || [])];
|
||||
detail = `<div class="card"><h2>${esc(f.short || id)}</h2><p>${esc(f.label || "")}</p>${kindSel}${inputs}${canRunNow(id) && editing() ? `<p class="help">Run it from the key it belongs to (${esc(keys.join(", ") || "none yet")}).</p>` : ""}<details ${editing() ? "" : "open"}><summary>Advanced</summary><dl class="kv">${Object.entries(f).map(([k, v]) => `<dt>${esc(k)}</dt><dd class="mono small">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}<dt>id</dt><dd class="mono">${esc(id)}</dd></dl></details>${editing() && !keys.length ? `<button class="danger small" id="delfix">Delete this fix</button>` : ""}</div>`;
|
||||
let inputs = "";
|
||||
if (editing()) {
|
||||
inputs += `<div class="field"><label>Built as</label><select id="tplsel">${tpls.map((t) => `<option value="${t.name}" ${t.name === tplName ? "selected" : ""}>${esc(t.words)}</option>`).join("")}</select></div>`;
|
||||
inputs += `<div id="tplform">${paramsHtml(tpl, f.params || {}, "fx")}</div><p id="tplmsg" class="help"></p>`;
|
||||
inputs += inputHtml(`fixes.${id}.label`, F("fixes.*.label"), f.label) + inputHtml(`fixes.${id}.short`, F("fixes.*.short"), f.short);
|
||||
if (kindOnly) inputs += (state.schema.fix_kind_fields[tpl.kind] || []).map((fl) => inputHtml(`fixes.${id}.${fl}`, F("fixes.*." + fl), f[fl])).join("");
|
||||
if (tplName === "custom") {
|
||||
inputs += `<div class="field"><label>Runs on</label><select data-path="fixes.${esc(id)}.kind" class="kindsel">${["ssh", "local"].map((k) => `<option ${kind === k ? "selected" : ""}>${k === "ssh" ? "another computer" : "this Mac"}</option>`).join("")}</select></div>`;
|
||||
if (kind === "ssh") inputs += `<div class="field"><label>Computer</label><select data-path="fixes.${esc(id)}.host"><option value="">–</option>${Object.entries(c.hosts).filter(([, h]) => !h.self).map(([a, h]) => `<option value="${esc(a)}" ${f.host === a ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select></div>`;
|
||||
inputs += `<div class="field"><label>Command (typed by hand; shown as a command at Review; never run before Apply)</label><textarea data-path="fixes.${esc(id)}.run" rows="3" class="mono">${esc(f.run || "")}</textarea><div class="help">zsh on a Mac, cmd.exe on a PC.</div></div>`;
|
||||
} else if (f.run) inputs += `<div class="field"><label>The command it makes (${f.kind === "local" ? "this Mac" : "on " + esc((c.hosts[f.host] || {}).name || f.host)})</label><pre class="log small" style="max-height:160px">${esc(f.run)}</pre></div>`;
|
||||
inputs += `<details><summary>Advanced</summary>${["reconnect", "conn", "restart_ok", "never_auto", "timeout"].map((fl) => inputHtml(`fixes.${id}.${fl}`, F("fixes.*." + fl), f[fl])).join("")}</details>`;
|
||||
} else inputs = `<p class="small muted">Built as: ${esc(tpl.words)}${f.run ? `</p><pre class="log small" style="max-height:160px">${esc(f.run)}</pre>` : "</p>"}`;
|
||||
detail = `<div class="card"><h2>${esc(f.short || id)}</h2><p>${esc(f.label || "")}</p>${inputs}${canRunNow(id) && editing() ? `<p class="help">Run it from the key it belongs to (${esc(keys.join(", ") || "none yet")}).</p>` : ""}<details><summary>Everything stored</summary><dl class="kv">${Object.entries(f).map(([k, v]) => `<dt>${esc(k)}</dt><dd class="mono small">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}<dt>id</dt><dd class="mono">${esc(id)}</dd></dl></details>${editing() && !keys.length ? `<button class="danger small" id="delfix">Delete this fix</button>` : ""}</div>`;
|
||||
}
|
||||
const add = editing() ? `<div class="row"><input id="newfix" placeholder="new fix id (letters, digits, _)"><button id="addfix" class="small">+ New fix</button></div>` : "";
|
||||
main.innerHTML = `<h1>Fixes</h1><p class="what">Everything a hold can run. ${Object.keys(c.fixes).length} today.</p>${add}<div class="tablewrap"><table><tr><th>key text</th><th>plain words</th><th>built as</th><th>used by</th><th>by itself</th></tr>${rows}</table></div>${detail}`;
|
||||
const ks = main.querySelector("select.kindsel"); if (ks) ks.onchange = () => { setPath(ks.dataset.path, ks.value); route(); };
|
||||
const ks = main.querySelector("select.kindsel"); if (ks) ks.onchange = () => { setPath(ks.dataset.path, ks.value === "another computer" ? "ssh" : ks.value === "this Mac" ? "local" : ks.value); route(); };
|
||||
const rebuild = async () => {
|
||||
if (!ensureDraft()) return;
|
||||
const fx = state.draft.config.fixes[id], tplName = main.querySelector("#tplsel").value, tpl = state.builders.fixes.find((t) => t.name === tplName);
|
||||
const params = readParams(main.querySelector("#tplform"), tpl);
|
||||
if (tplName === "custom") { fx.template = "custom"; delete fx.params; saveDraft(); route(); return; }
|
||||
const r = await post("/api/build", { fix: Object.assign({}, fx, { template: tplName, params }) });
|
||||
const msg = main.querySelector("#tplmsg");
|
||||
if (!r.ok) { msg.textContent = r.error; fx.template = tplName; fx.params = params; saveDraft(); return; }
|
||||
const keep = { label: fx.label, short: fx.short };
|
||||
Object.assign(fx, r.fix); for (const k of ["run", "host"]) if (!(k in r.fix)) delete fx[k];
|
||||
if (!keep.label && r.label) fx.label = r.label;
|
||||
msg.textContent = "";
|
||||
saveDraft(); route();
|
||||
};
|
||||
const ts = main.querySelector("#tplsel"); if (ts) { ts.onchange = rebuild; for (const el of main.querySelectorAll("#tplform [data-param]")) el.onchange = rebuild; }
|
||||
const del = main.querySelector("#delfix"); if (del) del.onclick = () => confirmBox(`Delete the fix ${id}? No key uses it.`, "Delete", () => { if (!ensureDraft()) return; delete state.draft.config.fixes[id]; const af2 = state.draft.config.auto_fix.fixes || []; state.draft.config.auto_fix.fixes = af2.filter((x) => x !== id); saveDraft(); location.hash = "#fixes"; });
|
||||
const af = main.querySelector("#addfix"); if (af) af.onclick = () => { const nid = main.querySelector("#newfix").value.trim(); if (!/^[A-Za-z0-9_]+$/.test(nid) || c.fixes[nid]) return alertBox("A new id: letters, digits and _ only, not in use."); if (!ensureDraft()) return; state.draft.config.fixes[nid] = { kind: "local", short: nid.toUpperCase().slice(0, 12), label: "", run: "" }; saveDraft(); location.hash = "#fixes/" + nid; };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user