feat(#1053): permissions tab — capabilities UI + move tool-groups
Add a new P3RM1SS10NS tab to the dashboard that consolidates all
per-agent permission configuration:
Backend:
- GET /api/capabilities returns { caps: [...], assignments: {...} }
driven by Capability::ALL variants (manage_root_agent,
read_host_journal, query_agent_state)
- POST /api/capabilities/{agent} writes capabilities.json and queues
a rebuild so HIVE_CAPABILITIES takes effect
Frontend:
- New 'permissions' entry in TABS, placed after 'system'
- P3RM1SS10NS tab pane with two sections:
C4P4B1L1T13S — agents × capabilities checkbox matrix (.cap-*)
T00L GR0UPS — agents × tool-groups checkbox matrix (.tg-*) moved
from SYST3M tab
- activateTab('permissions') fetches both tables; neither has an SSE
channel so they re-fetch on each activation to stay fresh
- CSS for .cap-* mirrors the .tg-* layout (scrollable, Catppuccin)
This commit is contained in:
parent
9d11e5b6d6
commit
013e8740bd
4 changed files with 252 additions and 7 deletions
|
|
@ -1178,6 +1178,121 @@ window.marked = marked;
|
|||
// each save. Groups (columns) come from the backend so the UI doesn't
|
||||
// need updating when a new group is added.
|
||||
|
||||
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, 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-cap-col', title: c }, c.replace(/_/g, '_')));
|
||||
}
|
||||
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-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;
|
||||
|
|
@ -3463,7 +3578,7 @@ window.marked = marked;
|
|||
// (`/flow.html`) reached via the tab-strip link. Tab routing only
|
||||
// applies when the tab DOM is present (e.g. not on the flow page
|
||||
// itself, where these elements don't exist and the loop no-ops).
|
||||
const TABS = ['swarm', 'call', 'system', 'schedules', 'peers', 'settings'];
|
||||
const TABS = ['swarm', 'call', 'system', 'permissions', 'schedules', 'peers', 'settings'];
|
||||
function activateTab(name) {
|
||||
const target = TABS.includes(name) ? name : TABS[0];
|
||||
for (const t of TABS) {
|
||||
|
|
@ -3483,9 +3598,12 @@ window.marked = marked;
|
|||
// Schedules pane has no SSE channel for mutations, so re-fetch
|
||||
// on activation so the operator never lands on stale data.
|
||||
if (target === 'schedules') refreshSchedules();
|
||||
// Capabilities table is on the system pane; fetch on each activation
|
||||
// so it stays fresh without an SSE channel.
|
||||
if (target === 'system') fetchAndRenderToolGroups();
|
||||
// Permissions tables (capabilities + tool-groups) have no SSE channel;
|
||||
// fetch both on each activation so the operator sees fresh data.
|
||||
if (target === 'permissions') {
|
||||
fetchAndRenderCapabilities();
|
||||
fetchAndRenderToolGroups();
|
||||
}
|
||||
}
|
||||
// ─── tabbar overflow menu ────────────────────────────────────────────────
|
||||
// Tabs with `data-overflow="default"` (LOGS, SETTINGS) always live in
|
||||
|
|
|
|||
Loading…
Reference in a new issue