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;
|
||||
};
|
||||
|
||||
// ─── 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;
|
||||
try {
|
||||
pre.textContent = await fetchStateFile(path);
|
||||
} catch (e) {
|
||||
pre.textContent = 'error: ' + (e.message || e);
|
||||
fetched = false; // allow retry on next open
|
||||
}
|
||||
Panel.open('↳ ' + path, pre);
|
||||
try {
|
||||
pre.textContent = await fetchStateFile(path);
|
||||
} catch (e) {
|
||||
pre.textContent = 'error: ' + (e.message || e);
|
||||
}
|
||||
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,112 +581,111 @@
|
|||
}
|
||||
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);
|
||||
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;
|
||||
// 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' });
|
||||
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 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;
|
||||
} finally {
|
||||
fetching = false;
|
||||
}
|
||||
} 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;
|
||||
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); });
|
||||
unitSelect.addEventListener('change', fetchLogs);
|
||||
controls.append(unitSelect, refresh);
|
||||
body.append(controls, pre);
|
||||
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);
|
||||
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' },
|
||||
'↻ refresh');
|
||||
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
|
||||
let fetching = false;
|
||||
async function fetchConfig() {
|
||||
if (fetching) return;
|
||||
fetching = true;
|
||||
pre.textContent = 'fetching…';
|
||||
try {
|
||||
const resp = await fetch('/api/agent-config/' + agentName);
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) {
|
||||
pre.textContent = 'error: ' + resp.status + '\n' + text;
|
||||
} else {
|
||||
pre.textContent = text || '(empty)';
|
||||
pre.scrollTop = 0;
|
||||
// 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' },
|
||||
'↻ refresh');
|
||||
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
|
||||
let fetching = false;
|
||||
async function fetchConfig() {
|
||||
if (fetching) return;
|
||||
fetching = true;
|
||||
pre.textContent = 'fetching…';
|
||||
try {
|
||||
const resp = await fetch('/api/agent-config/' + agentName);
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) {
|
||||
pre.textContent = 'error: ' + resp.status + '\n' + text;
|
||||
} else {
|
||||
pre.textContent = text || '(empty)';
|
||||
}
|
||||
} catch (err) {
|
||||
pre.textContent = 'fetch failed: ' + err;
|
||||
} finally {
|
||||
fetching = false;
|
||||
}
|
||||
} catch (err) {
|
||||
pre.textContent = 'fetch failed: ' + err;
|
||||
} finally {
|
||||
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;
|
||||
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchConfig(); });
|
||||
controls.append(refresh);
|
||||
body.append(controls, pre);
|
||||
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,30 +1189,30 @@
|
|||
);
|
||||
li.append(row);
|
||||
if (a.diff) {
|
||||
const details = el('details', {
|
||||
'data-restore-key': 'approval-diff:' + a.id,
|
||||
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';
|
||||
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'));
|
||||
// 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);
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue