From 7f813f88c30c9c08a56bee30a861daec9e109247 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:41:59 -0400 Subject: [PATCH] Report a widget render failure as internal, not network --- ui/js/widget-toolbox.js | 13 +++++++++++- ui/test/widget-toolbox-poll.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 | 2 +- 16 files changed, 49 insertions(+), 15 deletions(-) diff --git a/ui/js/widget-toolbox.js b/ui/js/widget-toolbox.js index f25a8911..9f926434 100644 --- a/ui/js/widget-toolbox.js +++ b/ui/js/widget-toolbox.js @@ -635,6 +635,14 @@ if (typeof window !== 'undefined') /** @type {any} */ (window).__setPollRate = s const SLOW_POLL_MS = 300000; +function asInternal(e) { + if (e && typeof e === 'object' && !Object.isFrozen(e)) { + if (typeof e.kind !== 'string') e.kind = 'internal'; + return e; + } + return Object.assign(new Error(String(e)), { kind: 'internal' }); +} + export function poll(opts = {}) { const intervalFor = d => (typeof opts.interval === 'function' ? opts.interval(d) : opts.interval) || 30000; /* A slow widget that waits for a second failure shows data two intervals old @@ -657,8 +665,10 @@ export function poll(opts = {}) { async function tick() { if (stopped) return; lastTick = Date.now(); + let fetched = false; try { const data = await doFetch(); + fetched = true; if (stopped) return; if (isEmpty(data)) { if (ov) ov.empty(opts.emptyText || _t('noData', 'No data')); @@ -671,8 +681,9 @@ export function poll(opts = {}) { lastOk = Date.now(); everOk = true; lastData = data; - } catch (e) { + } catch (caught) { if (stopped) return; + const e = fetched ? asInternal(caught) : caught; fails++; const stale = fails >= staleAfter(); if (custom) opts.onError({ error: e, everOk, stale, since: lastOk ? sinceLabel(lastOk) : '' }); diff --git a/ui/test/widget-toolbox-poll.test.mjs b/ui/test/widget-toolbox-poll.test.mjs index 6cd9e2a5..f46a1ac8 100644 --- a/ui/test/widget-toolbox-poll.test.mjs +++ b/ui/test/widget-toolbox-poll.test.mjs @@ -8,6 +8,7 @@ import { register } from 'node:module'; register('./js-root-hooks.mjs', import.meta.url); globalThis.location = { search: '?id=test' }; const { poll, setPollRate, errorState } = await import('../js/widget-toolbox.js'); +const { errorKind } = await import('../js/widget-error.js'); const tick = ms => new Promise(r => setTimeout(r, ms)); @@ -536,3 +537,25 @@ test('a widget that never rendered reports its first render failure', async () = assert.equal(errors[0].everOk, false); }); + +test('a render failure reads as the widget going wrong, and a fetch failure as out of reach', async () => { + const errors = []; + let call = 0; + const p = poll({ + interval: 5, + fetch: async () => { + call++; + if (call === 2) throw new TypeError('Failed to fetch'); + return {}; + }, + render: () => { + if (call === 1) throw new TypeError('bad field'); + }, + onError: info => errors.push(info), + }); + await tick(30); + p.stop(); + + assert.equal(errorKind(errors[0].error), 'internal'); + assert.equal(errorKind(errors[1].error), 'network'); +}); diff --git a/ui/widgets/backup/backup.html b/ui/widgets/backup/backup.html index f07f07ae..a2ee0e44 100644 --- a/ui/widgets/backup/backup.html +++ b/ui/widgets/backup/backup.html @@ -122,7 +122,7 @@