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

View file

@ -71,6 +71,8 @@ body.logs-shell {
font-size: 0.85em; font-size: 0.85em;
} }
.journal-refresh { font-size: 0.75em; padding: 0.15em 0.5em; } .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 { .journal-output {
margin: 0; margin: 0;
background: var(--crust); background: var(--crust);

View file

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

View file

@ -13,6 +13,11 @@
// - The build list auto-refreshes (debounced 2s) when rebuild_queue_changed // - The build list auto-refreshes (debounced 2s) when rebuild_queue_changed
// fires from the dashboard SSE stream, so new log entries appear without // fires from the dashboard SSE stream, so new log entries appear without
// a manual page refresh. // 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 { import {
$, el, fmtAgeSecs, openStream, $, el, fmtAgeSecs, openStream,
@ -265,8 +270,16 @@ import {
const agentUnitSelect = $('agent-unit-select'); const agentUnitSelect = $('agent-unit-select');
const agentRefresh = $('agent-refresh'); const agentRefresh = $('agent-refresh');
const agentOutput = $('agent-output'); 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() { async function loadAgentList() {
try { try {
const resp = await fetch('/api/state'); const resp = await fetch('/api/state');
@ -278,10 +291,26 @@ import {
for (const c of (state.containers || [])) { for (const c of (state.containers || [])) {
agentSelect.append(el('option', { value: c.name }, c.name)); 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 { /**/ } } catch { /**/ }
} }
let agentFetching = false; let agentFetching = false;
let agentLastFetch = 0;
async function fetchAgent() { async function fetchAgent() {
if (!agentSelect || !agentOutput) return; if (!agentSelect || !agentOutput) return;
const name = agentSelect.value; const name = agentSelect.value;
@ -289,6 +318,7 @@ import {
if (agentFetching) return; if (agentFetching) return;
agentFetching = true; agentFetching = true;
agentOutput.textContent = 'fetching…'; agentOutput.textContent = 'fetching…';
if (agentFetchTs) agentFetchTs.hidden = true;
const unit = agentUnitSelect ? agentUnitSelect.value : ''; const unit = agentUnitSelect ? agentUnitSelect.value : '';
const params = new URLSearchParams({ lines: '500' }); const params = new URLSearchParams({ lines: '500' });
if (unit) params.set('unit', unit); if (unit) params.set('unit', unit);
@ -297,6 +327,13 @@ import {
const text = await resp.text(); const text = await resp.text();
agentOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text; agentOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text;
agentOutput.scrollTop = agentOutput.scrollHeight; agentOutput.scrollTop = agentOutput.scrollHeight;
if (resp.ok) {
agentLastFetch = Date.now();
if (agentFetchTs) {
agentFetchTs.textContent = fmtFetchTs(agentLastFetch);
agentFetchTs.hidden = false;
}
}
} catch (err) { } catch (err) {
agentOutput.textContent = 'fetch failed: ' + err; agentOutput.textContent = 'fetch failed: ' + err;
} finally { } finally {
@ -313,13 +350,16 @@ import {
const systemUnitSelect = $('system-unit-select'); const systemUnitSelect = $('system-unit-select');
const systemRefresh = $('system-refresh'); const systemRefresh = $('system-refresh');
const systemOutput = $('system-output'); const systemOutput = $('system-output');
const systemFetchTs = $('system-fetch-ts');
let systemFetching = false; let systemFetching = false;
let systemLastFetch = 0;
async function fetchSystem() { async function fetchSystem() {
if (!systemOutput) return; if (!systemOutput) return;
if (systemFetching) return; if (systemFetching) return;
systemFetching = true; systemFetching = true;
systemOutput.textContent = 'fetching…'; systemOutput.textContent = 'fetching…';
if (systemFetchTs) systemFetchTs.hidden = true;
const unit = systemUnitSelect ? systemUnitSelect.value : 'hive-c0re.service'; const unit = systemUnitSelect ? systemUnitSelect.value : 'hive-c0re.service';
const params = new URLSearchParams({ lines: '500' }); const params = new URLSearchParams({ lines: '500' });
if (unit) params.set('unit', unit); if (unit) params.set('unit', unit);
@ -328,6 +368,13 @@ import {
const text = await resp.text(); const text = await resp.text();
systemOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text; systemOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text;
systemOutput.scrollTop = systemOutput.scrollHeight; systemOutput.scrollTop = systemOutput.scrollHeight;
if (resp.ok) {
systemLastFetch = Date.now();
if (systemFetchTs) {
systemFetchTs.textContent = fmtFetchTs(systemLastFetch);
systemFetchTs.hidden = false;
}
}
} catch (err) { } catch (err) {
systemOutput.textContent = 'fetch failed: ' + err; systemOutput.textContent = 'fetch failed: ' + err;
} finally { } finally {
@ -346,4 +393,15 @@ import {
fetchBuild(); fetchBuild();
if (tab === 'system') fetchSystem(); 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);
})(); })();