From a477bc47f8f915e0bb0312bffda55472240e556e Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 9 Jun 2026 12:29:15 +0200 Subject: [PATCH] refactor(frontend): extract the permissions tab into permissions.js (#1451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../packages/dashboard/src/permissions.js | 270 ++++++++++++++++++ frontend/packages/dashboard/src/tabs.js | 262 +---------------- 2 files changed, 274 insertions(+), 258 deletions(-) create mode 100644 frontend/packages/dashboard/src/permissions.js diff --git a/frontend/packages/dashboard/src/permissions.js b/frontend/packages/dashboard/src/permissions.js new file mode 100644 index 00000000..cff5658d --- /dev/null +++ b/frontend/packages/dashboard/src/permissions.js @@ -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); +} diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 5fc58116..e896184e 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -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;