From f87d200da4fc71d5657d020939cf6ba578a0b47d Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:25:39 -0400 Subject: [PATCH] Docs: describe the failure line a widget shows The app no longer composes a widget's error text from the service's own message. It shows one line named after the kind of failure, and an empty result is drawn differently from a failed one. Correct the three places that quoted the old strings, and add a table of the lines a widget can show, with where to look for each. Re-sync the embedded widget code so the previews run the current version. --- public/js/widget-error.js | 196 ++++++++++++++++++ public/js/widget-toolbox.js | 52 ++--- public/widgets/backup/backup.html | 51 +++-- public/widgets/backup/i18n/de.json | 3 - public/widgets/backup/i18n/en.json | 3 - public/widgets/backup/i18n/es.json | 3 - public/widgets/backup/i18n/fa.json | 3 - public/widgets/backup/i18n/fr.json | 3 - public/widgets/backup/i18n/zh-Hans.json | 3 - public/widgets/books/index.html | 16 +- .../widgets/connections/connections-map.html | 18 +- .../widgets/connections/connections-vpn.html | 15 +- public/widgets/connections/data.js | 22 +- public/widgets/dashboard-switch/index.html | 2 +- public/widgets/disk-health/i18n/de.json | 4 +- public/widgets/disk-health/i18n/en.json | 4 +- public/widgets/disk-health/i18n/es.json | 4 +- public/widgets/disk-health/i18n/fa.json | 4 +- public/widgets/disk-health/i18n/fr.json | 4 +- public/widgets/disk-health/i18n/zh-Hans.json | 4 +- public/widgets/disk-health/index.html | 53 ++++- public/widgets/dns/index.html | 33 +-- public/widgets/github/contributions.html | 51 ++--- public/widgets/github/i18n/de.json | 4 - public/widgets/github/i18n/en.json | 4 - public/widgets/github/i18n/es.json | 4 - public/widgets/github/i18n/fa.json | 4 - public/widgets/github/i18n/fr.json | 4 - public/widgets/github/i18n/zh-Hans.json | 4 - public/widgets/github/pullrequests.html | 54 ++--- public/widgets/nowplaying/index.html | 28 ++- public/widgets/system-summary/index.html | 31 ++- public/widgets/weather/index.html | 24 +-- src/content/docs/docs/troubleshooting.md | 27 ++- src/content/docs/docs/widgets/github.mdx | 2 +- 35 files changed, 524 insertions(+), 217 deletions(-) create mode 100644 public/js/widget-error.js diff --git a/public/js/widget-error.js b/public/js/widget-error.js new file mode 100644 index 0000000..1822d94 --- /dev/null +++ b/public/js/widget-error.js @@ -0,0 +1,196 @@ +// @ts-check +/* The failure states every widget shares. The wording comes from the kind the + API sends, documented in docs/api-errors.md. + + Never draw a response's error message. It names hosts, ports and status + codes, and it is not translated. */ + +/* Nodes, not markup: this file is covered by the innerHTML ratchet. */ +const GLYPHS = { + offline: [ + ['path', { d: 'M4 4l16 16' }], + ['path', { d: 'M12 18.6h.01' }], + ['path', { d: 'M8.1 14.7a5.5 5.5 0 0 1 3.9-1.6' }], + ], + slow: [ + ['circle', { cx: '12', cy: '12', r: '7.6' }], + ['path', { d: 'M12 7.8V12l3 1.8' }], + ], + key: [ + ['rect', { x: '5.4', y: '10.6', width: '13.2', height: '8.4', rx: '2.4' }], + ['path', { d: 'M8.6 10.6V8.2a3.4 3.4 0 0 1 6.8 0v2.4' }], + ], + blocked: [ + ['circle', { cx: '12', cy: '12', r: '7.6' }], + ['path', { d: 'M6.6 6.6l10.8 10.8' }], + ], + unset: [ + ['path', { d: 'M4.6 8.4h14.8M4.6 15.6h14.8' }], + ['circle', { cx: '9.4', cy: '8.4', r: '2.1' }], + ['circle', { cx: '14.6', cy: '15.6', r: '2.1' }], + ], + odd: [ + ['circle', { cx: '12', cy: '12', r: '7.6' }], + ['path', { d: 'M12 8.2v4.6' }], + ['path', { d: 'M12 16h.01' }], + ], +}; + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +/* kind -> [glyph, catalog key, English] */ +const COPY = { + network: ['offline', 'errNetwork', 'Out of reach'], + timeout: ['slow', 'errTimeout', 'Took too long'], + auth: ['key', 'errAuth', 'Key rejected'], + blocked: ['blocked', 'errBlocked', 'Request blocked'], + invalid: ['unset', 'errInvalid', 'Not set up yet'], + upstream: ['odd', 'errUpstream', 'Service error'], + internal: ['odd', 'errInternal', 'Something went wrong'], +}; + +const FALLBACK = 'internal'; + +/** The kind the API sent, or the nearest one a bare fetch failure implies. + @param {unknown} err @returns {string} */ +export function errorKind(err) { + const e = /** @type {{ kind?: unknown, status?: unknown }} */ (err && typeof err === 'object' ? err : {}); + if (typeof e.kind === 'string' && Object.hasOwn(COPY, e.kind)) return e.kind; + const s = typeof e.status === 'number' ? e.status : 0; + if (s === 401 || s === 403) return 'auth'; + if (s === 503) return 'invalid'; + if (s === 504) return 'timeout'; + if (s === 502) return 'network'; + if (s >= 400) return 'upstream'; + return err ? 'network' : FALLBACK; +} + +/** @param {string} kind @param {Document} doc @returns {SVGElement} */ +function errorGlyph(kind, doc) { + const [g] = COPY[kind] || COPY[FALLBACK]; + const svg = doc.createElementNS(SVG_NS, 'svg'); + for (const [k, v] of Object.entries({ + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '1.7', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + 'aria-hidden': 'true', + })) + svg.setAttribute(k, v); + for (const [tag, attrs] of GLYPHS[g]) { + const el = doc.createElementNS(SVG_NS, tag); + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); + svg.appendChild(el); + } + return svg; +} + +/** The catalog key and English wording for a kind. + @param {string} kind @returns {{ key: string, text: string }} */ +export function errorCopy(kind) { + const [, key, text] = COPY[kind] || COPY[FALLBACK]; + return { key, text }; +} + +export const ERROR_KINDS = Object.freeze(Object.keys(COPY)); + +const STYLE_ID = 'wt-error-css'; +const CSS = ` +.wt-inert { filter: grayscale(0.9) opacity(0.5); transition: filter 0.4s ease; pointer-events: none; } +.wt-cap { display: flex; align-items: center; gap: 5px; min-width: 0; font-size: 11px; font-weight: 500; + line-height: 1.3; color: var(--wt-cap-color, rgba(255,255,255,0.62)); + /* The caption is never a target. A centred one covers its whole widget, and + with pointer events it swallows every hover the widget has. */ + pointer-events: none; } +/* This rule sets display, which outranks the user agent's [hidden] rule. Without + its own hidden rule the caption never goes away. */ +.wt-cap[hidden] { display: none; } +.wt-cap svg { width: 12px; height: 12px; flex: 0 0 auto; opacity: 0.85; } +.wt-cap b { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wt-cap i { font-style: normal; opacity: 0.7; flex: 0 0 auto; } +.wt-cap-auto { position: absolute; inset-inline: 16px; bottom: 12px; } +.wt-cap-center { position: absolute; inset: 0; justify-content: center; text-align: center; padding: 0 14px; } +@media (prefers-reduced-motion: reduce) { .wt-inert { transition: none; } } +`; + +function ensureStyle(doc) { + if (!doc || doc.getElementById(STYLE_ID)) return; + const s = doc.createElement('style'); + s.id = STYLE_ID; + s.textContent = CSS; + doc.head.appendChild(s); +} + +/** One widget's error, empty and healthy states. The options are documented in + docs/widgets.md. + + @param {any} opts */ +export function errorState(opts = {}) { + const root = opts.root; + const contentOf = () => + [].concat((typeof opts.content === 'function' ? opts.content() : opts.content) || root || []).filter(Boolean); + const t = typeof opts.t === 'function' ? opts.t : (_k, fallback) => fallback; + const doc = root && root.ownerDocument; + ensureStyle(doc); + + let cap = opts.caption || null; + if (!cap && root) { + /* Absolutely placed. Without a positioned root it escapes the widget. */ + const view = doc.defaultView; + if (view && view.getComputedStyle(root).position === 'static') root.style.position = 'relative'; + cap = doc.createElement('div'); + cap.className = opts.place === 'center' ? 'wt-cap wt-cap-center' : 'wt-cap wt-cap-auto'; + root.appendChild(cap); + } + if (cap) { + cap.classList.add('wt-cap'); + cap.hidden = true; + } + + function paint(glyph, text, suffix) { + if (!cap) return; + cap.textContent = ''; + if (glyph) cap.appendChild(glyph); + const b = doc.createElement('b'); + b.dir = 'auto'; + b.textContent = text; + cap.appendChild(b); + if (suffix) { + const i = doc.createElement('i'); + i.textContent = '· ' + suffix; + cap.appendChild(i); + } + cap.hidden = false; + } + + return { + /** Pass inert false when the widget never had data. Fading a placeholder + leaves an empty frame. + + @param {unknown} err @param {{ since?: string, inert?: boolean }} [info] + @returns {string} the line drawn, for an accessible name */ + fail(err, info = {}) { + const kind = errorKind(err); + const { key, text } = errorCopy(kind); + const line = t(key, text); + const dim = info.inert !== false; + for (const el of contentOf()) el.classList.toggle('wt-inert', dim); + paint(errorGlyph(kind, doc), line, info.since || ''); + return line; + }, + /** @param {string} text */ + empty(text) { + for (const el of contentOf()) el.classList.remove('wt-inert'); + paint(null, text, ''); + }, + ok() { + for (const el of contentOf()) el.classList.remove('wt-inert'); + if (cap) { + cap.hidden = true; + cap.textContent = ''; + } + }, + }; +} diff --git a/public/js/widget-toolbox.js b/public/js/widget-toolbox.js index ead62f3..39d5fcd 100644 --- a/public/js/widget-toolbox.js +++ b/public/js/widget-toolbox.js @@ -4,6 +4,7 @@ import { esc, html, setHtml } from '/js/html.js?v=c71f8903'; import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f'; import { jitter } from '/js/jitter.js?v=4eeef4c9'; +import { errorState as _errorState, errorKind, errorCopy } from '/js/widget-error.js?v=e06eef64'; export { esc, html, setHtml }; @@ -28,8 +29,9 @@ export async function fetchData(endpoint, opts = {}) { const r = await fetch(`/api/widget-data/${encodeURIComponent(id)}${qs}`, { cache: 'no-store', signal: opts.signal }); if (!r.ok) { const d = await r.json().catch(() => ({})); - const e = /** @type {Error & { status?: number }} */ (new Error(d.error || 'HTTP ' + r.status)); + const e = /** @type {Error & { status?: number, kind?: string }} */ (new Error(d.error || 'HTTP ' + r.status)); e.status = r.status; + if (typeof d.kind === 'string') e.kind = d.kind; throw e; } return r.json(); @@ -262,23 +264,18 @@ export function sinceLabel(ts) { } } -function _overlay(root) { - if (getComputedStyle(root).position === 'static') root.style.position = 'relative'; - const el = document.createElement('div'); - el.style.cssText = - 'position:absolute;inset:0;display:none;align-items:center;justify-content:center;' + - 'text-align:center;padding:0 16px;font-size:11px;line-height:1.35;color:rgba(150,150,150,0.92);pointer-events:none'; - root.appendChild(el); - return { - show(msg, dim) { - el.textContent = msg; - el.style.background = dim ? 'rgba(20,20,22,0.55)' : 'transparent'; - el.style.display = 'flex'; - }, - hide() { - el.style.display = 'none'; - }, - }; +/** The shared error, empty and healthy states, wired to the widget catalog. + @param {any} opts */ +export function errorState(opts = {}) { + return _errorState(Object.assign({ t: _t }, opts)); +} + +/** The reader's wording for a failure, for a widget that draws its own state. + @param {unknown} err a caught error, or { kind } from a response + @returns {string} */ +export function errorLine(err) { + const { key, text } = errorCopy(errorKind(err)); + return _t(key, text); } /* The options and the poll lifecycle are documented in docs/widgets.md. */ @@ -305,7 +302,7 @@ export function poll(opts = {}) { const isEmpty = opts.isEmpty || (() => false); const doFetch = opts.fetch || (() => fetchData(opts.endpoint)); const custom = typeof opts.onError === 'function'; /* widget draws its own error UI */ - const ov = custom ? null : _overlay(opts.root || document.body); + const ov = custom ? null : errorState({ root: opts.root || document.body, content: opts.content }); let lastOk = 0, fails = 0, everOk = false, @@ -325,9 +322,11 @@ export function poll(opts = {}) { lastOk = Date.now(); everOk = true; lastData = data; - if (!custom && isEmpty(data)) ov.show(opts.emptyText || _t('noData', 'No data'), false); - else { - if (ov) ov.hide(); + if (isEmpty(data)) { + if (ov) ov.empty(opts.emptyText || _t('noData', 'No data')); + else opts.onEmpty && opts.onEmpty(data); + } else { + if (ov) ov.ok(); opts.render && opts.render(data); } } catch (e) { @@ -335,12 +334,7 @@ export function poll(opts = {}) { fails++; const stale = fails >= staleAfter; if (custom) opts.onError({ error: e, everOk, stale, since: lastOk ? sinceLabel(lastOk) : '' }); - else if (!everOk) ov.show(opts.errorText || _t('unavailable', 'Unavailable'), false); - else if (stale) - ov.show( - (opts.errorText || _t('unavailable', 'Unavailable')) + (lastOk ? ' · ' + sinceLabel(lastOk) : ''), - true, - ); + else if (!everOk || stale) ov.fail(e, { since: everOk && lastOk ? sinceLabel(lastOk) : '' }); } } @@ -393,7 +387,7 @@ export function poll(opts = {}) { const canObserve = typeof document !== 'undefined' && typeof document.addEventListener === 'function'; if (canObserve) document.addEventListener('visibilitychange', onVisibility); - if (ov) ov.show(opts.loadingText || _t('loading', 'Loading'), false); + if (ov) ov.empty(opts.loadingText || _t('loading', 'Loading')); loop(); return { stop() { diff --git a/public/widgets/backup/backup.html b/public/widgets/backup/backup.html index 9792ca3..54faa29 100644 --- a/public/widgets/backup/backup.html +++ b/public/widgets/backup/backup.html @@ -13,9 +13,14 @@ .widget { width: 100%; height: 100%; - display: flex; align-items: stretch; justify-content: center; - padding: 10px; gap: 12px; box-sizing: border-box; + display: flex; flex-direction: column; + padding: 10px; box-sizing: border-box; } + #cards { + flex: 1; min-height: 0; + display: flex; align-items: stretch; justify-content: center; gap: 12px; + } + .bk-cap { flex: none; padding-top: 6px; } /* Mobile is detected by body height <= 200px, or an aspect ratio above 1.5 for a medium slot. */ @@ -115,7 +120,7 @@
diff --git a/public/widgets/connections/connections-vpn.html b/public/widgets/connections/connections-vpn.html index d9112e7..7da48de 100644 --- a/public/widgets/connections/connections-vpn.html +++ b/public/widgets/connections/connections-vpn.html @@ -61,7 +61,7 @@ diff --git a/public/widgets/connections/data.js b/public/widgets/connections/data.js index 7d5ddc3..2dabe99 100644 --- a/public/widgets/connections/data.js +++ b/public/widgets/connections/data.js @@ -103,6 +103,14 @@ function errorText(e) { return 'Unreachable'; } +function errorKindOf(e) { + if (e && e.name === 'SsrfBlockedError') return 'blocked'; + const code = e && e.code; + if (TIMEOUT_CODES.has(code)) return 'timeout'; + if (typeof code === 'string' && code) return 'network'; + return 'upstream'; +} + function authErr(r) { return r.status === 401 || r.status === 403; } @@ -166,11 +174,16 @@ async function vpnView(ctx) { ipRes = await fetchJSON(base + '/v1/publicip/ip', { headers, timeout: 7000 }); } catch (e) { out.error = errorText(e); + out.errorKind = errorKindOf(e); } if (ipRes) { - if (authErr(ipRes)) out.error = 'Auth required — set the API key'; - else if (ipRes.status >= 400) out.error = 'Control server HTTP ' + ipRes.status; - else { + if (authErr(ipRes)) { + out.error = 'Auth required — set the API key'; + out.errorKind = 'auth'; + } else if (ipRes.status >= 400) { + out.error = 'Control server HTTP ' + ipRes.status; + out.errorKind = 'upstream'; + } else { const d = ipRes.data || {}; out.ip = d.public_ip || d.ip || ''; out.city = d.city || ''; @@ -226,6 +239,7 @@ async function vpnView(ctx) { } } catch (e) { out.error = errorText(e); + out.errorKind = errorKindOf(e); } return out; @@ -358,9 +372,11 @@ async function mapView(ctx) { o.limit = 0; } else { o.error = 'Unsupported service type'; + o.errorKind = 'invalid'; } } catch (e) { o.error = errorText(e); + o.errorKind = errorKindOf(e); } return o; }), diff --git a/public/widgets/dashboard-switch/index.html b/public/widgets/dashboard-switch/index.html index 183fd8b..bf35a86 100644 --- a/public/widgets/dashboard-switch/index.html +++ b/public/widgets/dashboard-switch/index.html @@ -33,7 +33,7 @@