refactor(frontend): extract the permissions tab into permissions.js (#1451)

Second module of the tabs.js split (after the roster keystone). The
P3RM1SS10NS tab — the per-agent capabilities + tool-groups matrices — is
the cleanest leaf: six contiguous functions with no module-scoped state
(each render builds fresh from the fetched payload) and no cross-domain
references except the agent roster, which it now imports from state.js.

Moves applyCapabilitiesChanged / applyToolGroupsChanged (the live-update
handlers wired into the entry's mutation dispatch table) and
fetchAndRenderCapabilities / fetchAndRenderToolGroups (called on tab
activation) into a new permissions.js, exporting those four; the two
renderers stay module-private. tabs.js imports the four — the dispatch
table and tab-activation call-sites resolve unchanged.

Behaviour-preserving: pure code motion. esbuild inlines permissions.js
into the tabs.js bundle, so the static output is unchanged. Build green;
tabs.js drops 258 lines.
This commit is contained in:
iris 2026-06-09 12:29:15 +02:00 committed by mara
commit a477bc47f8
2 changed files with 274 additions and 258 deletions

View file

@ -0,0 +1,270 @@
// 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 } = data;
if (!caps || !caps.length) {
root.append(el('p', { class: 'meta' }, '(no capabilities defined)'));
return;
}
// Agent names: union of live containers + keys already in assignments.
const agentNames = [...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) {
const assigned = 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 } = data;
if (!groups || !groups.length) {
root.append(el('p', { class: 'meta' }, '(no tool groups defined)'));
return;
}
// Agent names: union of live containers + keys already in assignments,
// sorted alphabetically.
const agentNames = [...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) {
// Explicit assignment or empty = using role default.
const assigned = 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);
}

View file

@ -20,6 +20,10 @@ import {
} from './common.js';
import { createTabStrip } from '@hive/shared/tabs.js';
import { containersState, syncContainersFromSnapshot } from './state.js';
import {
applyCapabilitiesChanged, applyToolGroupsChanged,
fetchAndRenderCapabilities, fetchAndRenderToolGroups,
} from './permissions.js';
// mdNode (in common.js) reads `window.marked` for the markdown side
// panel preview path. Set it here on the dashboard entry so file
@ -1347,264 +1351,6 @@ window.marked = marked;
root.append(ul);
}
// ── tool-groups (permissions) table ─────────────────────────────────────
// Fetched from GET /api/tool-groups on system tab activation and after
// each save. Groups (columns) come from the backend so the UI doesn't
// need updating when a new group is added. Live updates via
// `capabilities_changed` / `tool_groups_changed` SSE events fired
// after the rebuild-queue worker commits the perm JSON file.
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);
}
function applyToolGroupsChanged(ev) {
const root = $('tool-groups-section');
if (!root) return;
if (root.contains(document.activeElement)) return;
renderToolGroups(root, ev);
}
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 } = data;
if (!caps || !caps.length) {
root.append(el('p', { class: 'meta' }, '(no capabilities defined)'));
return;
}
// Agent names: union of live containers + keys already in assignments.
const agentNames = [...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) {
const assigned = 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);
}
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 } = data;
if (!groups || !groups.length) {
root.append(el('p', { class: 'meta' }, '(no tool groups defined)'));
return;
}
// Agent names: union of live containers + keys already in assignments,
// sorted alphabetically.
const agentNames = [...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) {
// Explicit assignment or empty = using role default.
const assigned = 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);
}
// Derived question state — cold-loaded from /api/state, then mutated
// live by `question_added` / `question_resolved` dashboard events.
const QUESTION_HISTORY_LIMIT = 20;