// Dashboard P3RM1SS10NS tab — the per-agent capabilities + tool-groups // matrices, with a single page-level "save all" button. // // Both tables are fetched on tab activation (`GET /api/capabilities`, // `GET /api/tool-groups`) and after each save. Columns (caps / groups) // come from the backend so the UI needs no change when a new one is // added. Live updates arrive via the `capabilities_changed` / // `tool_groups_changed` dashboard events (fired after the rebuild-queue // worker commits the perm JSON file), wired into the entry's mutation // dispatch table. // // Stateless at module scope: each render builds fresh from the fetched // payload. The agent roster (`containersState`) is the only shared state // it reads — to union live containers with agents already named in the // assignments map. // // Editing model: checkboxes are diffed against a `data-baseline` captured at // render time; only changed perm-types per agent are POSTed to // `POST /api/permissions` as `{ changes: [{agent, tool_groups?, capabilities?}] }`. // Omitted field = leave untouched; included array = full replace. The backend // coalesces caps+groups per agent into ONE queue entry (one rebuild, no // double-rebuild). Batch is atomic — saved→rebuilding only fires on a clean 200. import { $ } from "./common.js"; import { el } from "@hive/shared/dom.js"; import { containersState } from "./state.js"; import { asyncBtn } from "@hive/shared/forms.js"; // ── SSE re-render guards ──────────────────────────────────────────── // Skip the live re-render when the operator has unsaved edits in that // section (or a checkbox focused) — clobbering a half-finished edit set // is worse than a brief staleness window. The tab-activation re-fetch // and the post-save re-fetch are the recovery paths; both clear dirty. export function applyCapabilitiesChanged(ev) { const root = $("capabilities-section"); if (!root) return; if (root.contains(document.activeElement)) return; if (sectionHasDirty(root)) return; renderCapabilities(root, ev); } export function applyToolGroupsChanged(ev) { const root = $("tool-groups-section"); if (!root) return; if (root.contains(document.activeElement)) return; if (sectionHasDirty(root)) return; renderToolGroups(root, ev); } // A section is dirty if any checkbox diverges from its render-time // baseline. Cheap DOM scan; no module-level mirror to drift. function sectionHasDirty(root) { for (const cb of root.querySelectorAll("input[type=checkbox]")) { if (cb.checked !== (cb.dataset.baseline === "1")) return true; } return false; } export async function fetchAndRenderCapabilities() { const root = $("capabilities-section"); if (!root) return; root.replaceChildren(); root.append(el("p", { class: "meta" }, "loading…")); try { const resp = await fetch("/api/capabilities"); if (!resp.ok) throw new Error("http " + resp.status); const data = await resp.json(); renderCapabilities(root, data); } catch (err) { root.replaceChildren(); root.append(el("p", { class: "meta" }, "fetch failed: " + err)); } } function renderCapabilities(root, data) { root.replaceChildren(); const { caps, descriptions = {}, assignments, effective = {} } = data; if (!caps || !caps.length) { root.append(el("p", { class: "meta" }, "(no capabilities defined)")); updateSaveBar(); return; } // Agent rows: prefer the backend roster (every manageable agent, // default-perms included). Fall back to the live-container ∪ explicit // union for older payloads that don't carry `agents`. const agentNames = data.agents && data.agents.length ? [...data.agents] : [ ...new Set([ ...Array.from(containersState.keys()), ...Object.keys(assignments), ]), ].sort(); if (!agentNames.length) { root.append(el("p", { class: "meta" }, "(no agents)")); updateSaveBar(); return; } const wrap = el("div", { class: "cap-table-wrap" }); const table = el("table", { class: "cap-table" }); // Header row. const thead = el("thead"); const hrow = el("tr"); hrow.append(el("th", { class: "cap-agent-col" }, "agent")); for (const c of caps) { hrow.append(el("th", { class: "cap-col", title: descriptions[c] || c }, c)); } thead.append(hrow); table.append(thead); const tbody = el("tbody"); for (const name of agentNames) { // Effective caps (explicit-or-default) drive the checkboxes so // default-perms agents show their real grants, not blank. const assigned = effective[name] || assignments[name] || []; // `containersState` is keyed from `nixos-container list`, which // includes stopped-but-configured containers — so a temporarily-stopped // agent is NOT stale. Only destroyed/renamed agents are absent here. const isStale = !containersState.has(name); const tr = el("tr", { class: "cap-row" + (isStale ? " perm-row-stale" : ""), "data-agent": name, }); // Agent name cell. const nameTd = el("td", { class: "cap-agent-col" }); nameTd.append(el("span", { class: "cap-agent-name" }, name)); if (isStale) { nameTd.append(el("span", { class: "perm-stale-label" }, "(not running)")); const removeBtn = el( "button", { type: "button", class: "perm-remove-btn", title: "remove stale permission entries for " + name, }, "✕ remove", ); removeBtn.addEventListener("click", () => clearStaleAgent(name, root)); nameTd.append(removeBtn); } tr.append(nameTd); // One checkbox per capability. for (const c of caps) { const checked = assigned.includes(c); const td = el("td", { class: "cap-col" }); const cb = el("input", { type: "checkbox", class: "cap-cb", "data-cap": c, "data-baseline": checked ? "1" : "0", "aria-label": c, }); cb.checked = checked; cb.addEventListener("change", onCellToggle); td.append(cb); tr.append(td); } tbody.append(tr); } table.append(tbody); wrap.append(table); root.append(wrap); updateSaveBar(); } export async function fetchAndRenderToolGroups() { const root = $("tool-groups-section"); if (!root) return; root.replaceChildren(); root.append(el("p", { class: "meta" }, "loading…")); try { const resp = await fetch("/api/tool-groups"); if (!resp.ok) throw new Error("http " + resp.status); const data = await resp.json(); renderToolGroups(root, data); } catch (err) { root.replaceChildren(); root.append(el("p", { class: "meta" }, "fetch failed: " + err)); } } function renderToolGroups(root, data) { root.replaceChildren(); const { groups, descriptions = {}, assignments, effective = {} } = data; if (!groups || !groups.length) { root.append(el("p", { class: "meta" }, "(no tool groups defined)")); updateSaveBar(); return; } // Agent rows: prefer the backend roster (default-perms agents included); // fall back to the live-container ∪ explicit union for older payloads. const agentNames = data.agents && data.agents.length ? [...data.agents] : [ ...new Set([ ...Array.from(containersState.keys()), ...Object.keys(assignments), ]), ].sort(); if (!agentNames.length) { root.append(el("p", { class: "meta" }, "(no agents)")); updateSaveBar(); return; } const wrap = el("div", { class: "tg-table-wrap" }); const table = el("table", { class: "tg-table" }); // Header row. const thead = el("thead"); const hrow = el("tr"); hrow.append(el("th", { class: "tg-agent-col" }, "agent")); for (const g of groups) { hrow.append( el("th", { class: "tg-group-col", title: descriptions[g] || g }, g), ); } thead.append(hrow); table.append(thead); const tbody = el("tbody"); for (const name of agentNames) { // Effective groups (explicit-or-role-default) drive the checkboxes so // a default agent shows its real groups, not blank — and saving keeps // them instead of silently stripping the defaults. The "(default)" // badge still keys off explicit-assignment presence. const assigned = effective[name] || assignments[name] || []; const hasExplicit = Object.prototype.hasOwnProperty.call(assignments, name); // `containersState` is keyed from `nixos-container list`, which // includes stopped-but-configured containers — only destroyed/renamed // agents are absent. const isStale = !containersState.has(name); const tr = el("tr", { class: "tg-row" + (isStale ? " perm-row-stale" : ""), "data-agent": name, }); // Agent name cell. const nameTd = el("td", { class: "tg-agent-col" }); nameTd.append(el("span", { class: "tg-agent-name" }, name)); if (isStale) { nameTd.append(el("span", { class: "perm-stale-label" }, "(not running)")); const removeBtn = el( "button", { type: "button", class: "perm-remove-btn", title: "remove stale permission entries for " + name, }, "✕ remove", ); removeBtn.addEventListener("click", () => clearStaleAgent(name, root)); nameTd.append(removeBtn); } else if (!hasExplicit) { nameTd.append( el("span", { class: "meta tg-default-label" }, "(default)"), ); } tr.append(nameTd); // One checkbox per group. for (const g of groups) { const checked = assigned.includes(g); const td = el("td", { class: "tg-group-col" }); const cb = el("input", { type: "checkbox", class: "tg-cb", "data-group": g, "data-baseline": checked ? "1" : "0", "aria-label": g, }); cb.checked = checked; cb.addEventListener("change", onCellToggle); td.append(cb); tr.append(td); } tbody.append(tr); } table.append(tbody); wrap.append(table); root.append(wrap); updateSaveBar(); } // ── Save-all wiring ───────────────────────────────────────────────── // Recompute the save bar on every cell toggle. Also clears any stale // error/✓ status the bar may be showing from a prior save. function onCellToggle() { clearSaveStatus(); updateSaveBar(); } // Collect the sparse change-set: one entry per agent that has at least // one perm-type diverging from baseline. Only the changed perm-type(s) // are included so untouched defaults stay defaults (an included array is // a full replacement → omitting it leaves that file alone). function collectChanges() { const byAgent = new Map(); // agent -> { capabilities?, tool_groups? } collectSection( $("capabilities-section"), ".cap-row", ".cap-cb", "cap", "capabilities", byAgent, ); collectSection( $("tool-groups-section"), ".tg-row", ".tg-cb", "group", "tool_groups", byAgent, ); const changes = []; for (const [agent, obj] of byAgent) changes.push({ agent, ...obj }); return changes; } function collectSection(root, rowSel, cbSel, dataKey, field, byAgent) { if (!root) return; for (const tr of root.querySelectorAll(rowSel)) { const agent = tr.dataset.agent; if (!agent) continue; let dirty = false; const selected = []; for (const cb of tr.querySelectorAll(cbSel)) { if (cb.checked !== (cb.dataset.baseline === "1")) dirty = true; if (cb.checked) selected.push(cb.dataset[dataKey]); } if (dirty) { const obj = byAgent.get(agent) || {}; obj[field] = selected; byAgent.set(agent, obj); } } } function updateSaveBar() { const btn = $("perm-save-all"); if (!btn) return; // Don't stomp a transient saving/rebuilding label. if (btn.dataset.busy === "1") return; const n = collectChanges().length; btn.disabled = n === 0; btn.textContent = n === 0 ? "save all" : `save all (${n} agent${n === 1 ? "" : "s"})`; } // Remove all explicit permission entries for a stale (non-running) // agent. Calls DELETE /api/permissions/{agent}, which bypasses the // roster guard so the stale entries can be cleaned up even though the // agent isn't in the live container list. Re-fetches both tables after // the delete so the row disappears immediately. async function clearStaleAgent(name, sectionRoot) { const btn = sectionRoot ? sectionRoot.querySelector( `[data-agent="${CSS.escape(name)}"] .perm-remove-btn`, ) : null; const doDelete = async () => { try { const resp = await fetch("/api/permissions/" + encodeURIComponent(name), { method: "DELETE", }); if (!resp.ok) { const text = await resp.text().catch(() => resp.status); setSaveNote("failed to remove " + name + ": " + text, true); return; } // Re-fetch both sections so the stale row disappears. await Promise.all([ fetchAndRenderCapabilities(), fetchAndRenderToolGroups(), ]); } catch (err) { setSaveNote("failed to remove " + name + ": " + err, true); } }; // asyncBtn guards double-submit; fall through without guard when there // is no button (e.g. called programmatically without a DOM context). // Return the promise so callers can await clearStaleAgent() if needed. if (btn) return asyncBtn(btn, doDelete); await doDelete(); } function clearSaveStatus() { const note = $("perm-save-note"); if (note) { note.textContent = ""; note.classList.remove("perm-save-err"); } } function setSaveNote(text, isErr) { const note = $("perm-save-note"); if (!note) return; note.textContent = text; note.classList.toggle("perm-save-err", !!isErr); } async function saveAll() { const btn = $("perm-save-all"); if (!btn) return; const changes = collectChanges(); if (!changes.length) return; btn.dataset.busy = "1"; btn.disabled = true; btn.textContent = "saving…"; clearSaveStatus(); try { const r = await fetch("/api/permissions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ changes }), }); if (!r.ok) { const txt = await r.text(); btn.dataset.busy = ""; btn.disabled = false; btn.textContent = "save all"; setSaveNote("save failed: " + (txt || "http " + r.status), true); updateSaveBar(); return; } // Atomic 200: the whole batch landed. Show a rebuilding hint and // re-fetch both tables once the queue worker has committed — that // resets baselines (dirty clears) and the bar disables itself. const n = changes.length; btn.textContent = "queued ✓"; setSaveNote(`rebuilding ${n} agent${n === 1 ? "" : "s"}…`, false); setTimeout(() => { btn.dataset.busy = ""; fetchAndRenderCapabilities(); fetchAndRenderToolGroups(); }, 900); } catch (err) { btn.dataset.busy = ""; btn.disabled = false; btn.textContent = "save all"; setSaveNote("save failed: " + String(err), true); updateSaveBar(); } } // Bind the page-level save button once. Called from the dashboard entry // after the DOM is ready (the button lives in the static permissions // pane markup, so it exists before any fetch). export function initPermissions() { const btn = $("perm-save-all"); if (btn && !btn.dataset.bound) { btn.dataset.bound = "1"; btn.addEventListener("click", saveAll); } }