dashboard: per-container applied agent.nix viewer

new GET /api/agent-config/{name} returns the contents of
/var/lib/hyperhive/applied/<name>/agent.nix — the file the
container actually builds against. validated against the live
container list to avoid arbitrary filesystem reads.

frontend mirrors the journald viewer: collapsed <details> on each
container row, lazy-fetches on expand, refresh button re-fetches.
restore-keyed (agent-config:<name>) so it survives the dashboard
heartbeat refresh.

read-only — mutating the applied config goes through the existing
request_apply_commit + operator approval flow.
This commit is contained in:
müde 2026-05-15 21:46:25 +02:00
parent 80229c6af9
commit 91c78d626f
3 changed files with 70 additions and 9 deletions

View file

@ -290,6 +290,9 @@
// narrows to the harness service (or empty = full machine).
const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service';
li.append(buildJournalDetails(c.container, journalUnit));
// Per-container applied config viewer. Shows the agent.nix
// the container is actually built against.
li.append(buildConfigDetails(c.name));
ul.append(li);
}
@ -348,6 +351,48 @@
return details;
}
// Per-container applied-config viewer. Lazy-fetches on expand;
// refresh button re-fetches. Read-only — the file is hive-c0re's
// applied repo, mutated only via the approval flow.
function buildConfigDetails(agentName) {
const details = el('details', {
class: 'journal',
'data-restore-key': 'agent-config:' + agentName,
});
const summary = el('summary', {}, '↳ agent.nix · ' + agentName);
const body = el('div', { class: 'journal-body' });
const controls = el('div', { class: 'journal-controls' });
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
'↻ refresh');
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
let fetching = false;
async function fetchConfig() {
if (fetching) return;
fetching = true;
pre.textContent = 'fetching…';
try {
const resp = await fetch('/api/agent-config/' + agentName);
const text = await resp.text();
if (!resp.ok) {
pre.textContent = 'error: ' + resp.status + '\n' + text;
} else {
pre.textContent = text || '(empty)';
pre.scrollTop = 0;
}
} catch (err) {
pre.textContent = 'fetch failed: ' + err;
} finally {
fetching = false;
}
}
details.addEventListener('toggle', () => { if (details.open) fetchConfig(); });
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchConfig(); });
controls.append(refresh);
body.append(controls, pre);
details.append(summary, body);
return details;
}
function renderTombstones(s) {
const root = $('tombstones-section');
root.innerHTML = '';

View file

@ -54,6 +54,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/cancel-question/{id}", post(post_cancel_question))
.route("/purge-tombstone/{name}", post(post_purge_tombstone))
.route("/api/journal/{name}", get(get_journal))
.route("/api/agent-config/{name}", get(get_agent_config))
.route("/request-spawn", post(post_request_spawn))
.route("/messages/stream", get(messages_stream))
.with_state(AppState { coord });
@ -548,6 +549,30 @@ async fn get_journal(
}
}
/// Show the current `agent.nix` from the applied repo — the file
/// the container actually builds against. Read-only; the manager
/// can't influence what this returns (that path goes through the
/// approval queue).
async fn get_agent_config(AxumPath(name): AxumPath<String>) -> Response {
let logical = strip_container_prefix(&name);
// Constrain to managed containers — same shape as the journal
// endpoint, prevents arbitrary filesystem reads.
let live = lifecycle::list().await.unwrap_or_default();
let prefixed = if logical == lifecycle::MANAGER_NAME {
logical.clone()
} else {
format!("{}{logical}", lifecycle::AGENT_PREFIX)
};
if !live.iter().any(|c| c == &prefixed) {
return error_response(&format!("agent-config: no managed container {prefixed:?}"));
}
let path = Coordinator::agent_applied_dir(&logical).join("agent.nix");
match std::fs::read_to_string(&path) {
Ok(body) => ([("content-type", "text/plain; charset=utf-8")], body).into_response(),
Err(e) => error_response(&format!("read {}: {e}", path.display())),
}
}
async fn post_purge_tombstone(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,