From 498cc463a3c948fba7d5463649dce180ff6a3a9b Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:42:00 -0400 Subject: [PATCH] Date widget readings by when the service produced them --- api/src/latest.js | 8 ++--- api/src/widget-data.js | 5 +++- api/test/http-integration.test.js | 33 +++++++++++++++++++++ api/test/latest.test.js | 17 ++++++++++- ui/js/widget-toolbox.js | 16 ++++++++-- ui/test/weather-updated.test.mjs | 4 +++ ui/test/widget-toolbox-fetch-data.test.mjs | 23 ++++++++++++-- ui/widgets/backup/backup.html | 2 +- ui/widgets/books/index.html | 2 +- ui/widgets/clock/analog.html | 2 +- ui/widgets/clock/digital.html | 2 +- ui/widgets/connections/connections-map.html | 2 +- ui/widgets/connections/connections-vpn.html | 2 +- ui/widgets/dashboard-switch/index.html | 2 +- ui/widgets/disk-health/index.html | 2 +- ui/widgets/dns/index.html | 2 +- ui/widgets/github/contributions.html | 2 +- ui/widgets/github/pullrequests.html | 2 +- ui/widgets/nowplaying/index.html | 2 +- ui/widgets/system-summary/index.html | 2 +- ui/widgets/weather/index.html | 4 +-- 21 files changed, 111 insertions(+), 25 deletions(-) diff --git a/api/src/latest.js b/api/src/latest.js index 5302cf04..bc245439 100644 --- a/api/src/latest.js +++ b/api/src/latest.js @@ -2,7 +2,7 @@ holdMs old. After that it gets the last settled outcome, or null when there is none younger than keepMs. */ -/** @typedef {{ ok: true, value: any } | { ok: false, error: unknown }} Outcome */ +/** @typedef {{ ok: true, value: any, at: number } | { ok: false, error: unknown, at: number }} Outcome */ /** @type {Map | null, group: string | null }>} */ const _entries = new Map(); @@ -53,12 +53,12 @@ async function latest(key, refresh, { holdMs, keepMs, group, maxPerGroup }) { entry.started = now; entry.pending = (async () => refresh())() .then( - value => /** @type {Outcome} */ ({ ok: true, value }), - error => /** @type {Outcome} */ ({ ok: false, error }), + value => /** @type {Outcome} */ ({ ok: true, value, at: Date.now() }), + error => /** @type {Outcome} */ ({ ok: false, error, at: Date.now() }), ) .then(outcome => { entry.outcome = outcome; - entry.at = Date.now(); + entry.at = outcome.at; entry.pending = null; return outcome; }); diff --git a/api/src/widget-data.js b/api/src/widget-data.js index 5036f3ce..1fc5bab7 100644 --- a/api/src/widget-data.js +++ b/api/src/widget-data.js @@ -172,7 +172,10 @@ on('GET', '/api/widget-data/:id', async (req, res) => { maxPerGroup: KEPT_PER_WIDGET, }); if (!outcome) return json(res, 202, { pending: true }); - if (outcome.ok) return json(res, outcome.value.status, outcome.value.body); + if (outcome.ok) { + res.setHeader('Age', String(Math.max(0, Math.floor((Date.now() - outcome.at) / 1000)))); + return json(res, outcome.value.status, outcome.value.body); + } fail(res, outcome.error, { status: 502, quiet: true }); }); diff --git a/api/test/http-integration.test.js b/api/test/http-integration.test.js index 0e53059d..3c23cd59 100644 --- a/api/test/http-integration.test.js +++ b/api/test/http-integration.test.js @@ -223,6 +223,39 @@ test('a widget whose service stalls answers pending, then with the data once it } }); +test('a stored reply served while a refresh stalls says how old it is', async () => { + let release; + const released = new Promise(r => { + release = r; + }); + let calls = 0; + const upstream = http.createServer(async (_, res) => { + if (++calls > 1) await released; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(`{"n":${calls}}`); + }); + await new Promise(r => upstream.listen(0, '127.0.0.1', r)); + const cfg = loadConfig(); + const url = `http://127.0.0.1:${upstream.address().port}/`; + saveConfig({ + ...cfg, + items: [...(cfg.items || []), { id: 'aged-w', type: 'widget', widgetType: 'fixture', widgetConfig: { url } }], + }); + try { + const first = await req('GET', '/api/widget-data/aged-w', { cookie: validCookie }); + assert.equal(first.headers.age, '0'); + await new Promise(r => setTimeout(r, 2100)); + const second = await req('GET', '/api/widget-data/aged-w', { cookie: validCookie }); + assert.deepEqual(second.body, { status: 200, data: { n: 1 } }); + assert.ok(Number(second.headers.age) >= 2, `Age was ${second.headers.age}`); + } finally { + release(); + saveConfig(cfg); + upstream.closeAllConnections?.(); + await new Promise(r => upstream.close(r)); + } +}); + test('query values a widget does not read share one refresh and one cached reply', async () => { let release; const released = new Promise(r => { diff --git a/api/test/latest.test.js b/api/test/latest.test.js index 6047cccd..7c10448d 100644 --- a/api/test/latest.test.js +++ b/api/test/latest.test.js @@ -1,6 +1,13 @@ const { test, beforeEach } = require('node:test'); const assert = require('node:assert/strict'); -const { latest, reset, _size } = require('../src/latest'); +const { latest: latestWithTime, reset, _size } = require('../src/latest'); + +const latest = async (...args) => { + const outcome = await latestWithTime(...args); + if (!outcome) return outcome; + const { at, ...rest } = outcome; + return rest; +}; const OPTS = { holdMs: 30, keepMs: 60_000 }; const wait = ms => new Promise(r => setTimeout(r, ms)); @@ -101,3 +108,11 @@ test('the cap never drops a refresh in flight, and leaves other groups alone', a value: 0, }); }); + +test('an outcome carries the time it settled, and a stored one keeps it', async t => { + t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 }); + assert.equal((await latestWithTime('k', async () => 'old', OPTS)).at, 1_000_000); + t.mock.timers.tick(10_000); + const stored = await latestWithTime('k', () => new Promise(() => {}), { holdMs: 0, keepMs: OPTS.keepMs }); + assert.deepEqual(stored, { ok: true, value: 'old', at: 1_000_000 }); +}); diff --git a/ui/js/widget-toolbox.js b/ui/js/widget-toolbox.js index 9f926434..e5b5e773 100644 --- a/ui/js/widget-toolbox.js +++ b/ui/js/widget-toolbox.js @@ -118,7 +118,19 @@ export async function fetchData(endpoint, opts = {}) { if (typeof d.kind === 'string') e.kind = d.kind; throw e; } - return r.json(); + const data = await r.json(); + const age = Number(r.headers.get('Age')); + if (data && typeof data === 'object') _readAt.set(data, Date.now() - (age > 0 ? age * 1000 : 0)); + return data; +} + +/** @type {WeakMap} */ +const _readAt = new WeakMap(); + +/** When the service produced data that fetchData returned. + @param {unknown} data @returns {number} */ +export function readingTime(data) { + return (data && typeof data === 'object' && _readAt.get(data)) || Date.now(); } export function openUrl(href) { @@ -678,7 +690,7 @@ export function poll(opts = {}) { opts.render && opts.render(data); } fails = 0; - lastOk = Date.now(); + lastOk = readingTime(data); everOk = true; lastData = data; } catch (caught) { diff --git a/ui/test/weather-updated.test.mjs b/ui/test/weather-updated.test.mjs index e93cda95..f9ad3975 100644 --- a/ui/test/weather-updated.test.mjs +++ b/ui/test/weather-updated.test.mjs @@ -21,3 +21,7 @@ test('the hover and screen-reader text say when the reading arrived', () => { ); assert.match(page, /setInterval\(describe, 60000\)/); }); + +test('the updated time is when the service produced the reading', () => { + assert.match(page, /_fetchedAt = readingTime\(d\);/); +}); diff --git a/ui/test/widget-toolbox-fetch-data.test.mjs b/ui/test/widget-toolbox-fetch-data.test.mjs index 8a8fad6c..ae5e77b8 100644 --- a/ui/test/widget-toolbox-fetch-data.test.mjs +++ b/ui/test/widget-toolbox-fetch-data.test.mjs @@ -4,9 +4,14 @@ import { register } from 'node:module'; register('./js-root-hooks.mjs', import.meta.url); globalThis.location = { search: '?id=w1' }; -const { fetchData } = await import('../js/widget-toolbox.js'); +const { fetchData, readingTime } = await import('../js/widget-toolbox.js'); -const reply = (status, body) => ({ status, ok: status >= 200 && status < 300, json: async () => body }); +const reply = (status, body, headers = {}) => ({ + status, + ok: status >= 200 && status < 300, + headers: new Headers(headers), + json: async () => body, +}); /* Settles `promise` while advancing mocked timers one second at a time. */ async function drive(t, promise) { @@ -45,3 +50,17 @@ test('a reply that stays pending ends as a timeout', async t => { await assert.rejects(drive(t, fetchData()), { kind: 'timeout' }); assert.ok(calls > 1 && calls < 50, `${calls} fetches`); }); + +test('a reply the server held back is dated by its Age', async () => { + globalThis.fetch = async () => reply(200, { n: 1 }, { Age: '600' }); + const before = Date.now(); + const data = await fetchData('stats'); + assert.ok(Math.abs(readingTime(data) - (before - 600_000)) < 1000); +}); + +test('a fresh reply is dated now', async () => { + globalThis.fetch = async () => reply(200, { n: 1 }, { Age: '0' }); + const data = await fetchData('stats'); + assert.ok(Date.now() - readingTime(data) < 1000); + assert.ok(Date.now() - readingTime({ other: true }) < 1000); +}); diff --git a/ui/widgets/backup/backup.html b/ui/widgets/backup/backup.html index a2ee0e44..d705318a 100644 --- a/ui/widgets/backup/backup.html +++ b/ui/widgets/backup/backup.html @@ -122,7 +122,7 @@