dashboard: open long content in a slide-in side panel

file previews, approval diffs, journald logs and applied config no
longer expand inline — they open in a drawer that swipes in from the
right, with a title naming what's open and a close button (esc /
backdrop also close). path references in messages become plain inline
links that open the file in the panel; the sibling-<details> dance in
appendLinkified is gone.

also: the question-answer free-text field is now a textarea — enter
submits, shift+enter inserts a newline.
This commit is contained in:
müde 2026-05-20 10:43:23 +02:00
parent 5aad2d67e1
commit 7ce3da1e21
3 changed files with 308 additions and 228 deletions

View file

@ -38,6 +38,36 @@
return f;
};
// ─── side panel ─────────────────────────────────────────────────────────
// Singleton drawer that swipes in from the right. Long content
// (file previews, approval diffs, journald logs, applied config)
// opens here via `Panel.open(title, node)` instead of expanding
// inline. Body is swapped on each open; closing just slides out so
// the content stays visible through the transition.
const Panel = (() => {
const root = $('side-panel');
const titleEl = $('side-panel-title');
const bodyEl = $('side-panel-body');
function open(title, content) {
titleEl.textContent = title;
bodyEl.replaceChildren(...(content ? [content] : []));
root.classList.add('open');
root.setAttribute('aria-hidden', 'false');
}
function close() {
root.classList.remove('open');
root.setAttribute('aria-hidden', 'true');
}
function bind() {
$('side-panel-close').addEventListener('click', close);
$('side-panel-backdrop').addEventListener('click', close);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && root.classList.contains('open')) close();
});
}
return { open, close, bind };
})();
// ─── path linkification ─────────────────────────────────────────────────
// Agents constantly drop pointer strings into messages + question
// bodies (it's the 1 KiB-cap escape hatch). Anything matching the
@ -54,53 +84,40 @@
if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status));
return text;
}
function makePathPreview(path) {
// Inline anchor + a sibling <details> that lazy-loads the file
// on first open. Caller appends both: the anchor inline with the
// surrounding text, the details as a block sibling after the
// line so the layout doesn't get awkward.
const anchor = el('a', {
href: '#', class: 'path-link', title: 'click to preview ' + path,
}, path);
const details = el('details', { class: 'path-preview' });
const summary = el('summary', {}, '↳ ' + path);
// Lazy-load `path` from /api/state-file into the side panel.
async function openFilePanel(path) {
const pre = el('pre', { class: 'path-preview-body' }, '(fetching…)');
details.append(summary, pre);
let fetched = false;
async function doFetch() {
if (fetched) return;
fetched = true;
Panel.open('↳ ' + path, pre);
try {
pre.textContent = await fetchStateFile(path);
} catch (e) {
pre.textContent = 'error: ' + (e.message || e);
fetched = false; // allow retry on next open
}
}
details.addEventListener('toggle', () => { if (details.open) doFetch(); });
function makePathLink(path) {
const anchor = el('a', {
href: '#', class: 'path-link', title: 'open ' + path + ' in panel',
}, path);
anchor.addEventListener('click', (e) => {
e.preventDefault();
details.open = !details.open;
openFilePanel(path);
});
return { anchor, details };
return anchor;
}
// Append `text` to `parent` as a mix of text nodes + path
// anchors. `refs` is the server-attached `file_refs` array
// (verified-file tokens that appear in `text`); each occurrence
// of a ref in `text` becomes a clickable anchor + a sibling
// <details> preview that lazy-fetches from /api/state-file.
// Anything not in `refs` stays plain text. No client-side
// regex, no probe endpoint — the server saw the body first
// and made the call. When `refs` is empty/missing we just
// emit plain text.
// Append `text` to `parent` as a mix of text nodes + path anchors.
// `refs` is the server-attached `file_refs` array (verified-file
// tokens that appear in `text`); each occurrence of a ref becomes a
// clickable anchor that opens the file in the side panel. Anything
// not in `refs` stays plain text. No client-side regex, no probe
// endpoint — the server saw the body first and made the call. When
// `refs` is empty/missing we just emit plain text.
function appendLinkified(parent, text, refs) {
const previews = [];
if (text == null) return previews;
if (text == null) return;
const str = String(text);
const tokens = (refs || []).slice();
if (!tokens.length) {
if (str) parent.appendChild(document.createTextNode(str));
return previews;
return;
}
// Walk the string left-to-right, at each step looking for the
// next occurrence of any token. Longest-first tie-break so a
@ -128,12 +145,9 @@
if (bestStart > i) {
parent.appendChild(document.createTextNode(str.slice(i, bestStart)));
}
const { anchor, details } = makePathPreview(bestToken);
parent.appendChild(anchor);
previews.push(details);
parent.appendChild(makePathLink(bestToken));
i = bestStart + bestToken.length;
}
return previews;
}
// ─── browser notifications ──────────────────────────────────────────────
@ -567,29 +581,28 @@
}
li.append(actions);
// Per-container journald viewer. Expand to fetch + render the
// last N lines; refresh button re-fetches; unit selector
// Per-container journald viewer. Opens the side panel and
// fetches the last N lines; refresh 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));
li.append(buildJournalTrigger(c.container, journalUnit));
// Per-container applied config viewer. Shows the agent.nix
// the container is actually built against.
li.append(buildConfigDetails(c.name));
li.append(buildConfigTrigger(c.name));
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',
'data-restore-key': 'journal:' + containerName,
});
const summary = el('summary', {}, '↳ logs · ' + containerName);
// Per-container journald viewer. Returns an inline trigger; the
// click opens the side panel and fetches the last N lines. Refresh
// re-fetches; the unit toggle switches between the harness service
// and the full machine journal.
function buildJournalTrigger(containerName, defaultUnit) {
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
'↳ logs · ' + containerName);
trigger.addEventListener('click', () => {
const body = el('div', { class: 'journal-body' });
const controls = el('div', { class: 'journal-controls' });
const unitSelect = el('select', { class: 'journal-unit' });
@ -615,8 +628,9 @@
pre.textContent = 'error: ' + resp.status + '\n' + text;
} else {
pre.textContent = text || '(empty)';
// Auto-scroll to bottom on fresh fetch.
pre.scrollTop = pre.scrollHeight;
// Auto-scroll the panel to the newest lines on fresh fetch.
const sb = $('side-panel-body');
if (sb) sb.scrollTop = sb.scrollHeight;
}
} catch (err) {
pre.textContent = 'fetch failed: ' + err;
@ -624,24 +638,23 @@
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;
Panel.open('logs · ' + containerName, body);
fetchLogs();
});
return trigger;
}
// 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);
// Per-container applied-config viewer. Returns an inline trigger;
// the click opens the side panel and fetches agent.nix. Read-only —
// the file is hive-c0re's applied repo, mutated only via approvals.
function buildConfigTrigger(agentName) {
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
'↳ agent.nix · ' + agentName);
trigger.addEventListener('click', () => {
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' },
@ -659,7 +672,6 @@
pre.textContent = 'error: ' + resp.status + '\n' + text;
} else {
pre.textContent = text || '(empty)';
pre.scrollTop = 0;
}
} catch (err) {
pre.textContent = 'fetch failed: ' + err;
@ -667,12 +679,13 @@
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;
Panel.open('agent.nix · ' + agentName, body);
fetchConfig();
});
return trigger;
}
function renderTombstones(s) {
@ -867,19 +880,25 @@
head.append(' ', el('span', { class: 'q-ttl' }, txt));
}
const qBody = el('div', { class: 'q-body' });
const qPreviews = appendLinkified(qBody, q.question, q.question_refs);
appendLinkified(qBody, q.question, q.question_refs);
li.append(head, qBody);
for (const d of qPreviews) li.appendChild(d);
const f = el('form', {
method: 'POST', action: '/answer-question/' + q.id,
class: 'qform', 'data-async': '', 'data-no-refresh': '',
});
const hasOptions = q.options && q.options.length;
const isMulti = !!q.multi && hasOptions;
const freeText = el('input', {
type: 'text', name: 'answer-free',
placeholder: hasOptions ? 'or type your own…' : 'your answer',
autocomplete: 'off',
const freeText = el('textarea', {
name: 'answer-free', rows: '2', autocomplete: 'off',
placeholder: (hasOptions ? 'or type your own…' : 'your answer')
+ ' (shift+enter for newline)',
});
// Enter submits; shift+enter inserts a newline (textarea default).
freeText.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
f.requestSubmit();
}
});
const optionGroup = el('div', { class: 'q-options' });
if (hasOptions) {
@ -963,17 +982,14 @@
el('span', { class: 'msg-sep' }, 'asked:'),
);
const histBody = el('div', { class: 'q-body' });
const histBodyPreviews = appendLinkified(histBody, q.question, q.question_refs);
appendLinkified(histBody, q.question, q.question_refs);
const ansText = el('span', { class: 'q-answer-text' });
const histAnsPreviews = appendLinkified(ansText, q.answer || '(none)', q.answer_refs);
appendLinkified(ansText, q.answer || '(none)', q.answer_refs);
const ansLine = el('div', { class: 'q-answer' },
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
ansText,
);
li.append(head, histBody);
for (const d of histBodyPreviews) li.appendChild(d);
li.append(ansLine);
for (const d of histAnsPreviews) li.appendChild(d);
li.append(head, histBody, ansLine);
hul.append(li);
}
details.append(hul);
@ -1012,14 +1028,13 @@
for (const m of operatorInbox) {
const li = el('li');
const body = el('span', { class: 'msg-body' });
const previews = appendLinkified(body, m.body, m.file_refs);
appendLinkified(body, m.body, m.file_refs);
li.append(
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
el('span', { class: 'msg-from' }, m.from), ' ',
el('span', { class: 'msg-sep' }, '→ '),
body,
);
for (const d of previews) li.appendChild(d);
ul.append(li);
}
root.append(ul);
@ -1174,16 +1189,15 @@
);
li.append(row);
if (a.diff) {
const details = el('details', {
'data-restore-key': 'approval-diff:' + a.id,
});
details.append(el('summary', {}, 'diff vs applied'));
// Server ships the raw unified diff; classify each line by its
// leading char so `.diff-add` / `.diff-del` / `.diff-hunk` /
// `.diff-file` / `.diff-ctx` colour the output. Building spans
// here (instead of innerHTML-ing pre-rendered markup) keeps
// the snapshot wire format text-only and one less HTML-escape
// surface server-side.
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
'diff vs applied');
trigger.addEventListener('click', () => {
// Server ships the raw unified diff; classify each line by
// its leading char so `.diff-add` / `.diff-del` /
// `.diff-hunk` / `.diff-file` / `.diff-ctx` colour the
// output. Building spans here (instead of innerHTML-ing
// pre-rendered markup) keeps the snapshot wire format
// text-only and one less HTML-escape surface server-side.
const pre = el('pre', { class: 'diff' });
for (const raw of a.diff.split('\n')) {
let cls = 'diff-ctx';
@ -1196,8 +1210,9 @@
span.textContent = raw + '\n';
pre.appendChild(span);
}
details.append(pre);
li.append(details);
Panel.open('diff · ' + a.agent + ' #' + a.id, pre);
});
li.append(trigger);
}
ul.append(li);
}
@ -1381,9 +1396,8 @@
`${r.attempt_count} failed`));
}
const body = el('div', { class: 'reminder-body' });
const previews = appendLinkified(body, r.message);
appendLinkified(body, r.message);
li.append(head, body);
for (const d of previews) li.appendChild(d);
if (r.last_error) {
li.append(el('div', { class: 'reminder-error' },
el('span', { class: 'msg-sep' }, 'error: '),
@ -1439,9 +1453,10 @@
// <details> sections that should survive a refresh need a stable
// `data-restore-key` attribute. snapshotOpenDetails walks managed
// sections and records which keys are currently open; restoreOpenDetails
// re-applies after the render. The `toggle` event fires on
// programmatic open changes too, so any onload-fetch wired up via
// a toggle listener (e.g. journald) re-fires cleanly.
// re-applies after the render. (Long-content drill-ins — file
// previews, diffs, logs, config — open in the side panel instead,
// which lives outside the managed sections and survives re-render
// on its own.)
function snapshotOpenDetails() {
const open = new Set();
for (const id of MANAGED_SECTION_IDS) {
@ -1533,6 +1548,7 @@
}
refreshState();
NOTIF.bind();
Panel.bind();
// ─── message flow: shared terminal pane ────────────────────────────────
// Scroll, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS
@ -1571,9 +1587,8 @@
to.className = 'msg-to'; to.textContent = ev.to;
const body = document.createElement('span');
body.className = 'msg-body';
const previews = appendLinkified(body, ev.body, ev.file_refs);
appendLinkified(body, ev.body, ev.file_refs);
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
for (const d of previews) row.appendChild(d);
}
HiveTerminal.create({
logEl: flow,

View file

@ -129,24 +129,9 @@ 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);
}
/* Per-container journald viewer + applied-config viewer. Both open
in the side panel and lazy-fetch on open; output is monospace
inside a bordered <pre>, controls (unit select + refresh) above. */
.journal-controls {
display: flex;
gap: 0.5em;
@ -168,8 +153,7 @@ a:hover {
color: var(--fg);
border: 1px solid var(--purple-dim);
padding: 0.5em 0.7em;
max-height: 24em;
overflow: auto;
overflow-x: auto;
font-size: 0.85em;
line-height: 1.4;
white-space: pre;
@ -495,34 +479,20 @@ summary:hover { color: var(--purple); }
}
/* Path linkification agents drop pointer strings into messages
constantly; clicking the anchor expands a sibling <details> that
lazy-loads from /api/state-file. */
constantly; clicking the anchor opens the file in the side panel,
lazy-loaded from /api/state-file. */
.path-link {
color: var(--blue, #89b4fa);
text-decoration: underline dotted;
cursor: pointer;
}
.path-link:hover { color: var(--amber); }
.path-preview {
margin: 0.2em 0 0.4em 1.5em;
border-left: 2px solid var(--border);
padding-left: 0.6em;
}
.path-preview > summary {
cursor: pointer;
color: var(--muted);
font-size: 0.85em;
list-style: none;
user-select: none;
}
.path-preview > summary::marker { content: ''; }
/* File-preview body — rendered inside the side panel. */
.path-preview-body {
background: var(--bg);
border: 1px solid var(--border);
padding: 0.5em 0.7em;
margin: 0.3em 0 0;
max-height: 30em;
overflow: auto;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.85em;
@ -599,7 +569,7 @@ summary:hover { color: var(--purple); }
.qform .q-option label { cursor: pointer; user-select: none; }
.qform .q-option input { margin-right: 0.4em; accent-color: var(--amber); }
.qform .q-free { display: flex; }
.qform .q-free input {
.qform .q-free textarea {
flex: 1;
font-family: inherit;
font-size: 1em;
@ -607,9 +577,11 @@ summary:hover { color: var(--purple); }
color: var(--fg);
border: 1px solid var(--border);
padding: 0.4em 0.6em;
resize: vertical;
line-height: 1.4;
}
.qform .q-free input::placeholder { color: var(--muted); }
.qform .q-free input:focus { outline: 1px solid var(--amber); }
.qform .q-free textarea::placeholder { color: var(--muted); }
.qform .q-free textarea:focus { outline: 1px solid var(--amber); }
.qform button { align-self: flex-start; }
.qform-cancel { margin-top: 0.3em; }
.q-history {
@ -655,13 +627,10 @@ summary:hover { color: var(--purple); }
dashboard-specific: each broker event becomes a grid of timestamp +
arrow + from/sep/to + body inside the `.row` shell. */
/* Flex (not grid): the row carries the header chips (ts / arrow /
from / / to / body) inline, and may also carry one or more
`<details>` path-preview siblings appended by appendLinkified.
Grid would treat each preview as an extra grid item in a fixed
column template, distorting the header column widths. Flex
collapses whitespace-only text nodes between items, gives `body`
the remaining width via `flex: 1`, and lets each preview claim a
full-width row of its own via `flex-basis: 100%`. */
from / / to / body) inline. Flex collapses whitespace-only text
nodes between items and gives `body` the remaining width via
`flex: 1`. Path references inside `body` are inline anchors that
open the side panel no full-width sibling rows. */
.live .msgrow {
display: flex;
flex-wrap: wrap;
@ -680,12 +649,6 @@ summary:hover { color: var(--purple); }
the whole flex line wider than the container. */
min-width: 0;
}
.live .msgrow > .path-preview {
flex: 1 0 100%;
/* line up with the message body no left chrome inherited from
the global .path-preview indent. */
margin-left: 0;
}
.live .msgrow.sent .msg-arrow { color: var(--cyan); }
.live .msgrow.delivered .msg-arrow { color: var(--green); }
.msg-ts { color: var(--muted); font-size: 0.85em; }
@ -757,3 +720,88 @@ footer {
font-size: 0.9em;
}
footer a { color: var(--purple); }
/* side panel
Long content (file previews, diffs, journald, applied config)
opens in a drawer that swipes in from the right instead of
expanding inline. `.panel-trigger` is the inline affordance that
opens it. */
.panel-trigger {
background: none;
border: none;
color: var(--muted);
font-family: inherit;
font-size: 0.85em;
letter-spacing: 0.05em;
cursor: pointer;
padding: 0;
margin-top: 0.5em;
display: inline-block;
text-align: left;
}
.panel-trigger:hover { color: var(--cyan); }
.side-panel {
position: fixed;
inset: 0;
z-index: 50;
/* Closed: the wrapper ignores pointer events so the dashboard
underneath stays interactive; `.open` flips it back on. */
pointer-events: none;
}
.side-panel-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
opacity: 0;
transition: opacity 0.2s ease;
}
.side-panel-drawer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: min(760px, 94vw);
display: flex;
flex-direction: column;
background: var(--bg-elev);
border-left: 2px solid var(--purple);
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.45);
transform: translateX(100%);
transition: transform 0.25s ease;
}
.side-panel.open { pointer-events: auto; }
.side-panel.open .side-panel-backdrop { opacity: 1; }
.side-panel.open .side-panel-drawer { transform: translateX(0); }
.side-panel-head {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
padding: 0.7em 1em;
border-bottom: 1px solid var(--border);
}
.side-panel-title {
color: var(--purple);
font-weight: bold;
letter-spacing: 0.05em;
word-break: break-all;
}
.side-panel-close {
flex: 0 0 auto;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
font-family: inherit;
font-size: 1em;
line-height: 1;
padding: 0.25em 0.55em;
cursor: pointer;
}
.side-panel-close:hover { border-color: var(--red); color: var(--red); }
.side-panel-body {
flex: 1 1 auto;
overflow: auto;
padding: 1em;
}

View file

@ -84,6 +84,23 @@
<p>▲△▲ <a href="https://git.berlin.ccc.de/vinzenz/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
</footer>
<!-- Slide-in detail panel. Long content (clicked file previews,
approval diffs, journald logs, applied config) opens here
instead of expanding inline. Singleton — JS swaps the title +
body and toggles `.open`. -->
<div id="side-panel" class="side-panel" aria-hidden="true">
<div class="side-panel-backdrop" id="side-panel-backdrop"></div>
<aside class="side-panel-drawer" role="dialog" aria-modal="true"
aria-labelledby="side-panel-title">
<header class="side-panel-head">
<span class="side-panel-title" id="side-panel-title"></span>
<button type="button" class="side-panel-close" id="side-panel-close"
title="close (esc)">✕</button>
</header>
<div class="side-panel-body" id="side-panel-body"></div>
</aside>
</div>
<script src="/static/hive-fr0nt.js" defer></script>
<script src="/static/app.js" defer></script>
</body>