hyperhive/frontend/packages/dashboard/src/permissions.js

416 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
}
}