dashboard frontend: consume rfc3339 timestamps from the api

This commit is contained in:
damocles 2026-07-02 21:35:03 +02:00 committed by mara
commit cafef519a9
5 changed files with 46 additions and 33 deletions

View file

@ -16,7 +16,7 @@
import { $, el, form, Panel, appendLinkified } from './common.js';
import { themedToast } from './modal.js';
import { fmtAgo, fmtDuration } from './util.js';
import { epochSec, fmtAgo, fmtDuration } from './util.js';
import { questionsState, QUESTION_HISTORY_LIMIT } from './state.js';
// Registered by the dashboard entry at boot; defaults to a no-op so the
@ -70,7 +70,7 @@ function renderOperatorInbox() {
`✓ mark all read (${operatorInbox.length})`);
mark.addEventListener('click', markOperatorInboxRead);
root.append(el('div', { class: 'inbox-toolbar' }, mark));
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
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' });
@ -333,11 +333,12 @@ export function renderApprovals() {
// 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 ageSec = Math.max(0, Math.floor(Date.now() / 1000 - a.requested_at));
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 * 1000).toLocaleString(),
'data-requested-at': String(a.requested_at),
title: 'requested ' + new Date(a.requested_at).toLocaleString(),
'data-requested-at': String(requestedSec),
}, 'requested ' + fmtAgo(a.requested_at)));
}
li.append(head);
@ -556,7 +557,7 @@ function questionRowFingerprint(q) {
// Event listeners attached here (keydown on textarea, submit on form) are
// preserved in the reused node — no re-attachment needed.
function buildQuestionLi(q) {
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
const fmt = (ts) => new Date(ts).toISOString().replace('T', ' ').slice(0, 19);
const targetLabel = q.target || 'operator';
const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') });
const head = el('div', { class: 'q-head' },
@ -570,10 +571,10 @@ function buildQuestionLi(q) {
// Tag the chip with its deadline so the global 1s ticker
// can refresh the text without re-rendering the questions section.
const ttlEl = el('span', {
class: 'q-ttl', 'data-deadline': String(q.deadline_at),
class: 'q-ttl', 'data-deadline': String(epochSec(q.deadline_at)),
});
ttlEl.textContent = formatTtl(
q.deadline_at - Math.floor(Date.now() / 1000),
epochSec(q.deadline_at) - Math.floor(Date.now() / 1000),
);
head.append(' ', ttlEl);
}
@ -694,7 +695,7 @@ export function renderQuestions() {
// <li> nodes (preserving textarea/checkbox state) and only rebuilds
// cache-miss rows, so we no longer wipe the DOM at the start.
const openDetails = snapshotOpenDetails(root);
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
const fmt = (ts) => new Date(ts).toISOString().replace('T', ' ').slice(0, 19);
const allPending = questionsState.pending;
// Filter chips. Always include `all` / `operator` / `peer`; add

View file

@ -18,6 +18,7 @@ import {
appendLinkified,
openStream, initServerWarnings,
} from './common.js';
import { epochSec } from './util.js';
(() => {
NOTIF.bind();
@ -92,7 +93,7 @@ import {
const flow = $('msgflow');
if (!flow) return;
flow.replaceChildren();
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
const tsFmt = (ts) => new Date(ts).toISOString().slice(11, 19);
// Pulse the page banner whenever a broker event lands. The
// `.banner` element lives in the dashboard's <footer> rather than
// in the flow chrome — `pulseBanner` no-ops on /flow.html since
@ -179,7 +180,7 @@ import {
const recentSent = new Map(); // broker row id → { row, at }
function rememberSent(ev, row) {
if (ev.id == null || ev.id <= 0) return;
recentSent.set(ev.id, { row, at: ev.at });
recentSent.set(ev.id, { row, at: epochSec(ev.at) });
// Bound the map — drop the oldest entries once it grows past a
// page of un-collapsed sends (insertion order = oldest first).
while (recentSent.size > 256) {
@ -190,7 +191,7 @@ import {
function collapseDelivered(ev) {
if (ev.id == null || ev.id <= 0) return false;
const s = recentSent.get(ev.id);
if (!s || ev.at - s.at > COLLAPSE_SECS) return false;
if (!s || epochSec(ev.at) - s.at > COLLAPSE_SECS) return false;
const arrow = s.row.querySelector('.msg-arrow');
if (arrow) arrow.textContent = '✓';
// Re-style the row as delivered (green ✓) — the collapsed line now

View file

@ -15,6 +15,7 @@
import {
$, el, fmtAgeSecs, openStream, initServerWarnings,
} from './common.js';
import { epochSec } from './util.js';
import { createTabStrip } from '@hive/shared/tabs.js';
(() => {
@ -170,10 +171,11 @@ import { createTabStrip } from '@hive/shared/tabs.js';
let auditTotal = 0;
let auditFetching = false;
// ts_unix is unix seconds — fmtAgeSecs wants an age in seconds.
function auditFmtWhen(tsUnix) {
if (!tsUnix) return '';
const age = Math.floor(Date.now() / 1000) - tsUnix;
// ts_unix arrives as an RFC 3339 string — fmtAgeSecs wants an age
// in seconds, so normalize via epochSec first.
function auditFmtWhen(ts) {
if (!ts) return '';
const age = Math.floor(Date.now() / 1000) - epochSec(ts);
return fmtAgeSecs(Math.max(0, age)) + ' ago';
}
@ -234,7 +236,7 @@ import { createTabStrip } from '@hive/shared/tabs.js';
tbody.append(el('tr', {},
el('td', {
class: 'audit-when meta',
title: e.ts_unix ? new Date(e.ts_unix * 1000).toISOString() : '',
title: e.ts_unix ? new Date(e.ts_unix).toISOString() : '',
}, auditFmtWhen(e.ts_unix)),
el('td', { class: 'audit-agent' }, e.agent || ''),
el('td', { class: 'audit-action' }, e.action || ''),

View file

@ -13,7 +13,7 @@
import { $, el, appendLinkified } from './common.js';
import { themedConfirm, themedToast } from './modal.js';
import { paintAtomic, fmtAgo, fmtDuration } from './util.js';
import { paintAtomic, epochSec, fmtAgo, fmtDuration } from './util.js';
import { containersState } from './state.js';
export async function refreshReminders() {
@ -47,7 +47,7 @@ function renderReminders(rows) {
for (const r of rows) {
const failed = (r.attempt_count || 0) > 0;
const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') });
const dueIn = r.due_at - Math.floor(Date.now() / 1000);
const dueIn = epochSec(r.due_at) - Math.floor(Date.now() / 1000);
const dueLabel = dueIn <= 0
? `overdue ${fmtAgo(r.due_at)}`
: `in ${fmtDuration(dueIn)}`;
@ -55,8 +55,8 @@ function renderReminders(rows) {
el('span', { class: 'agent' }, r.agent), ' ',
el('span', {
class: 'meta reminder-due',
title: new Date(r.due_at * 1000).toISOString(),
'data-due-at': String(r.due_at),
title: new Date(r.due_at).toISOString(),
'data-due-at': String(epochSec(r.due_at)),
}, dueLabel),
' ',
el('span', { class: 'meta' }, `· id ${r.id}`),
@ -394,7 +394,7 @@ function renderSchedulesList() {
const aOrd = a.cancelled_at_unix ? 2 : a.paused_at_unix ? 1 : 0;
const bOrd = b.cancelled_at_unix ? 2 : b.paused_at_unix ? 1 : 0;
if (aOrd !== bOrd) return aOrd - bOrd;
return a.next_fire_at_unix - b.next_fire_at_unix;
return epochSec(a.next_fire_at_unix) - epochSec(b.next_fire_at_unix);
});
for (const s of sorted) {
tbody.append(renderScheduleRow(s, agents));
@ -657,24 +657,24 @@ function renderScheduleRow(s, agents) {
// absolute ISO in the title.
const nextCell = el('td', { class: 'meta schedules-table-next-col' });
if (cancelled) {
nextCell.title = 'cancelled ' + new Date(s.cancelled_at_unix * 1000).toISOString();
nextCell.title = 'cancelled ' + new Date(s.cancelled_at_unix).toISOString();
nextCell.textContent = 'cancelled';
} else if (paused) {
nextCell.title = 'paused since '
+ new Date(s.paused_at_unix * 1000).toISOString()
+ new Date(s.paused_at_unix).toISOString()
+ '\nwould fire at '
+ new Date(s.next_fire_at_unix * 1000).toISOString();
+ new Date(s.next_fire_at_unix).toISOString();
nextCell.append(
el('span', { class: 'sched-paused-label' }, '⏸ paused'),
);
} else {
const dueIn = s.next_fire_at_unix - Math.floor(Date.now() / 1000);
nextCell.title = new Date(s.next_fire_at_unix * 1000).toISOString();
const dueIn = epochSec(s.next_fire_at_unix) - Math.floor(Date.now() / 1000);
nextCell.title = new Date(s.next_fire_at_unix).toISOString();
nextCell.textContent = dueIn <= 0
? 'overdue ' + fmtAgo(s.next_fire_at_unix)
: fmtDuration(dueIn);
nextCell.classList.add('sched-due');
nextCell.dataset.dueAt = String(s.next_fire_at_unix);
nextCell.dataset.dueAt = String(epochSec(s.next_fire_at_unix));
}
tr.append(nextCell);
@ -841,7 +841,7 @@ function renderScheduleEditForm(s) {
});
firstFireInput.value = carry.next_fire !== undefined
? carry.next_fire
: isoForDatetimeLocal(new Date(s.next_fire_at_unix * 1000));
: isoForDatetimeLocal(new Date(s.next_fire_at_unix));
form_.append(scheduleField('next fire', firstFireInput));
const intervalCx = buildIntervalComposer({
@ -968,7 +968,7 @@ async function submitEditSchedule(originalSchedule, form_) {
if (newDescription !== (s.description || '')) {
patch.description = newDescription || null;
}
if (newNextFireUnix !== s.next_fire_at_unix) {
if (newNextFireUnix !== epochSec(s.next_fire_at_unix)) {
patch.next_fire_at_unix = newNextFireUnix;
}
if (newIntervalSeconds !== (s.interval_seconds || null)) {

View file

@ -19,9 +19,18 @@ export function paintAtomic(liveRoot, build) {
liveRoot.replaceChildren(buf);
}
// Relative age of a unix timestamp, coarsened to one unit ("5m ago").
export function fmtAgo(unixSecs) {
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSecs));
// Epoch seconds from an API timestamp. The hive-c0re dashboard API
// ships timestamps as RFC 3339 strings; a few feeds (rebuild queue,
// meta inputs, turn stats) still carry unix-second numbers, so
// numbers pass through unchanged.
export function epochSec(ts) {
return typeof ts === 'number' ? ts : Math.floor(Date.parse(ts) / 1000);
}
// Relative age of a timestamp (RFC 3339 string or unix seconds),
// coarsened to one unit ("5m ago").
export function fmtAgo(ts) {
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - epochSec(ts)));
if (ageSec < 60) return ageSec + 's ago';
if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';