// 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 { $, el } from './common.js'; import { containersState } from './state.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) { // Disable the row's remove button while the request is in flight to // prevent a double-submit. const btn = sectionRoot ? sectionRoot.querySelector(`[data-agent="${CSS.escape(name)}"] .perm-remove-btn`) : null; if (btn) btn.disabled = true; 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); if (btn) btn.disabled = false; return; } } catch (err) { setSaveNote('failed to remove ' + name + ': ' + err, true); if (btn) btn.disabled = false; return; } // Re-fetch both sections so the stale row disappears. await Promise.all([fetchAndRenderCapabilities(), fetchAndRenderToolGroups()]); } 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); } }