// Dashboard P3RM1SS10NS tab — the per-agent capabilities + tool-groups // matrices. // // 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. import { $, el } from './common.js'; import { containersState } from './state.js'; export function applyCapabilitiesChanged(ev) { const root = $('capabilities-section'); if (!root) return; // Skip re-render while operator has a checkbox focused in this // section — the tab-activation re-fetch is the recovery path. if (root.contains(document.activeElement)) return; renderCapabilities(root, ev); } export function applyToolGroupsChanged(ev) { const root = $('tool-groups-section'); if (!root) return; if (root.contains(document.activeElement)) return; renderToolGroups(root, ev); } 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)')); 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)')); 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)); } hrow.append(el('th', { class: 'cap-save-col' }, '')); 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] || []; const tr = el('tr', { class: 'cap-row' }); // Agent name cell. tr.append(el('td', { class: 'cap-agent-col' }, el('span', { class: 'cap-agent-name' }, name))); // One checkbox per capability. const checkboxes = []; 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, 'aria-label': c, }); cb.checked = checked; td.append(cb); tr.append(td); checkboxes.push(cb); } // Save button cell. const saveTd = el('td', { class: 'cap-save-col' }); const saveBtn = el('button', { type: 'button', class: 'btn cap-save-btn' }, 'save'); saveBtn.addEventListener('click', async () => { const selectedCaps = checkboxes .filter((cb) => cb.checked) .map((cb) => cb.dataset.cap); saveBtn.disabled = true; saveBtn.textContent = '…'; try { const r = await fetch('/api/capabilities/' + encodeURIComponent(name), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ caps: selectedCaps }), }); if (!r.ok) { const txt = await r.text(); saveBtn.textContent = 'err'; saveBtn.title = txt; } else { saveBtn.textContent = '✓'; setTimeout(fetchAndRenderCapabilities, 800); } } catch (err) { saveBtn.textContent = 'err'; saveBtn.title = String(err); } finally { saveBtn.disabled = false; } }); saveTd.append(saveBtn); tr.append(saveTd); tbody.append(tr); } table.append(tbody); wrap.append(table); root.append(wrap); } 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)')); 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)')); 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)); } hrow.append(el('th', { class: 'tg-save-col' }, '')); 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); const tr = el('tr', { class: 'tg-row' }); // Agent name cell. const nameTd = el('td', { class: 'tg-agent-col' }); nameTd.append(el('span', { class: 'tg-agent-name' }, name)); if (!hasExplicit) { nameTd.append(el('span', { class: 'meta tg-default-label' }, '(default)')); } tr.append(nameTd); // One checkbox per group. const checkboxes = []; 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, 'aria-label': g, }); cb.checked = checked; td.append(cb); tr.append(td); checkboxes.push(cb); } // Save button cell. const saveTd = el('td', { class: 'tg-save-col' }); const saveBtn = el('button', { type: 'button', class: 'btn tg-save-btn' }, 'save'); saveBtn.addEventListener('click', async () => { const selectedGroups = checkboxes .filter((cb) => cb.checked) .map((cb) => cb.dataset.group); saveBtn.disabled = true; saveBtn.textContent = '…'; try { const r = await fetch('/api/tool-groups/' + encodeURIComponent(name), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ groups: selectedGroups }), }); if (!r.ok) { const txt = await r.text(); saveBtn.textContent = 'err'; saveBtn.title = txt; } else { saveBtn.textContent = '✓'; setTimeout(fetchAndRenderToolGroups, 800); } } catch (err) { saveBtn.textContent = 'err'; saveBtn.title = String(err); } finally { saveBtn.disabled = false; } }); saveTd.append(saveBtn); tr.append(saveTd); tbody.append(tr); } table.append(tbody); wrap.append(table); root.append(wrap); }