dashboard frontend: consume rfc3339 timestamps from the api
This commit is contained in:
parent
bac2c0a65e
commit
cafef519a9
5 changed files with 46 additions and 33 deletions
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
import { $, el, form, Panel, appendLinkified } from './common.js';
|
import { $, el, form, Panel, appendLinkified } from './common.js';
|
||||||
import { themedToast } from './modal.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';
|
import { questionsState, QUESTION_HISTORY_LIMIT } from './state.js';
|
||||||
|
|
||||||
// Registered by the dashboard entry at boot; defaults to a no-op so the
|
// 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 all read (${operatorInbox.length})`);
|
||||||
mark.addEventListener('click', markOperatorInboxRead);
|
mark.addEventListener('click', markOperatorInboxRead);
|
||||||
root.append(el('div', { class: 'inbox-toolbar' }, mark));
|
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' });
|
const ul = el('ul', { class: 'inbox' });
|
||||||
for (const m of operatorInbox) {
|
for (const m of operatorInbox) {
|
||||||
const body = el('span', { class: 'msg-body' });
|
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
|
// 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).
|
// obvious at a glance (see docs/web-ui.md::Approval card).
|
||||||
if (a.requested_at != null) {
|
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', {
|
head.append(el('span', {
|
||||||
class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''),
|
class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''),
|
||||||
title: 'requested ' + new Date(a.requested_at * 1000).toLocaleString(),
|
title: 'requested ' + new Date(a.requested_at).toLocaleString(),
|
||||||
'data-requested-at': String(a.requested_at),
|
'data-requested-at': String(requestedSec),
|
||||||
}, 'requested ' + fmtAgo(a.requested_at)));
|
}, 'requested ' + fmtAgo(a.requested_at)));
|
||||||
}
|
}
|
||||||
li.append(head);
|
li.append(head);
|
||||||
|
|
@ -556,7 +557,7 @@ function questionRowFingerprint(q) {
|
||||||
// Event listeners attached here (keydown on textarea, submit on form) are
|
// Event listeners attached here (keydown on textarea, submit on form) are
|
||||||
// preserved in the reused node — no re-attachment needed.
|
// preserved in the reused node — no re-attachment needed.
|
||||||
function buildQuestionLi(q) {
|
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 targetLabel = q.target || 'operator';
|
||||||
const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') });
|
const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') });
|
||||||
const head = el('div', { class: 'q-head' },
|
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
|
// Tag the chip with its deadline so the global 1s ticker
|
||||||
// can refresh the text without re-rendering the questions section.
|
// can refresh the text without re-rendering the questions section.
|
||||||
const ttlEl = el('span', {
|
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(
|
ttlEl.textContent = formatTtl(
|
||||||
q.deadline_at - Math.floor(Date.now() / 1000),
|
epochSec(q.deadline_at) - Math.floor(Date.now() / 1000),
|
||||||
);
|
);
|
||||||
head.append(' ', ttlEl);
|
head.append(' ', ttlEl);
|
||||||
}
|
}
|
||||||
|
|
@ -694,7 +695,7 @@ export function renderQuestions() {
|
||||||
// <li> nodes (preserving textarea/checkbox state) and only rebuilds
|
// <li> nodes (preserving textarea/checkbox state) and only rebuilds
|
||||||
// cache-miss rows, so we no longer wipe the DOM at the start.
|
// cache-miss rows, so we no longer wipe the DOM at the start.
|
||||||
const openDetails = snapshotOpenDetails(root);
|
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;
|
const allPending = questionsState.pending;
|
||||||
|
|
||||||
// Filter chips. Always include `all` / `operator` / `peer`; add
|
// Filter chips. Always include `all` / `operator` / `peer`; add
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
appendLinkified,
|
appendLinkified,
|
||||||
openStream, initServerWarnings,
|
openStream, initServerWarnings,
|
||||||
} from './common.js';
|
} from './common.js';
|
||||||
|
import { epochSec } from './util.js';
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
NOTIF.bind();
|
NOTIF.bind();
|
||||||
|
|
@ -92,7 +93,7 @@ import {
|
||||||
const flow = $('msgflow');
|
const flow = $('msgflow');
|
||||||
if (!flow) return;
|
if (!flow) return;
|
||||||
flow.replaceChildren();
|
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
|
// Pulse the page banner whenever a broker event lands. The
|
||||||
// `.banner` element lives in the dashboard's <footer> rather than
|
// `.banner` element lives in the dashboard's <footer> rather than
|
||||||
// in the flow chrome — `pulseBanner` no-ops on /flow.html since
|
// 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 }
|
const recentSent = new Map(); // broker row id → { row, at }
|
||||||
function rememberSent(ev, row) {
|
function rememberSent(ev, row) {
|
||||||
if (ev.id == null || ev.id <= 0) return;
|
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
|
// Bound the map — drop the oldest entries once it grows past a
|
||||||
// page of un-collapsed sends (insertion order = oldest first).
|
// page of un-collapsed sends (insertion order = oldest first).
|
||||||
while (recentSent.size > 256) {
|
while (recentSent.size > 256) {
|
||||||
|
|
@ -190,7 +191,7 @@ import {
|
||||||
function collapseDelivered(ev) {
|
function collapseDelivered(ev) {
|
||||||
if (ev.id == null || ev.id <= 0) return false;
|
if (ev.id == null || ev.id <= 0) return false;
|
||||||
const s = recentSent.get(ev.id);
|
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');
|
const arrow = s.row.querySelector('.msg-arrow');
|
||||||
if (arrow) arrow.textContent = '✓';
|
if (arrow) arrow.textContent = '✓';
|
||||||
// Re-style the row as delivered (green ✓) — the collapsed line now
|
// Re-style the row as delivered (green ✓) — the collapsed line now
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
import {
|
import {
|
||||||
$, el, fmtAgeSecs, openStream, initServerWarnings,
|
$, el, fmtAgeSecs, openStream, initServerWarnings,
|
||||||
} from './common.js';
|
} from './common.js';
|
||||||
|
import { epochSec } from './util.js';
|
||||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
|
|
@ -170,10 +171,11 @@ import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
let auditTotal = 0;
|
let auditTotal = 0;
|
||||||
let auditFetching = false;
|
let auditFetching = false;
|
||||||
|
|
||||||
// ts_unix is unix seconds — fmtAgeSecs wants an age in seconds.
|
// ts_unix arrives as an RFC 3339 string — fmtAgeSecs wants an age
|
||||||
function auditFmtWhen(tsUnix) {
|
// in seconds, so normalize via epochSec first.
|
||||||
if (!tsUnix) return '';
|
function auditFmtWhen(ts) {
|
||||||
const age = Math.floor(Date.now() / 1000) - tsUnix;
|
if (!ts) return '';
|
||||||
|
const age = Math.floor(Date.now() / 1000) - epochSec(ts);
|
||||||
return fmtAgeSecs(Math.max(0, age)) + ' ago';
|
return fmtAgeSecs(Math.max(0, age)) + ' ago';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,7 +236,7 @@ import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
tbody.append(el('tr', {},
|
tbody.append(el('tr', {},
|
||||||
el('td', {
|
el('td', {
|
||||||
class: 'audit-when meta',
|
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)),
|
}, auditFmtWhen(e.ts_unix)),
|
||||||
el('td', { class: 'audit-agent' }, e.agent || ''),
|
el('td', { class: 'audit-agent' }, e.agent || ''),
|
||||||
el('td', { class: 'audit-action' }, e.action || ''),
|
el('td', { class: 'audit-action' }, e.action || ''),
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
import { $, el, appendLinkified } from './common.js';
|
import { $, el, appendLinkified } from './common.js';
|
||||||
import { themedConfirm, themedToast } from './modal.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';
|
import { containersState } from './state.js';
|
||||||
|
|
||||||
export async function refreshReminders() {
|
export async function refreshReminders() {
|
||||||
|
|
@ -47,7 +47,7 @@ function renderReminders(rows) {
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const failed = (r.attempt_count || 0) > 0;
|
const failed = (r.attempt_count || 0) > 0;
|
||||||
const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') });
|
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
|
const dueLabel = dueIn <= 0
|
||||||
? `overdue ${fmtAgo(r.due_at)}`
|
? `overdue ${fmtAgo(r.due_at)}`
|
||||||
: `in ${fmtDuration(dueIn)}`;
|
: `in ${fmtDuration(dueIn)}`;
|
||||||
|
|
@ -55,8 +55,8 @@ function renderReminders(rows) {
|
||||||
el('span', { class: 'agent' }, r.agent), ' ',
|
el('span', { class: 'agent' }, r.agent), ' ',
|
||||||
el('span', {
|
el('span', {
|
||||||
class: 'meta reminder-due',
|
class: 'meta reminder-due',
|
||||||
title: new Date(r.due_at * 1000).toISOString(),
|
title: new Date(r.due_at).toISOString(),
|
||||||
'data-due-at': String(r.due_at),
|
'data-due-at': String(epochSec(r.due_at)),
|
||||||
}, dueLabel),
|
}, dueLabel),
|
||||||
' ',
|
' ',
|
||||||
el('span', { class: 'meta' }, `· id ${r.id}`),
|
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 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;
|
const bOrd = b.cancelled_at_unix ? 2 : b.paused_at_unix ? 1 : 0;
|
||||||
if (aOrd !== bOrd) return aOrd - bOrd;
|
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) {
|
for (const s of sorted) {
|
||||||
tbody.append(renderScheduleRow(s, agents));
|
tbody.append(renderScheduleRow(s, agents));
|
||||||
|
|
@ -657,24 +657,24 @@ function renderScheduleRow(s, agents) {
|
||||||
// absolute ISO in the title.
|
// absolute ISO in the title.
|
||||||
const nextCell = el('td', { class: 'meta schedules-table-next-col' });
|
const nextCell = el('td', { class: 'meta schedules-table-next-col' });
|
||||||
if (cancelled) {
|
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';
|
nextCell.textContent = 'cancelled';
|
||||||
} else if (paused) {
|
} else if (paused) {
|
||||||
nextCell.title = 'paused since '
|
nextCell.title = 'paused since '
|
||||||
+ new Date(s.paused_at_unix * 1000).toISOString()
|
+ new Date(s.paused_at_unix).toISOString()
|
||||||
+ '\nwould fire at '
|
+ '\nwould fire at '
|
||||||
+ new Date(s.next_fire_at_unix * 1000).toISOString();
|
+ new Date(s.next_fire_at_unix).toISOString();
|
||||||
nextCell.append(
|
nextCell.append(
|
||||||
el('span', { class: 'sched-paused-label' }, '⏸ paused'),
|
el('span', { class: 'sched-paused-label' }, '⏸ paused'),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const dueIn = s.next_fire_at_unix - Math.floor(Date.now() / 1000);
|
const dueIn = epochSec(s.next_fire_at_unix) - Math.floor(Date.now() / 1000);
|
||||||
nextCell.title = new Date(s.next_fire_at_unix * 1000).toISOString();
|
nextCell.title = new Date(s.next_fire_at_unix).toISOString();
|
||||||
nextCell.textContent = dueIn <= 0
|
nextCell.textContent = dueIn <= 0
|
||||||
? 'overdue ' + fmtAgo(s.next_fire_at_unix)
|
? 'overdue ' + fmtAgo(s.next_fire_at_unix)
|
||||||
: fmtDuration(dueIn);
|
: fmtDuration(dueIn);
|
||||||
nextCell.classList.add('sched-due');
|
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);
|
tr.append(nextCell);
|
||||||
|
|
||||||
|
|
@ -841,7 +841,7 @@ function renderScheduleEditForm(s) {
|
||||||
});
|
});
|
||||||
firstFireInput.value = carry.next_fire !== undefined
|
firstFireInput.value = carry.next_fire !== undefined
|
||||||
? carry.next_fire
|
? 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));
|
form_.append(scheduleField('next fire', firstFireInput));
|
||||||
|
|
||||||
const intervalCx = buildIntervalComposer({
|
const intervalCx = buildIntervalComposer({
|
||||||
|
|
@ -968,7 +968,7 @@ async function submitEditSchedule(originalSchedule, form_) {
|
||||||
if (newDescription !== (s.description || '')) {
|
if (newDescription !== (s.description || '')) {
|
||||||
patch.description = newDescription || null;
|
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;
|
patch.next_fire_at_unix = newNextFireUnix;
|
||||||
}
|
}
|
||||||
if (newIntervalSeconds !== (s.interval_seconds || null)) {
|
if (newIntervalSeconds !== (s.interval_seconds || null)) {
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,18 @@ export function paintAtomic(liveRoot, build) {
|
||||||
liveRoot.replaceChildren(buf);
|
liveRoot.replaceChildren(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relative age of a unix timestamp, coarsened to one unit ("5m ago").
|
// Epoch seconds from an API timestamp. The hive-c0re dashboard API
|
||||||
export function fmtAgo(unixSecs) {
|
// ships timestamps as RFC 3339 strings; a few feeds (rebuild queue,
|
||||||
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSecs));
|
// 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 < 60) return ageSec + 's ago';
|
||||||
if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
|
if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
|
||||||
if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';
|
if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue