dashboard: per-container journald viewer
new GET /api/journal/{name}?unit=&lines= shells out journalctl -M
<container> -b --no-pager --output=short-iso --lines=<N> (cap 5000).
optional unit filter, restricted to hive-ag3nt.service /
hive-m1nd.service so the shell-out can't be coerced into reading
unrelated units. validates the container name against the live list
before invoking journalctl.
frontend renders a collapsed '↳ logs · <container>' details block
on each container row. expanding triggers a lazy fetch; refresh
button re-fetches; unit dropdown switches between the harness
service (default) and the full machine journal. output sits in a
24em-tall monospace pre, auto-scrolled to the bottom on fresh
fetch.
hive-c0re's systemd unit already runs as root, so journalctl has
the access it needs.
This commit is contained in:
parent
79a46f359a
commit
0385d96bf3
4 changed files with 173 additions and 11 deletions
|
|
@ -161,11 +161,66 @@
|
|||
}
|
||||
li.append(actions);
|
||||
|
||||
// Per-container journald viewer. Expand to fetch + render the
|
||||
// last N lines; refresh button re-fetches; unit selector
|
||||
// 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));
|
||||
|
||||
ul.append(li);
|
||||
}
|
||||
root.append(ul);
|
||||
}
|
||||
|
||||
// Build the per-container journald <details>. Lazy-fetches when the
|
||||
// operator expands; refresh re-fetches; unit toggle switches
|
||||
// between the harness service and the full machine journal.
|
||||
function buildJournalDetails(containerName, defaultUnit) {
|
||||
const details = el('details', { class: 'journal' });
|
||||
const summary = el('summary', {}, '↳ logs · ' + containerName);
|
||||
const body = el('div', { class: 'journal-body' });
|
||||
const controls = el('div', { class: 'journal-controls' });
|
||||
const unitSelect = el('select', { class: 'journal-unit' });
|
||||
unitSelect.append(
|
||||
el('option', { value: defaultUnit }, defaultUnit),
|
||||
el('option', { value: '' }, '(full machine journal)'),
|
||||
);
|
||||
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 fetchLogs() {
|
||||
if (fetching) return;
|
||||
fetching = true;
|
||||
pre.textContent = 'fetching…';
|
||||
const unit = unitSelect.value;
|
||||
const params = new URLSearchParams({ lines: '500' });
|
||||
if (unit) params.set('unit', unit);
|
||||
try {
|
||||
const resp = await fetch('/api/journal/' + containerName + '?' + params);
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) {
|
||||
pre.textContent = 'error: ' + resp.status + '\n' + text;
|
||||
} else {
|
||||
pre.textContent = text || '(empty)';
|
||||
// Auto-scroll to bottom on fresh fetch.
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
}
|
||||
} catch (err) {
|
||||
pre.textContent = 'fetch failed: ' + err;
|
||||
} finally {
|
||||
fetching = false;
|
||||
}
|
||||
}
|
||||
details.addEventListener('toggle', () => { if (details.open) fetchLogs(); });
|
||||
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); });
|
||||
unitSelect.addEventListener('change', fetchLogs);
|
||||
controls.append(unitSelect, refresh);
|
||||
body.append(controls, pre);
|
||||
details.append(summary, body);
|
||||
return details;
|
||||
}
|
||||
|
||||
function renderTombstones(s) {
|
||||
const root = $('tombstones-section');
|
||||
root.innerHTML = '';
|
||||
|
|
|
|||
|
|
@ -143,6 +143,53 @@ a:hover {
|
|||
opacity: 0.85;
|
||||
}
|
||||
.container-row.tombstone .name { color: var(--muted); }
|
||||
/* Per-container journald viewer: collapsed by default, fetches
|
||||
lazily on expand. The output is in monospace inside a bordered
|
||||
<pre>; controls (unit select + refresh) sit above. */
|
||||
.journal {
|
||||
margin-top: 0.5em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.journal > summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.journal > summary:hover { color: var(--cyan); }
|
||||
.journal .journal-body {
|
||||
margin-top: 0.4em;
|
||||
padding-top: 0.4em;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
.journal-controls {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
margin-bottom: 0.4em;
|
||||
align-items: center;
|
||||
}
|
||||
.journal-unit {
|
||||
font-family: inherit;
|
||||
font-size: 0.9em;
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.2em 0.4em;
|
||||
}
|
||||
.journal-refresh { font-size: 0.75em; padding: 0.15em 0.5em; }
|
||||
.journal-output {
|
||||
margin: 0;
|
||||
background: #11111b;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--purple-dim);
|
||||
padding: 0.5em 0.7em;
|
||||
max-height: 24em;
|
||||
overflow: auto;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.pending-state {
|
||||
color: var(--amber);
|
||||
font-size: 0.85em;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
|||
.route("/answer-question/{id}", post(post_answer_question))
|
||||
.route("/cancel-question/{id}", post(post_cancel_question))
|
||||
.route("/purge-tombstone/{name}", post(post_purge_tombstone))
|
||||
.route("/api/journal/{name}", get(get_journal))
|
||||
.route("/request-spawn", post(post_request_spawn))
|
||||
.route("/messages/stream", get(messages_stream))
|
||||
.with_state(AppState { coord });
|
||||
|
|
@ -467,6 +468,76 @@ async fn post_cancel_question(
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JournalQuery {
|
||||
/// Optional systemd unit filter — e.g. `hive-ag3nt.service`. When
|
||||
/// omitted, returns the full machine journal.
|
||||
#[serde(default)]
|
||||
unit: Option<String>,
|
||||
/// Number of trailing lines to return. Capped at 5000.
|
||||
#[serde(default)]
|
||||
lines: Option<u32>,
|
||||
}
|
||||
|
||||
/// Shell out to `journalctl -M <container> -b` and return its text
|
||||
/// output. Operator-only by virtue of the dashboard being host-bound;
|
||||
/// hive-c0re already runs as root in its systemd unit so journalctl
|
||||
/// has the access it needs.
|
||||
async fn get_journal(
|
||||
AxumPath(name): AxumPath<String>,
|
||||
axum::extract::Query(q): axum::extract::Query<JournalQuery>,
|
||||
) -> Response {
|
||||
// Validate the container name against the list of managed
|
||||
// containers so we don't shell out with arbitrary input.
|
||||
let container = strip_container_prefix(&name);
|
||||
let prefixed = if container == lifecycle::MANAGER_NAME {
|
||||
container.clone()
|
||||
} else {
|
||||
format!("{}{container}", lifecycle::AGENT_PREFIX)
|
||||
};
|
||||
let live = lifecycle::list().await.unwrap_or_default();
|
||||
if !live.iter().any(|c| c == &prefixed) {
|
||||
return error_response(&format!("journal: no managed container {prefixed:?}"));
|
||||
}
|
||||
let lines = q.lines.unwrap_or(500).min(5000);
|
||||
let mut cmd = tokio::process::Command::new("journalctl");
|
||||
cmd.args([
|
||||
"-M",
|
||||
&prefixed,
|
||||
"-b",
|
||||
"--no-pager",
|
||||
"--output=short-iso",
|
||||
"--lines",
|
||||
])
|
||||
.arg(lines.to_string());
|
||||
if let Some(u) = q.unit.as_deref().filter(|s| !s.is_empty()) {
|
||||
// accept hive-ag3nt[.service] / hive-m1nd[.service] — anything
|
||||
// else we refuse, again to keep the shell-out tight.
|
||||
let allowed = ["hive-ag3nt.service", "hive-m1nd.service"];
|
||||
let unit = if u.ends_with(".service") {
|
||||
u.to_owned()
|
||||
} else {
|
||||
format!("{u}.service")
|
||||
};
|
||||
if !allowed.contains(&unit.as_str()) {
|
||||
return error_response(&format!("journal: unknown unit {unit:?}"));
|
||||
}
|
||||
cmd.args(["-u", &unit]);
|
||||
}
|
||||
match cmd.output().await {
|
||||
Ok(out) => {
|
||||
// Combine stdout + stderr — journalctl emits to both on errors.
|
||||
let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
if !out.status.success() {
|
||||
body.push_str("\n--- stderr ---\n");
|
||||
body.push_str(&String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
([("content-type", "text/plain; charset=utf-8")], body).into_response()
|
||||
}
|
||||
Err(e) => error_response(&format!("journalctl spawn: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn post_purge_tombstone(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue