stackchain-dashboard/frontend/live-data-status.js
ox 398598e154
All checks were successful
CI / lint (pull_request) Successful in 4m4s
CI / build-release (pull_request) Successful in 15s
CI / browser-journey (pull_request) Successful in 8m45s
CI / release-candidate (pull_request) Has been skipped
feat: bucket aggregation latency panel (Closes #11)
Measure each live aggregation bucket's own upstream duration server-side
(auth fetch attributed to Work/Activity, per-feed timed gather) and surface
the measured values in the Live data status panel.

- Per-bucket latency_ms measured individually, never batch wall time
- Stale feeds label their number 'last known'; unmeasured feeds say
  'not measured' instead of an ambiguous dash; hidden when no data
- Failed buckets keep their last known latency across partial refreshes
- Panel is aria-labelled rows as a list, values wrap on mobile
- Ignore .venv/
2026-08-22 21:49:39 +00:00

241 lines
8.7 KiB
JavaScript

(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.liveDataStatus = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const feeds = [
['context', 'Work'],
['notifications', 'Updates'],
['events', 'Activity'],
];
function boundedSeconds(value) {
const seconds = Number(value);
return Number.isFinite(seconds) && seconds >= 0 ? Math.min(Math.round(seconds), 86400) : null;
}
function boundedLatencyMs(value) {
const ms = Number(value);
return Number.isFinite(ms) && ms >= 0 ? Math.min(Math.round(ms), 3600000) : null;
}
const latencyBuckets = [
['context', 'Work'],
['notifications', 'Updates'],
['events', 'Activity'],
];
function bucketLatencies(freshness = {}) {
const measured = freshness.latency_ms || {};
return new Map(
latencyBuckets
.map(([key]) => [key, boundedLatencyMs(measured[key])])
.filter(([, value]) => value !== null)
);
}
function describe(freshness = {}) {
const sections = freshness.sections || {};
const latencies = bucketLatencies(freshness);
const hasSectionData = feeds.some(([key]) => Object.prototype.hasOwnProperty.call(sections, key));
const described = feeds.map(([key, label]) => {
const section = sections[key] || {};
const state = section.revalidating ? 'refreshing' :
(section.stale || section.degraded ? 'delayed' : 'live');
return { key, label, state, ageSeconds: boundedSeconds(section.age_seconds),
latencyMs: latencies.get(key) ?? null };
});
const delayed = described.filter(feed => feed.state === 'delayed');
const refreshing = described.filter(feed => feed.state === 'refreshing');
let summary = hasSectionData ? 'Live' : 'Live data unavailable';
if (delayed.length === 1) summary = delayed[0].label + ' delayed';
else if (delayed.length > 1) summary = delayed.length + ' data feeds delayed';
else if (refreshing.length === 1) summary = refreshing[0].label + ' refreshing';
else if (refreshing.length > 1) summary = 'Refreshing live data';
const retryValues = described.map(feed => {
const section = sections[feed.key] || {};
return boundedSeconds(section.retry_in_seconds);
}).filter(value => value !== null && value > 0);
const aggregateRetry = boundedSeconds(freshness.retry_in_seconds);
if (aggregateRetry !== null && aggregateRetry > 0) retryValues.push(aggregateRetry);
return {
summary,
feeds: described,
nextRetrySeconds: retryValues.length ? Math.min(...retryValues) : null,
};
}
function formatLatency(ms) {
if (ms === null) return 'not measured';
if (ms >= 10000) return Math.round(ms / 1000) + ' s';
if (ms >= 1000) return (ms / 1000).toFixed(1) + ' s';
return ms + ' ms';
}
function renderBucketLatency(description, panel) {
const rows = description.feeds.map(feed => ({
key: feed.key,
label: feed.label,
latencyMs: feed.latencyMs,
stale: feed.state === 'delayed',
}));
if (!rows.some(row => row.latencyMs !== null)) {
panel.hidden = true;
panel.innerHTML = '';
return rows;
}
panel.hidden = false;
panel.innerHTML = rows.map(row => {
const slow = row.latencyMs !== null && row.latencyMs >= 2000 ? ' slow' : '';
const value = formatLatency(row.latencyMs);
// A delayed feed shows the last measurement that succeeded, so say so.
const suffix = row.stale && row.latencyMs !== null ? ' <span class="small muted">last known</span>' : '';
return '<div class="bucket-latency-row" data-bucket="' + row.key + '">' +
'<strong>' + row.label + '</strong>' +
'<span class="bucket-latency-value' + slow + '">' + value + suffix + '</span>' +
'</div>';
}).join('');
return rows;
}
function createRefreshController({ button, output, refresh, onState = () => {} }) {
let pending = null;
function run() {
if (pending) return pending;
button.disabled = true;
output.textContent = 'Refreshing live data…';
onState('refreshing');
let request;
try {
request = refresh();
} catch (error) {
request = Promise.reject(error);
}
pending = Promise.resolve(request).then(result => {
if (!result) throw new Error('Live data remains unavailable.');
output.textContent = 'Live data refreshed.';
onState('success');
return result;
}).catch(error => {
output.textContent = error && error.message ? error.message : 'Live data refresh failed.';
onState('error');
return null;
}).finally(() => {
button.disabled = false;
pending = null;
});
return pending;
}
return { run };
}
function createSheetController(options) {
let ownsDetour = false;
let backgroundState = null;
function focusableControls() {
return [...options.sheet.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])')]
.filter(control => !control.hidden);
}
function containBackground() {
if (backgroundState) return;
backgroundState = new Map((options.backgroundElements || []).map(element => [element, element.inert]));
backgroundState.forEach((_wasInert, element) => { element.inert = true; });
}
function releaseBackground() {
if (!backgroundState) return;
backgroundState.forEach((wasInert, element) => { element.inert = wasInert; });
backgroundState = null;
}
function finishClose() {
if (options.sheet.hidden) return;
options.sheet.hidden = true;
options.trigger.setAttribute('aria-expanded', 'false');
if (options.pausedStatus) options.pausedStatus.hidden = true;
releaseBackground();
if (ownsDetour) options.timerView?.finishDetour?.();
ownsDetour = false;
options.trigger.focus?.();
}
function close() {
if (options.history?.state?.liveDataStatus) {
options.history.back();
return;
}
finishClose();
}
function open() {
if (!options.sheet.hidden) return;
ownsDetour = options.timerView?.beginDetour?.('live-data-status')?.reason === 'live-data-status';
if (options.pausedStatus) options.pausedStatus.hidden = !ownsDetour;
if (options.returnButton) options.returnButton.hidden = !ownsDetour;
containBackground();
options.sheet.hidden = false;
options.trigger.setAttribute('aria-expanded', 'true');
if (options.history && !options.history.state?.liveDataStatus) {
options.history.pushState({ ...options.history.state, liveDataStatus:true }, '');
}
options.closeButton.focus();
}
function start() {
options.trigger.addEventListener('click', open);
options.closeButton.addEventListener('click', close);
options.returnButton?.addEventListener('click', close);
options.sheet.addEventListener('click', event => {
if (event.target === options.sheet) close();
});
options.escapeTarget?.addEventListener('keydown', event => {
if (options.sheet.hidden) return;
if (event.key === 'Escape') {
event.preventDefault?.();
close();
return;
}
if (event.key !== 'Tab') return;
const controls = focusableControls();
if (!controls.length) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && event.target === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && event.target === last) {
event.preventDefault();
first.focus();
}
});
options.historyTarget?.addEventListener('popstate', event => {
if (!options.sheet.hidden && !event.state?.liveDataStatus) finishClose();
});
}
return { start, open, close };
}
function mount(options) {
const document = options.document;
const qs = selector => document.querySelector(selector);
const trigger = qs('#open-live-data-status');
trigger.addEventListener('click', options.onOpen);
return createSheetController({
sheet:qs('#live-data-status-sheet'), trigger,
closeButton:qs('#close-live-data-status'),
returnButton:qs('#return-from-live-data-status'),
pausedStatus:qs('#live-data-status-today-paused'),
timerView:options.timerView,
history:options.window.history,
historyTarget:options.window,
escapeTarget:document,
backgroundElements:[qs('header'), qs('main'), qs('#mobile-task-dock')].filter(Boolean),
});
}
return { describe, renderBucketLatency, createRefreshController, createSheetController, mount };
});