feat(logs): URL deep-link + fetched-age chip for AGENT/SYSTEM tabs

Adds ?agent=name and ?unit=svc URL params to pre-select the agent on
the AGENT tab (used by the per-agent overflow menu journal logs link).
Last-fetched timestamp chip shows how stale the output is.

Agent selection is NOT persisted to localStorage — URL params only.
This commit is contained in:
iris 2026-06-05 13:14:58 +02:00 committed by mara
commit b6b93f9fff
4 changed files with 69 additions and 9 deletions

View file

@ -410,14 +410,12 @@ immediate re-fetch.
name (populated from `GET /api/state`) and unit filter
(`hive-ag3nt.service` / `(full machine journal)`). Fetches
`GET /api/journal/{name}?unit=<unit>&lines=500` on selection change
or `↻ refresh`. Output rendered as a `<pre>` block. The last-selected
agent name and unit are persisted to `localStorage` (`logs-agent` /
`logs-unit`) and restored on page load. A `?agent=<name>` and/or
`?unit=<svc>` URL param overrides the stored values — the per-agent
`⋮` menu's **journal logs →** entry uses this to deep-link directly
to a specific agent's journal. A "fetched N ago" chip appears after
the `↻ refresh` button following each successful fetch and ticks
every 30 s.
or `↻ refresh`. Output rendered as a `<pre>` block. A `?agent=<name>`
and/or `?unit=<svc>` URL param pre-selects the agent + unit on page
load — the per-agent `⋮` menu's **journal logs →** entry uses this to
deep-link directly to a specific agent's journal. A "fetched N ago"
chip appears after the `↻ refresh` button following each successful
fetch and ticks every 30 s.
**SYSTEM sub-tab** — host-side service logs. Unit selector (currently
only `hive-c0re.service`). Fetches

View file

@ -71,6 +71,8 @@ body.logs-shell {
font-size: 0.85em;
}
.journal-refresh { font-size: 0.75em; padding: 0.15em 0.5em; }
/* "fetched N ago" chip next to the refresh button on AGENT + SYSTEM tabs. */
.logs-fetch-ts { font-size: 0.8em; color: var(--muted); }
.journal-output {
margin: 0;
background: var(--crust);

View file

@ -56,6 +56,7 @@
<option value="">(full machine journal)</option>
</select>
<button type="button" class="btn btn-restart" id="agent-refresh">↻ refresh</button>
<span id="agent-fetch-ts" class="meta logs-fetch-ts" hidden></span>
</div>
<pre id="agent-output" class="journal-output">select an agent above</pre>
</section>
@ -69,6 +70,7 @@
<option value="hive-c0re.service">hive-c0re.service</option>
</select>
<button type="button" class="btn btn-restart" id="system-refresh">↻ refresh</button>
<span id="system-fetch-ts" class="meta logs-fetch-ts" hidden></span>
</div>
<pre id="system-output" class="journal-output">loading…</pre>
</section>

View file

@ -13,6 +13,11 @@
// - The build list auto-refreshes (debounced 2s) when rebuild_queue_changed
// fires from the dashboard SSE stream, so new log entries appear without
// a manual page refresh.
//
// URL params `?agent=name` and `?unit=svc` pre-select the agent + unit on
// the AGENT tab (deep-link from other pages, e.g. the per-agent ⋮ menu).
// Last-fetched timestamp is shown next to the refresh button on AGENT +
// SYSTEM tabs so the operator knows how stale the output is.
import {
$, el, fmtAgeSecs, openStream,
@ -265,8 +270,16 @@ import {
const agentUnitSelect = $('agent-unit-select');
const agentRefresh = $('agent-refresh');
const agentOutput = $('agent-output');
const agentFetchTs = $('agent-fetch-ts');
// Populate agent selector from /api/state
// Format a last-fetched timestamp: "fetched just now" / "fetched 3m ago".
function fmtFetchTs(fetchedAt) {
const ageSecs = Math.floor((Date.now() - fetchedAt) / 1000);
return 'fetched ' + (ageSecs < 5 ? 'just now' : fmtAgeSecs(ageSecs) + ' ago');
}
// Populate agent selector from /api/state, then honour any `?agent=` /
// `?unit=` URL params (used by the per-agent ⋮ menu's deep-link).
async function loadAgentList() {
try {
const resp = await fetch('/api/state');
@ -278,10 +291,26 @@ import {
for (const c of (state.containers || [])) {
agentSelect.append(el('option', { value: c.name }, c.name));
}
// Deep-link: honour ?agent= and ?unit= URL params.
const urlAgent = new URLSearchParams(location.search).get('agent');
const urlUnit = new URLSearchParams(location.search).get('unit');
if (urlAgent) {
const found = Array.from(agentSelect.options).some((o) => o.value === urlAgent);
if (found) {
agentSelect.value = urlAgent;
if (urlUnit && agentUnitSelect) {
const unitFound = Array.from(agentUnitSelect.options)
.some((o) => o.value === urlUnit);
if (unitFound) agentUnitSelect.value = urlUnit;
}
fetchAgent();
}
}
} catch { /**/ }
}
let agentFetching = false;
let agentLastFetch = 0;
async function fetchAgent() {
if (!agentSelect || !agentOutput) return;
const name = agentSelect.value;
@ -289,6 +318,7 @@ import {
if (agentFetching) return;
agentFetching = true;
agentOutput.textContent = 'fetching…';
if (agentFetchTs) agentFetchTs.hidden = true;
const unit = agentUnitSelect ? agentUnitSelect.value : '';
const params = new URLSearchParams({ lines: '500' });
if (unit) params.set('unit', unit);
@ -297,6 +327,13 @@ import {
const text = await resp.text();
agentOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text;
agentOutput.scrollTop = agentOutput.scrollHeight;
if (resp.ok) {
agentLastFetch = Date.now();
if (agentFetchTs) {
agentFetchTs.textContent = fmtFetchTs(agentLastFetch);
agentFetchTs.hidden = false;
}
}
} catch (err) {
agentOutput.textContent = 'fetch failed: ' + err;
} finally {
@ -313,13 +350,16 @@ import {
const systemUnitSelect = $('system-unit-select');
const systemRefresh = $('system-refresh');
const systemOutput = $('system-output');
const systemFetchTs = $('system-fetch-ts');
let systemFetching = false;
let systemLastFetch = 0;
async function fetchSystem() {
if (!systemOutput) return;
if (systemFetching) return;
systemFetching = true;
systemOutput.textContent = 'fetching…';
if (systemFetchTs) systemFetchTs.hidden = true;
const unit = systemUnitSelect ? systemUnitSelect.value : 'hive-c0re.service';
const params = new URLSearchParams({ lines: '500' });
if (unit) params.set('unit', unit);
@ -328,6 +368,13 @@ import {
const text = await resp.text();
systemOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text;
systemOutput.scrollTop = systemOutput.scrollHeight;
if (resp.ok) {
systemLastFetch = Date.now();
if (systemFetchTs) {
systemFetchTs.textContent = fmtFetchTs(systemLastFetch);
systemFetchTs.hidden = false;
}
}
} catch (err) {
systemOutput.textContent = 'fetch failed: ' + err;
} finally {
@ -346,4 +393,15 @@ import {
fetchBuild();
if (tab === 'system') fetchSystem();
// Tick the last-fetched timestamps every 30s so "fetched 1m ago" stays
// accurate without a manual refresh.
setInterval(() => {
if (agentLastFetch && agentFetchTs && !agentFetchTs.hidden) {
agentFetchTs.textContent = fmtFetchTs(agentLastFetch);
}
if (systemLastFetch && systemFetchTs && !systemFetchTs.hidden) {
systemFetchTs.textContent = fmtFetchTs(systemLastFetch);
}
}, 30_000);
})();