treefmt: apply prettier
Pure `nix fmt` output from the commit before this one — no hand edits. 203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs. Reproduce with `nix develop -c nix fmt` on the parent commit; the result should be byte-identical to this tree. None of the 13 `.prettierignore` entries appears here — verified by intersecting the changed-file list against the ignore file, with a control proving the intersection finds a match when one exists.
This commit is contained in:
parent
5d24bedd60
commit
39b95c2ede
203 changed files with 10090 additions and 6085 deletions
|
|
@ -10,16 +10,17 @@
|
|||
// live-mutation paths call an injected `onCountsChanged` callback the entry
|
||||
// registers once via `initCall`.
|
||||
|
||||
import { $, form, appendLinkified } from './common.js';
|
||||
import { el } from '@hive/shared/dom.js';
|
||||
import { epochSec, fmtAgo, fmtDuration } from './util.js';
|
||||
import { $, form, appendLinkified } from "./common.js";
|
||||
import { el } from "@hive/shared/dom.js";
|
||||
import { epochSec, fmtAgo, fmtDuration } from "./util.js";
|
||||
|
||||
// Registered by the dashboard entry at boot; defaults to a no-op so the
|
||||
// module is safe to call before wiring.
|
||||
let onCountsChanged = () => {};
|
||||
|
||||
export function initCall(opts = {}) {
|
||||
if (typeof opts.onCountsChanged === 'function') onCountsChanged = opts.onCountsChanged;
|
||||
if (typeof opts.onCountsChanged === "function")
|
||||
onCountsChanged = opts.onCountsChanged;
|
||||
}
|
||||
|
||||
// ─── operator inbox — unread agent→operator messages ────────────
|
||||
|
|
@ -31,50 +32,66 @@ export function initCall(opts = {}) {
|
|||
// Count folds into the Y3R C4LL pill + browser-title prefix.
|
||||
let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first
|
||||
|
||||
export function operatorInboxCount() { return operatorInbox.length; }
|
||||
export function operatorInboxCount() {
|
||||
return operatorInbox.length;
|
||||
}
|
||||
|
||||
export async function refreshOperatorInbox() {
|
||||
try {
|
||||
const r = await fetch('/api/operator-inbox');
|
||||
const r = await fetch("/api/operator-inbox");
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
operatorInbox = Array.isArray(data.messages) ? data.messages : [];
|
||||
}
|
||||
} catch { /* keep prior list on transient failure */ }
|
||||
} catch {
|
||||
/* keep prior list on transient failure */
|
||||
}
|
||||
renderOperatorInbox();
|
||||
onCountsChanged();
|
||||
}
|
||||
|
||||
function renderOperatorInbox() {
|
||||
const root = $('operator-inbox-section');
|
||||
const root = $("operator-inbox-section");
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
if (!operatorInbox.length) {
|
||||
root.append(el('p', { class: 'meta' }, 'no unread messages'));
|
||||
root.append(el("p", { class: "meta" }, "no unread messages"));
|
||||
return;
|
||||
}
|
||||
const mark = el('button', { type: 'button', class: 'btn', id: 'op-inbox-mark-read' },
|
||||
`✓ mark all read (${operatorInbox.length})`);
|
||||
mark.addEventListener('click', markOperatorInboxRead);
|
||||
root.append(el('div', { class: 'inbox-toolbar' }, mark));
|
||||
const fmt = (ts) => new Date(ts).toISOString().replace('T', ' ').slice(0, 19);
|
||||
const ul = el('ul', { class: 'inbox' });
|
||||
const mark = el(
|
||||
"button",
|
||||
{ type: "button", class: "btn", id: "op-inbox-mark-read" },
|
||||
`✓ mark all read (${operatorInbox.length})`,
|
||||
);
|
||||
mark.addEventListener("click", markOperatorInboxRead);
|
||||
root.append(el("div", { class: "inbox-toolbar" }, mark));
|
||||
const fmt = (ts) => new Date(ts).toISOString().replace("T", " ").slice(0, 19);
|
||||
const ul = el("ul", { class: "inbox" });
|
||||
for (const m of operatorInbox) {
|
||||
const body = el('span', { class: 'msg-body' });
|
||||
const body = el("span", { class: "msg-body" });
|
||||
appendLinkified(body, m.body, m.file_refs);
|
||||
ul.append(el('li', {},
|
||||
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
|
||||
el('span', { class: 'msg-from' }, m.from), ' ',
|
||||
el('span', { class: 'msg-sep' }, '→ '),
|
||||
body,
|
||||
));
|
||||
ul.append(
|
||||
el(
|
||||
"li",
|
||||
{},
|
||||
el("span", { class: "msg-ts" }, fmt(m.at)),
|
||||
" ",
|
||||
el("span", { class: "msg-from" }, m.from),
|
||||
" ",
|
||||
el("span", { class: "msg-sep" }, "→ "),
|
||||
body,
|
||||
),
|
||||
);
|
||||
}
|
||||
root.append(ul);
|
||||
}
|
||||
|
||||
async function markOperatorInboxRead() {
|
||||
try { await fetch('/api/agent/operator/mark-all-read', { method: 'POST' }); }
|
||||
catch { /* best-effort; the next refresh reconciles */ }
|
||||
try {
|
||||
await fetch("/api/agent/operator/mark-all-read", { method: "POST" });
|
||||
} catch {
|
||||
/* best-effort; the next refresh reconciles */
|
||||
}
|
||||
operatorInbox = [];
|
||||
renderOperatorInbox();
|
||||
onCountsChanged();
|
||||
|
|
@ -86,7 +103,10 @@ async function markOperatorInboxRead() {
|
|||
export function operatorInboxAppendFromEvent(ev) {
|
||||
if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return;
|
||||
operatorInbox.unshift({
|
||||
id: ev.id, from: ev.from, body: ev.body, at: ev.at,
|
||||
id: ev.id,
|
||||
from: ev.from,
|
||||
body: ev.body,
|
||||
at: ev.at,
|
||||
file_refs: ev.file_refs || [],
|
||||
});
|
||||
if (operatorInbox.length > 100) operatorInbox.length = 100;
|
||||
|
|
@ -95,14 +115,16 @@ export function operatorInboxAppendFromEvent(ev) {
|
|||
}
|
||||
|
||||
// ─── approvals — the operator config-change / spawn approval queue ────────
|
||||
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
|
||||
const APPROVAL_TAB_KEY = "hyperhive:approvals:tab";
|
||||
// Derived approval state — cold-loaded from /api/state, then mutated
|
||||
// live by `approval_added` / `approval_resolved` dashboard events.
|
||||
// `pending` is the open queue (newest-first); `history` is the last
|
||||
// 30 resolved rows.
|
||||
const APPROVAL_HISTORY_LIMIT = 30;
|
||||
const approvalsState = { pending: [], history: [] };
|
||||
export function activeApprovalCount() { return approvalsState.pending.length; }
|
||||
export function activeApprovalCount() {
|
||||
return approvalsState.pending.length;
|
||||
}
|
||||
export function syncApprovalsFromSnapshot(s) {
|
||||
approvalsState.pending = (s.approvals || []).slice();
|
||||
approvalsState.history = (s.approval_history || []).slice();
|
||||
|
|
@ -122,8 +144,8 @@ export function applyApprovalAdded(ev) {
|
|||
// approval was queued just now, so client-now is accurate — and
|
||||
// consistent with how fmtAgo compares everything to client-now.
|
||||
// A later /api/state cold-load swaps in the server value.
|
||||
requested_at: ev.requested_at != null
|
||||
? ev.requested_at : Math.floor(Date.now() / 1000),
|
||||
requested_at:
|
||||
ev.requested_at != null ? ev.requested_at : Math.floor(Date.now() / 1000),
|
||||
};
|
||||
if (existing >= 0) approvalsState.pending[existing] = row;
|
||||
else approvalsState.pending.push(row);
|
||||
|
|
@ -154,7 +176,7 @@ export function applyApprovalResolved(ev) {
|
|||
renderApprovals();
|
||||
}
|
||||
export function renderApprovals() {
|
||||
const root = $('approvals-section');
|
||||
const root = $("approvals-section");
|
||||
// #approvals-section only lives on /dashboard.html (Y3R C4LL tab);
|
||||
// no-op elsewhere — `approval_added` / `approval_resolved` SSE
|
||||
// events route through here on every page that loads the bundle.
|
||||
|
|
@ -163,42 +185,42 @@ export function renderApprovals() {
|
|||
|
||||
const pending = approvalsState.pending;
|
||||
const history = approvalsState.history;
|
||||
const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
|
||||
const tabs = el('div', { class: 'approval-tabs' });
|
||||
const active = localStorage.getItem(APPROVAL_TAB_KEY) || "pending";
|
||||
const tabs = el("div", { class: "approval-tabs" });
|
||||
const pendingTab = el(
|
||||
'button',
|
||||
"button",
|
||||
{
|
||||
type: 'button',
|
||||
class: 'approval-tab' + (active === 'pending' ? ' active' : ''),
|
||||
type: "button",
|
||||
class: "approval-tab" + (active === "pending" ? " active" : ""),
|
||||
},
|
||||
`pending · ${pending.length}`,
|
||||
);
|
||||
const historyTab = el(
|
||||
'button',
|
||||
"button",
|
||||
{
|
||||
type: 'button',
|
||||
class: 'approval-tab' + (active === 'history' ? ' active' : ''),
|
||||
type: "button",
|
||||
class: "approval-tab" + (active === "history" ? " active" : ""),
|
||||
},
|
||||
`history · ${history.length}`,
|
||||
);
|
||||
pendingTab.addEventListener('click', () => {
|
||||
localStorage.setItem(APPROVAL_TAB_KEY, 'pending');
|
||||
pendingTab.addEventListener("click", () => {
|
||||
localStorage.setItem(APPROVAL_TAB_KEY, "pending");
|
||||
renderApprovals();
|
||||
});
|
||||
historyTab.addEventListener('click', () => {
|
||||
localStorage.setItem(APPROVAL_TAB_KEY, 'history');
|
||||
historyTab.addEventListener("click", () => {
|
||||
localStorage.setItem(APPROVAL_TAB_KEY, "history");
|
||||
renderApprovals();
|
||||
});
|
||||
tabs.append(pendingTab, historyTab);
|
||||
root.append(tabs);
|
||||
|
||||
if (active === 'history') {
|
||||
if (active === "history") {
|
||||
renderApprovalHistory(root, history);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pending.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'queue empty'));
|
||||
root.append(el("p", { class: "empty" }, "queue empty"));
|
||||
return;
|
||||
}
|
||||
// forge link base — only when the hive-forge container is up.
|
||||
|
|
@ -208,81 +230,143 @@ export function renderApprovals() {
|
|||
// below already gates on forgeBase being truthy.
|
||||
const forgeBase = (fs && fs.forge_present && fs.forge_public_url) || null;
|
||||
|
||||
const ul = el('ul', { class: 'approvals' });
|
||||
const ul = el("ul", { class: "approvals" });
|
||||
for (const a of pending) {
|
||||
const isInit = a.kind === 'init_config';
|
||||
const isMergePr = a.kind === 'merge_config_pr';
|
||||
const isUpdateMeta = a.kind === 'update_meta_inputs';
|
||||
const isSchedule = a.kind === 'schedule_prompt';
|
||||
const li = el('li', { class: 'approval-card' });
|
||||
const isInit = a.kind === "init_config";
|
||||
const isMergePr = a.kind === "merge_config_pr";
|
||||
const isUpdateMeta = a.kind === "update_meta_inputs";
|
||||
const isSchedule = a.kind === "schedule_prompt";
|
||||
const li = el("li", { class: "approval-card" });
|
||||
|
||||
// ── identity header ──────────────────────────────────────────
|
||||
const head = el('div', { class: 'approval-head' },
|
||||
el('span', { class: 'glyph' }, isMergePr ? '⇒' : isUpdateMeta ? '↻' : isSchedule ? '⏱' : '⊕'),
|
||||
el('span', { class: 'id' }, '#' + a.id),
|
||||
el('span', { class: 'agent' }, a.agent),
|
||||
el('span', { class: 'kind' + ((isMergePr || isUpdateMeta || isSchedule) ? '' : ' kind-spawn') },
|
||||
isMergePr ? 'merge-pr' : isUpdateMeta ? 'meta-update' : isSchedule ? 'schedule' : isInit ? 'init' : 'spawn'),
|
||||
const head = el(
|
||||
"div",
|
||||
{ class: "approval-head" },
|
||||
el(
|
||||
"span",
|
||||
{ class: "glyph" },
|
||||
isMergePr ? "⇒" : isUpdateMeta ? "↻" : isSchedule ? "⏱" : "⊕",
|
||||
),
|
||||
el("span", { class: "id" }, "#" + a.id),
|
||||
el("span", { class: "agent" }, a.agent),
|
||||
el(
|
||||
"span",
|
||||
{
|
||||
class:
|
||||
"kind" +
|
||||
(isMergePr || isUpdateMeta || isSchedule ? "" : " kind-spawn"),
|
||||
},
|
||||
isMergePr
|
||||
? "merge-pr"
|
||||
: isUpdateMeta
|
||||
? "meta-update"
|
||||
: isSchedule
|
||||
? "schedule"
|
||||
: isInit
|
||||
? "init"
|
||||
: "spawn",
|
||||
),
|
||||
);
|
||||
if (isMergePr && a.sha_short) head.append(el('code', {}, a.sha_short));
|
||||
if (isMergePr && a.sha_short) head.append(el("code", {}, a.sha_short));
|
||||
// When the approval was requested — relative time, right-aligned.
|
||||
// Goes amber once it's been pending an hour so a stale request is
|
||||
// obvious at a glance (see docs/web-ui.md::Approval card).
|
||||
if (a.requested_at != null) {
|
||||
const requestedSec = epochSec(a.requested_at);
|
||||
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - requestedSec));
|
||||
head.append(el('span', {
|
||||
class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''),
|
||||
title: 'requested ' + new Date(a.requested_at).toLocaleString(),
|
||||
'data-requested-at': String(requestedSec),
|
||||
}, 'requested ' + fmtAgo(a.requested_at)));
|
||||
head.append(
|
||||
el(
|
||||
"span",
|
||||
{
|
||||
class: "approval-ts" + (ageSec >= 3600 ? " stale" : ""),
|
||||
title: "requested " + new Date(a.requested_at).toLocaleString(),
|
||||
"data-requested-at": String(requestedSec),
|
||||
},
|
||||
"requested " + fmtAgo(a.requested_at),
|
||||
),
|
||||
);
|
||||
}
|
||||
li.append(head);
|
||||
|
||||
// ── what-changed body ────────────────────────────────────────
|
||||
const body = el('div', { class: 'approval-body' });
|
||||
const body = el("div", { class: "approval-body" });
|
||||
if (a.description) {
|
||||
body.append(el('div', { class: 'approval-description' }, a.description));
|
||||
body.append(el("div", { class: "approval-description" }, a.description));
|
||||
}
|
||||
if (isMergePr) {
|
||||
// PR-based config deploy: link to the reviewed PR on the forge.
|
||||
// The config diff lives on the forge PR itself.
|
||||
const drill = el('div', { class: 'drill-ins' });
|
||||
const drill = el("div", { class: "drill-ins" });
|
||||
if (forgeBase && a.pr_number != null) {
|
||||
drill.append(el('a', {
|
||||
class: 'panel-trigger', target: '_blank', rel: 'noopener',
|
||||
href: `${forgeBase}/agent-configs/${a.agent}/pulls/${a.pr_number}`,
|
||||
title: 'review this config PR on the hive forge',
|
||||
}, '↳ review PR on forge ↗'));
|
||||
drill.append(
|
||||
el(
|
||||
"a",
|
||||
{
|
||||
class: "panel-trigger",
|
||||
target: "_blank",
|
||||
rel: "noopener",
|
||||
href: `${forgeBase}/agent-configs/${a.agent}/pulls/${a.pr_number}`,
|
||||
title: "review this config PR on the hive forge",
|
||||
},
|
||||
"↳ review PR on forge ↗",
|
||||
),
|
||||
);
|
||||
}
|
||||
body.append(drill);
|
||||
} else if (isUpdateMeta) {
|
||||
let inputs;
|
||||
try { inputs = JSON.parse(a.commit_ref || '[]'); } catch (_) { inputs = []; }
|
||||
body.append(el('span', { class: 'meta' },
|
||||
inputs.length
|
||||
? 'bump flake inputs: ' + inputs.join(', ')
|
||||
: 'bump all flake inputs'));
|
||||
try {
|
||||
inputs = JSON.parse(a.commit_ref || "[]");
|
||||
} catch (_) {
|
||||
inputs = [];
|
||||
}
|
||||
body.append(
|
||||
el(
|
||||
"span",
|
||||
{ class: "meta" },
|
||||
inputs.length
|
||||
? "bump flake inputs: " + inputs.join(", ")
|
||||
: "bump all flake inputs",
|
||||
),
|
||||
);
|
||||
} else if (isSchedule) {
|
||||
let payload;
|
||||
try { payload = JSON.parse(a.commit_ref || '{}'); } catch (_) { payload = {}; }
|
||||
const targets = (payload.targets || []).join(', ');
|
||||
try {
|
||||
payload = JSON.parse(a.commit_ref || "{}");
|
||||
} catch (_) {
|
||||
payload = {};
|
||||
}
|
||||
const targets = (payload.targets || []).join(", ");
|
||||
const firstFire = payload.first_fire_at_unix
|
||||
? new Date(payload.first_fire_at_unix * 1000).toLocaleString()
|
||||
: '?';
|
||||
: "?";
|
||||
const cadence = payload.interval_seconds
|
||||
? ' · ↻ every ' + fmtDuration(payload.interval_seconds)
|
||||
: ' · one-shot';
|
||||
body.append(el('div', { class: 'meta' }, '→ ' + targets + ' · first: ' + firstFire + cadence));
|
||||
? " · ↻ every " + fmtDuration(payload.interval_seconds)
|
||||
: " · one-shot";
|
||||
body.append(
|
||||
el(
|
||||
"div",
|
||||
{ class: "meta" },
|
||||
"→ " + targets + " · first: " + firstFire + cadence,
|
||||
),
|
||||
);
|
||||
if (payload.body) {
|
||||
const excerpt = payload.body.length > 80 ? payload.body.slice(0, 80) + '…' : payload.body;
|
||||
body.append(el('div', { class: 'approval-description' }, excerpt));
|
||||
const excerpt =
|
||||
payload.body.length > 80
|
||||
? payload.body.slice(0, 80) + "…"
|
||||
: payload.body;
|
||||
body.append(el("div", { class: "approval-description" }, excerpt));
|
||||
}
|
||||
} else {
|
||||
body.append(el('span', { class: 'meta' },
|
||||
isInit
|
||||
? 'scaffold proposed config repo — submitting agent customises agent.nix before spawn'
|
||||
: 'new sub-agent — container will be created on approve'));
|
||||
body.append(
|
||||
el(
|
||||
"span",
|
||||
{ class: "meta" },
|
||||
isInit
|
||||
? "scaffold proposed config repo — submitting agent customises agent.nix before spawn"
|
||||
: "new sub-agent — container will be created on approve",
|
||||
),
|
||||
);
|
||||
}
|
||||
li.append(body);
|
||||
|
||||
|
|
@ -291,16 +375,32 @@ export function renderApprovals() {
|
|||
// handler stashes it into a hidden `note` input that rides along
|
||||
// on the POST and is surfaced to the submitting agent via
|
||||
// HelperEvent::ApprovalResolved { note }.
|
||||
const denyForm = el('form', {
|
||||
method: 'POST', action: '/api/deny/' + a.id,
|
||||
class: 'inline', 'data-async': '', 'data-no-refresh': '',
|
||||
'data-prompt': 'reason for denying (optional, sent to submitter):',
|
||||
const denyForm = el("form", {
|
||||
method: "POST",
|
||||
action: "/api/deny/" + a.id,
|
||||
class: "inline",
|
||||
"data-async": "",
|
||||
"data-no-refresh": "",
|
||||
"data-prompt": "reason for denying (optional, sent to submitter):",
|
||||
});
|
||||
denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
|
||||
li.append(el('div', { class: 'approval-actions' },
|
||||
form('/api/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }),
|
||||
denyForm,
|
||||
));
|
||||
denyForm.append(
|
||||
el("button", { type: "submit", class: "btn btn-deny" }, "DENY"),
|
||||
);
|
||||
li.append(
|
||||
el(
|
||||
"div",
|
||||
{ class: "approval-actions" },
|
||||
form(
|
||||
"/api/approve/" + a.id,
|
||||
"btn-approve",
|
||||
"◆ APPR0VE",
|
||||
null,
|
||||
{},
|
||||
{ noRefresh: true },
|
||||
),
|
||||
denyForm,
|
||||
),
|
||||
);
|
||||
|
||||
ul.append(li);
|
||||
}
|
||||
|
|
@ -309,31 +409,52 @@ export function renderApprovals() {
|
|||
|
||||
function renderApprovalHistory(root, history) {
|
||||
if (!history.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'no resolved approvals yet'));
|
||||
root.append(el("p", { class: "empty" }, "no resolved approvals yet"));
|
||||
return;
|
||||
}
|
||||
const ul = el('ul', { class: 'approvals approvals-history' });
|
||||
const ul = el("ul", { class: "approvals approvals-history" });
|
||||
for (const a of history) {
|
||||
const li = el('li');
|
||||
const row = el('div', { class: 'row' });
|
||||
const glyph = a.status === 'approved' ? '✓'
|
||||
: a.status === 'denied' ? '✗'
|
||||
: a.status === 'cancelled' ? '⊘'
|
||||
: '⚠';
|
||||
const li = el("li");
|
||||
const row = el("div", { class: "row" });
|
||||
const glyph =
|
||||
a.status === "approved"
|
||||
? "✓"
|
||||
: a.status === "denied"
|
||||
? "✗"
|
||||
: a.status === "cancelled"
|
||||
? "⊘"
|
||||
: "⚠";
|
||||
row.append(
|
||||
el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',
|
||||
el('span', { class: 'id' }, '#' + a.id), ' ',
|
||||
el('span', { class: 'agent' }, a.agent), ' ',
|
||||
el('span', { class: 'kind' }, a.kind === 'merge_config_pr' ? 'merge-pr' : a.kind === 'update_meta_inputs' ? 'meta-update' : a.kind === 'schedule_prompt' ? 'schedule' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ',
|
||||
el("span", { class: "glyph glyph-" + a.status }, glyph),
|
||||
" ",
|
||||
el("span", { class: "id" }, "#" + a.id),
|
||||
" ",
|
||||
el("span", { class: "agent" }, a.agent),
|
||||
" ",
|
||||
el(
|
||||
"span",
|
||||
{ class: "kind" },
|
||||
a.kind === "merge_config_pr"
|
||||
? "merge-pr"
|
||||
: a.kind === "update_meta_inputs"
|
||||
? "meta-update"
|
||||
: a.kind === "schedule_prompt"
|
||||
? "schedule"
|
||||
: a.kind === "init_config"
|
||||
? "init"
|
||||
: "spawn",
|
||||
),
|
||||
" ",
|
||||
);
|
||||
if (a.sha_short) row.append(el('code', {}, a.sha_short), ' ');
|
||||
if (a.sha_short) row.append(el("code", {}, a.sha_short), " ");
|
||||
row.append(
|
||||
el('span', { class: 'status status-' + a.status }, a.status), ' ',
|
||||
el('span', { class: 'msg-ts' }, fmtAgo(a.resolved_at)),
|
||||
el("span", { class: "status status-" + a.status }, a.status),
|
||||
" ",
|
||||
el("span", { class: "msg-ts" }, fmtAgo(a.resolved_at)),
|
||||
);
|
||||
li.append(row);
|
||||
if (a.note) {
|
||||
li.append(el('div', { class: 'history-note' }, a.note));
|
||||
li.append(el("div", { class: "history-note" }, a.note));
|
||||
}
|
||||
ul.append(li);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue