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:
parent
5aad2d67e1
commit
7ce3da1e21
3 changed files with 308 additions and 228 deletions
|
|
@ -38,6 +38,36 @@
|
||||||
return f;
|
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 ─────────────────────────────────────────────────
|
// ─── path linkification ─────────────────────────────────────────────────
|
||||||
// Agents constantly drop pointer strings into messages + question
|
// Agents constantly drop pointer strings into messages + question
|
||||||
// bodies (it's the 1 KiB-cap escape hatch). Anything matching the
|
// 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));
|
if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status));
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
function makePathPreview(path) {
|
// Lazy-load `path` from /api/state-file into the side panel.
|
||||||
// Inline anchor + a sibling <details> that lazy-loads the file
|
async function openFilePanel(path) {
|
||||||
// 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);
|
|
||||||
const pre = el('pre', { class: 'path-preview-body' }, '(fetching…)');
|
const pre = el('pre', { class: 'path-preview-body' }, '(fetching…)');
|
||||||
details.append(summary, pre);
|
Panel.open('↳ ' + path, pre);
|
||||||
let fetched = false;
|
try {
|
||||||
async function doFetch() {
|
pre.textContent = await fetchStateFile(path);
|
||||||
if (fetched) return;
|
} catch (e) {
|
||||||
fetched = true;
|
pre.textContent = 'error: ' + (e.message || e);
|
||||||
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) => {
|
anchor.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
details.open = !details.open;
|
openFilePanel(path);
|
||||||
});
|
});
|
||||||
return { anchor, details };
|
return anchor;
|
||||||
}
|
}
|
||||||
// Append `text` to `parent` as a mix of text nodes + path
|
// Append `text` to `parent` as a mix of text nodes + path anchors.
|
||||||
// anchors. `refs` is the server-attached `file_refs` array
|
// `refs` is the server-attached `file_refs` array (verified-file
|
||||||
// (verified-file tokens that appear in `text`); each occurrence
|
// tokens that appear in `text`); each occurrence of a ref becomes a
|
||||||
// of a ref in `text` becomes a clickable anchor + a sibling
|
// clickable anchor that opens the file in the side panel. Anything
|
||||||
// <details> preview that lazy-fetches from /api/state-file.
|
// not in `refs` stays plain text. No client-side regex, no probe
|
||||||
// Anything not in `refs` stays plain text. No client-side
|
// endpoint — the server saw the body first and made the call. When
|
||||||
// regex, no probe endpoint — the server saw the body first
|
// `refs` is empty/missing we just emit plain text.
|
||||||
// and made the call. When `refs` is empty/missing we just
|
|
||||||
// emit plain text.
|
|
||||||
function appendLinkified(parent, text, refs) {
|
function appendLinkified(parent, text, refs) {
|
||||||
const previews = [];
|
if (text == null) return;
|
||||||
if (text == null) return previews;
|
|
||||||
const str = String(text);
|
const str = String(text);
|
||||||
const tokens = (refs || []).slice();
|
const tokens = (refs || []).slice();
|
||||||
if (!tokens.length) {
|
if (!tokens.length) {
|
||||||
if (str) parent.appendChild(document.createTextNode(str));
|
if (str) parent.appendChild(document.createTextNode(str));
|
||||||
return previews;
|
return;
|
||||||
}
|
}
|
||||||
// Walk the string left-to-right, at each step looking for the
|
// Walk the string left-to-right, at each step looking for the
|
||||||
// next occurrence of any token. Longest-first tie-break so a
|
// next occurrence of any token. Longest-first tie-break so a
|
||||||
|
|
@ -128,12 +145,9 @@
|
||||||
if (bestStart > i) {
|
if (bestStart > i) {
|
||||||
parent.appendChild(document.createTextNode(str.slice(i, bestStart)));
|
parent.appendChild(document.createTextNode(str.slice(i, bestStart)));
|
||||||
}
|
}
|
||||||
const { anchor, details } = makePathPreview(bestToken);
|
parent.appendChild(makePathLink(bestToken));
|
||||||
parent.appendChild(anchor);
|
|
||||||
previews.push(details);
|
|
||||||
i = bestStart + bestToken.length;
|
i = bestStart + bestToken.length;
|
||||||
}
|
}
|
||||||
return previews;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── browser notifications ──────────────────────────────────────────────
|
// ─── browser notifications ──────────────────────────────────────────────
|
||||||
|
|
@ -567,112 +581,111 @@
|
||||||
}
|
}
|
||||||
li.append(actions);
|
li.append(actions);
|
||||||
|
|
||||||
// Per-container journald viewer. Expand to fetch + render the
|
// Per-container journald viewer. Opens the side panel and
|
||||||
// last N lines; refresh button re-fetches; unit selector
|
// fetches the last N lines; refresh re-fetches; unit selector
|
||||||
// narrows to the harness service (or empty = full machine).
|
// narrows to the harness service (or empty = full machine).
|
||||||
const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service';
|
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
|
// Per-container applied config viewer. Shows the agent.nix
|
||||||
// the container is actually built against.
|
// the container is actually built against.
|
||||||
li.append(buildConfigDetails(c.name));
|
li.append(buildConfigTrigger(c.name));
|
||||||
|
|
||||||
ul.append(li);
|
ul.append(li);
|
||||||
}
|
}
|
||||||
root.append(ul);
|
root.append(ul);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the per-container journald <details>. Lazy-fetches when the
|
// Per-container journald viewer. Returns an inline trigger; the
|
||||||
// operator expands; refresh re-fetches; unit toggle switches
|
// click opens the side panel and fetches the last N lines. Refresh
|
||||||
// between the harness service and the full machine journal.
|
// re-fetches; the unit toggle switches between the harness service
|
||||||
function buildJournalDetails(containerName, defaultUnit) {
|
// and the full machine journal.
|
||||||
const details = el('details', {
|
function buildJournalTrigger(containerName, defaultUnit) {
|
||||||
class: 'journal',
|
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
|
||||||
'data-restore-key': 'journal:' + containerName,
|
'↳ logs · ' + containerName);
|
||||||
});
|
trigger.addEventListener('click', () => {
|
||||||
const summary = el('summary', {}, '↳ logs · ' + containerName);
|
const body = el('div', { class: 'journal-body' });
|
||||||
const body = el('div', { class: 'journal-body' });
|
const controls = el('div', { class: 'journal-controls' });
|
||||||
const controls = el('div', { class: 'journal-controls' });
|
const unitSelect = el('select', { class: 'journal-unit' });
|
||||||
const unitSelect = el('select', { class: 'journal-unit' });
|
unitSelect.append(
|
||||||
unitSelect.append(
|
el('option', { value: defaultUnit }, defaultUnit),
|
||||||
el('option', { value: defaultUnit }, defaultUnit),
|
el('option', { value: '' }, '(full machine journal)'),
|
||||||
el('option', { value: '' }, '(full machine journal)'),
|
);
|
||||||
);
|
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
|
||||||
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
|
'↻ refresh');
|
||||||
'↻ refresh');
|
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
|
||||||
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
|
let fetching = false;
|
||||||
let fetching = false;
|
async function fetchLogs() {
|
||||||
async function fetchLogs() {
|
if (fetching) return;
|
||||||
if (fetching) return;
|
fetching = true;
|
||||||
fetching = true;
|
pre.textContent = 'fetching…';
|
||||||
pre.textContent = 'fetching…';
|
const unit = unitSelect.value;
|
||||||
const unit = unitSelect.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);
|
try {
|
||||||
try {
|
const resp = await fetch('/api/journal/' + containerName + '?' + params);
|
||||||
const resp = await fetch('/api/journal/' + containerName + '?' + params);
|
const text = await resp.text();
|
||||||
const text = await resp.text();
|
if (!resp.ok) {
|
||||||
if (!resp.ok) {
|
pre.textContent = 'error: ' + resp.status + '\n' + text;
|
||||||
pre.textContent = 'error: ' + resp.status + '\n' + text;
|
} else {
|
||||||
} else {
|
pre.textContent = text || '(empty)';
|
||||||
pre.textContent = text || '(empty)';
|
// Auto-scroll the panel to the newest lines on fresh fetch.
|
||||||
// Auto-scroll to bottom on fresh fetch.
|
const sb = $('side-panel-body');
|
||||||
pre.scrollTop = pre.scrollHeight;
|
if (sb) sb.scrollTop = sb.scrollHeight;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
pre.textContent = 'fetch failed: ' + err;
|
||||||
|
} finally {
|
||||||
|
fetching = false;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
pre.textContent = 'fetch failed: ' + err;
|
|
||||||
} finally {
|
|
||||||
fetching = false;
|
|
||||||
}
|
}
|
||||||
}
|
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); });
|
||||||
details.addEventListener('toggle', () => { if (details.open) fetchLogs(); });
|
unitSelect.addEventListener('change', fetchLogs);
|
||||||
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); });
|
controls.append(unitSelect, refresh);
|
||||||
unitSelect.addEventListener('change', fetchLogs);
|
body.append(controls, pre);
|
||||||
controls.append(unitSelect, refresh);
|
Panel.open('logs · ' + containerName, body);
|
||||||
body.append(controls, pre);
|
fetchLogs();
|
||||||
details.append(summary, body);
|
});
|
||||||
return details;
|
return trigger;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-container applied-config viewer. Lazy-fetches on expand;
|
// Per-container applied-config viewer. Returns an inline trigger;
|
||||||
// refresh button re-fetches. Read-only — the file is hive-c0re's
|
// the click opens the side panel and fetches agent.nix. Read-only —
|
||||||
// applied repo, mutated only via the approval flow.
|
// the file is hive-c0re's applied repo, mutated only via approvals.
|
||||||
function buildConfigDetails(agentName) {
|
function buildConfigTrigger(agentName) {
|
||||||
const details = el('details', {
|
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
|
||||||
class: 'journal',
|
'↳ agent.nix · ' + agentName);
|
||||||
'data-restore-key': 'agent-config:' + agentName,
|
trigger.addEventListener('click', () => {
|
||||||
});
|
const body = el('div', { class: 'journal-body' });
|
||||||
const summary = el('summary', {}, '↳ agent.nix · ' + agentName);
|
const controls = el('div', { class: 'journal-controls' });
|
||||||
const body = el('div', { class: 'journal-body' });
|
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
|
||||||
const controls = el('div', { class: 'journal-controls' });
|
'↻ refresh');
|
||||||
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
|
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
|
||||||
'↻ refresh');
|
let fetching = false;
|
||||||
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
|
async function fetchConfig() {
|
||||||
let fetching = false;
|
if (fetching) return;
|
||||||
async function fetchConfig() {
|
fetching = true;
|
||||||
if (fetching) return;
|
pre.textContent = 'fetching…';
|
||||||
fetching = true;
|
try {
|
||||||
pre.textContent = 'fetching…';
|
const resp = await fetch('/api/agent-config/' + agentName);
|
||||||
try {
|
const text = await resp.text();
|
||||||
const resp = await fetch('/api/agent-config/' + agentName);
|
if (!resp.ok) {
|
||||||
const text = await resp.text();
|
pre.textContent = 'error: ' + resp.status + '\n' + text;
|
||||||
if (!resp.ok) {
|
} else {
|
||||||
pre.textContent = 'error: ' + resp.status + '\n' + text;
|
pre.textContent = text || '(empty)';
|
||||||
} else {
|
}
|
||||||
pre.textContent = text || '(empty)';
|
} catch (err) {
|
||||||
pre.scrollTop = 0;
|
pre.textContent = 'fetch failed: ' + err;
|
||||||
|
} finally {
|
||||||
|
fetching = false;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
pre.textContent = 'fetch failed: ' + err;
|
|
||||||
} finally {
|
|
||||||
fetching = false;
|
|
||||||
}
|
}
|
||||||
}
|
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchConfig(); });
|
||||||
details.addEventListener('toggle', () => { if (details.open) fetchConfig(); });
|
controls.append(refresh);
|
||||||
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchConfig(); });
|
body.append(controls, pre);
|
||||||
controls.append(refresh);
|
Panel.open('agent.nix · ' + agentName, body);
|
||||||
body.append(controls, pre);
|
fetchConfig();
|
||||||
details.append(summary, body);
|
});
|
||||||
return details;
|
return trigger;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTombstones(s) {
|
function renderTombstones(s) {
|
||||||
|
|
@ -867,19 +880,25 @@
|
||||||
head.append(' ', el('span', { class: 'q-ttl' }, txt));
|
head.append(' ', el('span', { class: 'q-ttl' }, txt));
|
||||||
}
|
}
|
||||||
const qBody = el('div', { class: 'q-body' });
|
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);
|
li.append(head, qBody);
|
||||||
for (const d of qPreviews) li.appendChild(d);
|
|
||||||
const f = el('form', {
|
const f = el('form', {
|
||||||
method: 'POST', action: '/answer-question/' + q.id,
|
method: 'POST', action: '/answer-question/' + q.id,
|
||||||
class: 'qform', 'data-async': '', 'data-no-refresh': '',
|
class: 'qform', 'data-async': '', 'data-no-refresh': '',
|
||||||
});
|
});
|
||||||
const hasOptions = q.options && q.options.length;
|
const hasOptions = q.options && q.options.length;
|
||||||
const isMulti = !!q.multi && hasOptions;
|
const isMulti = !!q.multi && hasOptions;
|
||||||
const freeText = el('input', {
|
const freeText = el('textarea', {
|
||||||
type: 'text', name: 'answer-free',
|
name: 'answer-free', rows: '2', autocomplete: 'off',
|
||||||
placeholder: hasOptions ? 'or type your own…' : 'your answer',
|
placeholder: (hasOptions ? 'or type your own…' : 'your answer')
|
||||||
autocomplete: 'off',
|
+ ' (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' });
|
const optionGroup = el('div', { class: 'q-options' });
|
||||||
if (hasOptions) {
|
if (hasOptions) {
|
||||||
|
|
@ -963,17 +982,14 @@
|
||||||
el('span', { class: 'msg-sep' }, 'asked:'),
|
el('span', { class: 'msg-sep' }, 'asked:'),
|
||||||
);
|
);
|
||||||
const histBody = el('div', { class: 'q-body' });
|
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 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' },
|
const ansLine = el('div', { class: 'q-answer' },
|
||||||
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
|
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
|
||||||
ansText,
|
ansText,
|
||||||
);
|
);
|
||||||
li.append(head, histBody);
|
li.append(head, histBody, ansLine);
|
||||||
for (const d of histBodyPreviews) li.appendChild(d);
|
|
||||||
li.append(ansLine);
|
|
||||||
for (const d of histAnsPreviews) li.appendChild(d);
|
|
||||||
hul.append(li);
|
hul.append(li);
|
||||||
}
|
}
|
||||||
details.append(hul);
|
details.append(hul);
|
||||||
|
|
@ -1012,14 +1028,13 @@
|
||||||
for (const m of operatorInbox) {
|
for (const m of operatorInbox) {
|
||||||
const li = el('li');
|
const li = el('li');
|
||||||
const body = el('span', { class: 'msg-body' });
|
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(
|
li.append(
|
||||||
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
|
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
|
||||||
el('span', { class: 'msg-from' }, m.from), ' ',
|
el('span', { class: 'msg-from' }, m.from), ' ',
|
||||||
el('span', { class: 'msg-sep' }, '→ '),
|
el('span', { class: 'msg-sep' }, '→ '),
|
||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
for (const d of previews) li.appendChild(d);
|
|
||||||
ul.append(li);
|
ul.append(li);
|
||||||
}
|
}
|
||||||
root.append(ul);
|
root.append(ul);
|
||||||
|
|
@ -1174,30 +1189,30 @@
|
||||||
);
|
);
|
||||||
li.append(row);
|
li.append(row);
|
||||||
if (a.diff) {
|
if (a.diff) {
|
||||||
const details = el('details', {
|
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
|
||||||
'data-restore-key': 'approval-diff:' + a.id,
|
'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';
|
||||||
|
if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file';
|
||||||
|
else if (raw.startsWith('@')) cls = 'diff-hunk';
|
||||||
|
else if (raw.startsWith('+')) cls = 'diff-add';
|
||||||
|
else if (raw.startsWith('-')) cls = 'diff-del';
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = cls;
|
||||||
|
span.textContent = raw + '\n';
|
||||||
|
pre.appendChild(span);
|
||||||
|
}
|
||||||
|
Panel.open('diff · ' + a.agent + ' #' + a.id, pre);
|
||||||
});
|
});
|
||||||
details.append(el('summary', {}, 'diff vs applied'));
|
li.append(trigger);
|
||||||
// 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';
|
|
||||||
if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file';
|
|
||||||
else if (raw.startsWith('@')) cls = 'diff-hunk';
|
|
||||||
else if (raw.startsWith('+')) cls = 'diff-add';
|
|
||||||
else if (raw.startsWith('-')) cls = 'diff-del';
|
|
||||||
const span = document.createElement('span');
|
|
||||||
span.className = cls;
|
|
||||||
span.textContent = raw + '\n';
|
|
||||||
pre.appendChild(span);
|
|
||||||
}
|
|
||||||
details.append(pre);
|
|
||||||
li.append(details);
|
|
||||||
}
|
}
|
||||||
ul.append(li);
|
ul.append(li);
|
||||||
}
|
}
|
||||||
|
|
@ -1381,9 +1396,8 @@
|
||||||
`⚠ ${r.attempt_count} failed`));
|
`⚠ ${r.attempt_count} failed`));
|
||||||
}
|
}
|
||||||
const body = el('div', { class: 'reminder-body' });
|
const body = el('div', { class: 'reminder-body' });
|
||||||
const previews = appendLinkified(body, r.message);
|
appendLinkified(body, r.message);
|
||||||
li.append(head, body);
|
li.append(head, body);
|
||||||
for (const d of previews) li.appendChild(d);
|
|
||||||
if (r.last_error) {
|
if (r.last_error) {
|
||||||
li.append(el('div', { class: 'reminder-error' },
|
li.append(el('div', { class: 'reminder-error' },
|
||||||
el('span', { class: 'msg-sep' }, 'error: '),
|
el('span', { class: 'msg-sep' }, 'error: '),
|
||||||
|
|
@ -1439,9 +1453,10 @@
|
||||||
// <details> sections that should survive a refresh need a stable
|
// <details> sections that should survive a refresh need a stable
|
||||||
// `data-restore-key` attribute. snapshotOpenDetails walks managed
|
// `data-restore-key` attribute. snapshotOpenDetails walks managed
|
||||||
// sections and records which keys are currently open; restoreOpenDetails
|
// sections and records which keys are currently open; restoreOpenDetails
|
||||||
// re-applies after the render. The `toggle` event fires on
|
// re-applies after the render. (Long-content drill-ins — file
|
||||||
// programmatic open changes too, so any onload-fetch wired up via
|
// previews, diffs, logs, config — open in the side panel instead,
|
||||||
// a toggle listener (e.g. journald) re-fires cleanly.
|
// which lives outside the managed sections and survives re-render
|
||||||
|
// on its own.)
|
||||||
function snapshotOpenDetails() {
|
function snapshotOpenDetails() {
|
||||||
const open = new Set();
|
const open = new Set();
|
||||||
for (const id of MANAGED_SECTION_IDS) {
|
for (const id of MANAGED_SECTION_IDS) {
|
||||||
|
|
@ -1533,6 +1548,7 @@
|
||||||
}
|
}
|
||||||
refreshState();
|
refreshState();
|
||||||
NOTIF.bind();
|
NOTIF.bind();
|
||||||
|
Panel.bind();
|
||||||
|
|
||||||
// ─── message flow: shared terminal pane ────────────────────────────────
|
// ─── message flow: shared terminal pane ────────────────────────────────
|
||||||
// Scroll, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS
|
// Scroll, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS
|
||||||
|
|
@ -1571,9 +1587,8 @@
|
||||||
to.className = 'msg-to'; to.textContent = ev.to;
|
to.className = 'msg-to'; to.textContent = ev.to;
|
||||||
const body = document.createElement('span');
|
const body = document.createElement('span');
|
||||||
body.className = 'msg-body';
|
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);
|
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
|
||||||
for (const d of previews) row.appendChild(d);
|
|
||||||
}
|
}
|
||||||
HiveTerminal.create({
|
HiveTerminal.create({
|
||||||
logEl: flow,
|
logEl: flow,
|
||||||
|
|
|
||||||
|
|
@ -129,24 +129,9 @@ a:hover {
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
.container-row.tombstone .name { color: var(--muted); }
|
.container-row.tombstone .name { color: var(--muted); }
|
||||||
/* Per-container journald viewer: collapsed by default, fetches
|
/* Per-container journald viewer + applied-config viewer. Both open
|
||||||
lazily on expand. The output is in monospace inside a bordered
|
in the side panel and lazy-fetch on open; output is monospace
|
||||||
<pre>; controls (unit select + refresh) sit above. */
|
inside a bordered <pre>, controls (unit select + refresh) 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 {
|
.journal-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
|
|
@ -168,8 +153,7 @@ a:hover {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
border: 1px solid var(--purple-dim);
|
border: 1px solid var(--purple-dim);
|
||||||
padding: 0.5em 0.7em;
|
padding: 0.5em 0.7em;
|
||||||
max-height: 24em;
|
overflow-x: auto;
|
||||||
overflow: auto;
|
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
|
|
@ -495,34 +479,20 @@ summary:hover { color: var(--purple); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Path linkification — agents drop pointer strings into messages
|
/* Path linkification — agents drop pointer strings into messages
|
||||||
constantly; clicking the anchor expands a sibling <details> that
|
constantly; clicking the anchor opens the file in the side panel,
|
||||||
lazy-loads from /api/state-file. */
|
lazy-loaded from /api/state-file. */
|
||||||
.path-link {
|
.path-link {
|
||||||
color: var(--blue, #89b4fa);
|
color: var(--blue, #89b4fa);
|
||||||
text-decoration: underline dotted;
|
text-decoration: underline dotted;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.path-link:hover { color: var(--amber); }
|
.path-link:hover { color: var(--amber); }
|
||||||
.path-preview {
|
/* File-preview body — rendered inside the side panel. */
|
||||||
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: ''; }
|
|
||||||
.path-preview-body {
|
.path-preview-body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 0.5em 0.7em;
|
padding: 0.5em 0.7em;
|
||||||
margin: 0.3em 0 0;
|
margin: 0;
|
||||||
max-height: 30em;
|
|
||||||
overflow: auto;
|
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
font-size: 0.85em;
|
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 label { cursor: pointer; user-select: none; }
|
||||||
.qform .q-option input { margin-right: 0.4em; accent-color: var(--amber); }
|
.qform .q-option input { margin-right: 0.4em; accent-color: var(--amber); }
|
||||||
.qform .q-free { display: flex; }
|
.qform .q-free { display: flex; }
|
||||||
.qform .q-free input {
|
.qform .q-free textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
|
|
@ -607,9 +577,11 @@ summary:hover { color: var(--purple); }
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 0.4em 0.6em;
|
padding: 0.4em 0.6em;
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.qform .q-free input::placeholder { color: var(--muted); }
|
.qform .q-free textarea::placeholder { color: var(--muted); }
|
||||||
.qform .q-free input:focus { outline: 1px solid var(--amber); }
|
.qform .q-free textarea:focus { outline: 1px solid var(--amber); }
|
||||||
.qform button { align-self: flex-start; }
|
.qform button { align-self: flex-start; }
|
||||||
.qform-cancel { margin-top: 0.3em; }
|
.qform-cancel { margin-top: 0.3em; }
|
||||||
.q-history {
|
.q-history {
|
||||||
|
|
@ -655,13 +627,10 @@ summary:hover { color: var(--purple); }
|
||||||
dashboard-specific: each broker event becomes a grid of timestamp +
|
dashboard-specific: each broker event becomes a grid of timestamp +
|
||||||
arrow + from/sep/to + body inside the `.row` shell. */
|
arrow + from/sep/to + body inside the `.row` shell. */
|
||||||
/* Flex (not grid): the row carries the header chips (ts / arrow /
|
/* Flex (not grid): the row carries the header chips (ts / arrow /
|
||||||
from / → / to / body) inline, and may also carry one or more
|
from / → / to / body) inline. Flex collapses whitespace-only text
|
||||||
`<details>` path-preview siblings appended by appendLinkified.
|
nodes between items and gives `body` the remaining width via
|
||||||
Grid would treat each preview as an extra grid item in a fixed
|
`flex: 1`. Path references inside `body` are inline anchors that
|
||||||
column template, distorting the header column widths. Flex
|
open the side panel — no full-width sibling rows. */
|
||||||
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%`. */
|
|
||||||
.live .msgrow {
|
.live .msgrow {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -680,12 +649,6 @@ summary:hover { color: var(--purple); }
|
||||||
the whole flex line wider than the container. */
|
the whole flex line wider than the container. */
|
||||||
min-width: 0;
|
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.sent .msg-arrow { color: var(--cyan); }
|
||||||
.live .msgrow.delivered .msg-arrow { color: var(--green); }
|
.live .msgrow.delivered .msg-arrow { color: var(--green); }
|
||||||
.msg-ts { color: var(--muted); font-size: 0.85em; }
|
.msg-ts { color: var(--muted); font-size: 0.85em; }
|
||||||
|
|
@ -757,3 +720,88 @@ footer {
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
footer a { color: var(--purple); }
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,23 @@
|
||||||
<p>▲△▲ <a href="https://git.berlin.ccc.de/vinzenz/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
|
<p>▲△▲ <a href="https://git.berlin.ccc.de/vinzenz/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
|
||||||
</footer>
|
</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/hive-fr0nt.js" defer></script>
|
||||||
<script src="/static/app.js" defer></script>
|
<script src="/static/app.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue