feat(#1005): capabilities UI — per-agent tool-group table in SYST3M tab
Backend (hive-c0re/src/dashboard.rs):
GET /api/tool-groups — returns { groups: [...], assignments: {...} };
groups list comes from ToolGroup::ALL so the UI needs no change when
a new group is added (satisfies the 'no extend ui' requirement)
POST /api/tool-groups/{agent} — accepts { groups: [...] }, calls
set_groups() then enqueues a rebuild so the new HIVE_TOOL_GROUPS
env var takes effect immediately
hive-sh4re/src/lib.rs:
Added ToolGroup::ALL const (ordered slice of every group)
Added ToolGroup::as_str() — snake_case wire name, matches serde
Frontend:
SYST3M tab: new C4P4B1L1T13S section above K3PT ST4T3 with
#capabilities-section placeholder
tabs.js: fetchAndRenderCapabilities() + renderCapabilities() —
columns are built from the groups array returned by the API;
each row has one checkbox per group and a save button that POSTs
and re-fetches after 800ms; agents without explicit assignments
show a (default) label; triggered on each SYST3M tab activation
dashboard.css: .cap-table-wrap/.cap-table/.cap-row/.cap-agent-*
styles for the scrollable matrix table
This commit is contained in:
parent
81607aca26
commit
86a1591cfc
5 changed files with 277 additions and 0 deletions
|
|
@ -2045,6 +2045,58 @@ body.logs-shell {
|
|||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* ─── capabilities (tool-group) table ──────────────────────────────
|
||||
Agents × tool-groups matrix in the SYST3M tab. Horizontally
|
||||
scrollable on narrow viewports. */
|
||||
.cap-table-wrap {
|
||||
overflow-x: auto;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.cap-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82em;
|
||||
min-width: 100%;
|
||||
}
|
||||
.cap-table th,
|
||||
.cap-table td {
|
||||
padding: 0.35em 0.6em;
|
||||
border: 1px solid var(--border);
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.cap-table thead th {
|
||||
background: var(--bg-elev);
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cap-agent-col {
|
||||
text-align: left !important;
|
||||
min-width: 8em;
|
||||
}
|
||||
.cap-agent-name {
|
||||
color: var(--fg);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cap-default-label {
|
||||
margin-left: 0.4em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.cap-cb {
|
||||
cursor: pointer;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
accent-color: var(--purple);
|
||||
}
|
||||
.cap-save-btn {
|
||||
font-size: 0.78em;
|
||||
padding: 0.2em 0.6em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cap-row:hover td {
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
|
||||
/* ─── scheduled prompts tab ────────────────────────────────────────
|
||||
Creation form at the top, list of queued schedule cards below.
|
||||
Cards show: id + source + due-in + cancel-all in the header,
|
||||
|
|
|
|||
|
|
@ -171,6 +171,18 @@
|
|||
<div id="tombstones-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<!-- C4P4B1L1T13S: per-agent tool-group matrix. Rows = agents,
|
||||
cols = tool groups fetched from GET /api/tool-groups.
|
||||
Checking / unchecking and saving POSTs to
|
||||
/api/tool-groups/{agent}; a rebuild is queued automatically.
|
||||
Absent agents default to the role default (shown in parens). -->
|
||||
<h2>◆ C4P4B1L1T13S ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p class="meta">per-agent tool-group assignments. columns are filled from the backend — adding a new group requires no UI change. agents without an explicit entry use the role default (agents: messaging, meta, inbox, execution; manager: all). saving queues a rebuild.</p>
|
||||
<div id="capabilities-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- P33RS: peer hive link cards. Rendered from state.peer_hives;
|
||||
|
|
|
|||
|
|
@ -1204,6 +1204,133 @@ window.marked = marked;
|
|||
root.append(ul);
|
||||
}
|
||||
|
||||
// ── capabilities (tool-group) 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.
|
||||
|
||||
async function fetchAndRenderCapabilities() {
|
||||
const root = $('capabilities-section');
|
||||
if (!root) return;
|
||||
root.innerHTML = '';
|
||||
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();
|
||||
renderCapabilities(root, data);
|
||||
} catch (err) {
|
||||
root.innerHTML = '';
|
||||
root.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
|
||||
}
|
||||
}
|
||||
|
||||
function renderCapabilities(root, data) {
|
||||
root.innerHTML = '';
|
||||
const { groups, 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: '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 g of groups) {
|
||||
hrow.append(el('th', { class: 'cap-group-col', title: g }, g));
|
||||
}
|
||||
hrow.append(el('th', { class: 'cap-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: 'cap-row' });
|
||||
|
||||
// Agent name cell.
|
||||
const nameTd = el('td', { class: 'cap-agent-col' });
|
||||
nameTd.append(el('span', { class: 'cap-agent-name' }, name));
|
||||
if (!hasExplicit) {
|
||||
nameTd.append(el('span', { class: 'meta cap-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: 'cap-group-col' });
|
||||
const cb = el('input', {
|
||||
type: 'checkbox',
|
||||
class: 'cap-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: 'cap-save-col' });
|
||||
const saveBtn = el('button', { type: 'button', class: 'btn cap-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(() => 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);
|
||||
}
|
||||
|
||||
// Derived question state — cold-loaded from /api/state, then mutated
|
||||
// live by `question_added` / `question_resolved` dashboard events.
|
||||
const QUESTION_HISTORY_LIMIT = 20;
|
||||
|
|
@ -3387,6 +3514,9 @@ 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') fetchAndRenderCapabilities();
|
||||
}
|
||||
function syncTabFromHash() {
|
||||
const h = (window.location.hash || '#swarm').replace(/^#/, '');
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
|||
.route("/retry-reminder/{id}", post(post_retry_reminder))
|
||||
.route("/request-spawn", post(post_request_spawn))
|
||||
.route("/api/topology/set-parent", post(post_set_parent))
|
||||
.route("/api/tool-groups", get(get_tool_groups))
|
||||
.route("/api/tool-groups/{agent}", post(post_tool_groups))
|
||||
.route("/op-send", post(post_op_send))
|
||||
.route("/meta-update", post(post_meta_update))
|
||||
.route("/api/schedules", get(api_schedules).post(post_schedule_new))
|
||||
|
|
@ -2475,6 +2477,57 @@ async fn post_set_parent(
|
|||
}
|
||||
}
|
||||
|
||||
// ── tool-group (capabilities) endpoints ──────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ToolGroupsSnapshot {
|
||||
/// Ordered list of all known tool-group names. Drives the column
|
||||
/// headers in the capabilities table — the UI does not hard-code them.
|
||||
groups: Vec<&'static str>,
|
||||
/// Per-agent assignment map. Absent agents use the role default
|
||||
/// (agents: messaging+meta+inbox+execution; manager: all groups).
|
||||
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
async fn get_tool_groups(State(_state): State<AppState>) -> axum::Json<ToolGroupsSnapshot> {
|
||||
let groups = hive_sh4re::ToolGroup::ALL
|
||||
.iter()
|
||||
.map(|g| g.as_str())
|
||||
.collect();
|
||||
let assignments = crate::tool_groups::read();
|
||||
axum::Json(ToolGroupsSnapshot { groups, assignments })
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetToolGroupsBody {
|
||||
groups: Vec<String>,
|
||||
}
|
||||
|
||||
async fn post_tool_groups(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
axum::Json(body): axum::Json<SetToolGroupsBody>,
|
||||
) -> Response {
|
||||
let logical = strip_container_prefix(&name);
|
||||
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||
return reject;
|
||||
}
|
||||
if let Err(e) = crate::tool_groups::set_groups(&logical, &body.groups) {
|
||||
return error_response(&format!("set tool-groups for {logical}: {e}"));
|
||||
}
|
||||
// Trigger a rebuild so the new HIVE_TOOL_GROUPS env var takes effect.
|
||||
state.coord.rebuild_queue.enqueue(
|
||||
crate::rebuild_queue::QueueKind::Rebuild,
|
||||
logical.clone(),
|
||||
crate::rebuild_queue::QueueSource::Manual,
|
||||
"tool-group change via capabilities UI".to_owned(),
|
||||
None,
|
||||
);
|
||||
state.coord.emit_rebuild_queue_snapshot();
|
||||
tracing::info!(agent = %logical, groups = ?body.groups, "operator: set tool-groups via dashboard");
|
||||
(StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
async fn post_rebuild(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
||||
let logical = strip_container_prefix(&name);
|
||||
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||
|
|
|
|||
|
|
@ -789,6 +789,36 @@ impl ToolGroup {
|
|||
Self::Diagnostics,
|
||||
Self::Execution,
|
||||
];
|
||||
|
||||
/// Every known tool group in a stable order. Use this to enumerate
|
||||
/// columns in the capabilities UI or any other place that needs the
|
||||
/// full list without hard-coding it at the call site.
|
||||
pub const ALL: &'static [Self] = &[
|
||||
Self::Messaging,
|
||||
Self::Meta,
|
||||
Self::Inbox,
|
||||
Self::Lifecycle,
|
||||
Self::Approvals,
|
||||
Self::Scheduling,
|
||||
Self::Diagnostics,
|
||||
Self::Execution,
|
||||
];
|
||||
|
||||
/// The snake_case wire name for this group (matches `serde(rename_all =
|
||||
/// "snake_case")` serialisation).
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Messaging => "messaging",
|
||||
Self::Meta => "meta",
|
||||
Self::Inbox => "inbox",
|
||||
Self::Lifecycle => "lifecycle",
|
||||
Self::Approvals => "approvals",
|
||||
Self::Scheduling => "scheduling",
|
||||
Self::Diagnostics => "diagnostics",
|
||||
Self::Execution => "execution",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule row shape on the wire — mirror of
|
||||
|
|
|
|||
Loading…
Reference in a new issue