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 @@
diff --git a/public/widgets/dns/index.html b/public/widgets/dns/index.html index c1a503a..edc60b4 100644 --- a/public/widgets/dns/index.html +++ b/public/widgets/dns/index.html @@ -43,30 +43,27 @@ .tip b { font-size:11px; font-weight:600; color:#fff; } .tip s { font-size:11px; font-weight:500; color:rgba(255,255,255,0.6); text-decoration:none; } - .msg { - position:absolute; inset:0; display:flex; align-items:center; justify-content:center; - font-size:11px; color:rgba(255,255,255,0.34); text-align:center; padding:0 16px; - } + .msg { position:absolute; inset-inline:0; bottom:0; } .sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; }
-
+
—
Queries Blocked
- +
diff --git a/public/widgets/github/i18n/de.json b/public/widgets/github/i18n/de.json index a6a0ac1..2e7aa84 100644 --- a/public/widgets/github/i18n/de.json +++ b/public/widgets/github/i18n/de.json @@ -18,10 +18,6 @@ "githubPrFilters.opt.mentioned": "Erwähnt", "githubPrFilters.opt.review-requested": "Review angefragt", "ui.prAria": "GitHub-Pull-Requests", - "ui.unreachable": "Nicht erreichbar", - "ui.notConfigured": "Nicht konfiguriert", - "ui.badToken": "Ungültiger Token", - "ui.error": "Fehler", "ui.noPrs": "Keine Pull Requests", "ui.contributionsAria": "GitHub-Beiträge: {total} im letzten Jahr", "ui.viewAll": "alle anzeigen" diff --git a/public/widgets/github/i18n/en.json b/public/widgets/github/i18n/en.json index 4b9a561..df6739d 100644 --- a/public/widgets/github/i18n/en.json +++ b/public/widgets/github/i18n/en.json @@ -18,10 +18,6 @@ "githubPrFilters.opt.mentioned": "Mentioned", "githubPrFilters.opt.review-requested": "Review requested", "ui.prAria": "GitHub pull requests", - "ui.unreachable": "Unreachable", - "ui.notConfigured": "Not configured", - "ui.badToken": "Bad token", - "ui.error": "Error", "ui.noPrs": "No pull requests", "ui.contributionsAria": "GitHub contributions: {total} in the last year", "ui.viewAll": "view all" diff --git a/public/widgets/github/i18n/es.json b/public/widgets/github/i18n/es.json index 0a48149..0ffc482 100644 --- a/public/widgets/github/i18n/es.json +++ b/public/widgets/github/i18n/es.json @@ -18,10 +18,6 @@ "githubPrFilters.opt.mentioned": "Mencionado", "githubPrFilters.opt.review-requested": "Revisión solicitada", "ui.prAria": "Pull requests de GitHub", - "ui.unreachable": "Inaccesible", - "ui.notConfigured": "Sin configurar", - "ui.badToken": "Token no válido", - "ui.error": "Error", "ui.noPrs": "No hay pull requests", "ui.contributionsAria": "Contribuciones de GitHub: {total} en el último año", "ui.viewAll": "ver todo" diff --git a/public/widgets/github/i18n/fa.json b/public/widgets/github/i18n/fa.json index 374248a..b731ce8 100644 --- a/public/widgets/github/i18n/fa.json +++ b/public/widgets/github/i18n/fa.json @@ -18,10 +18,6 @@ "githubPrFilters.opt.mentioned": "نام‌برده‌شده", "githubPrFilters.opt.review-requested": "درخواست بازبینی", "ui.prAria": "درخواست‌های ادغام گیت‌هاب", - "ui.unreachable": "در دسترس نیست", - "ui.notConfigured": "پیکربندی نشده", - "ui.badToken": "توکن نامعتبر", - "ui.error": "خطا", "ui.noPrs": "درخواست ادغامی نیست", "ui.contributionsAria": "مشارکت‌های گیت‌هاب: {total} در سال گذشته", "ui.viewAll": "مشاهده همه" diff --git a/public/widgets/github/i18n/fr.json b/public/widgets/github/i18n/fr.json index aa99fdd..064700f 100644 --- a/public/widgets/github/i18n/fr.json +++ b/public/widgets/github/i18n/fr.json @@ -18,10 +18,6 @@ "githubPrFilters.opt.mentioned": "Mentionné", "githubPrFilters.opt.review-requested": "Revue demandée", "ui.prAria": "Pull requests GitHub", - "ui.unreachable": "Injoignable", - "ui.notConfigured": "Non configuré", - "ui.badToken": "Jeton invalide", - "ui.error": "Erreur", "ui.noPrs": "Aucune pull request", "ui.contributionsAria": "Contributions GitHub : {total} sur la dernière année", "ui.viewAll": "tout afficher" diff --git a/public/widgets/github/i18n/zh-Hans.json b/public/widgets/github/i18n/zh-Hans.json index c348cff..0a0bf10 100644 --- a/public/widgets/github/i18n/zh-Hans.json +++ b/public/widgets/github/i18n/zh-Hans.json @@ -18,10 +18,6 @@ "githubPrFilters.opt.mentioned": "提到我的", "githubPrFilters.opt.review-requested": "请求我评审的", "ui.prAria": "GitHub 拉取请求", - "ui.unreachable": "无法连接", - "ui.notConfigured": "尚未配置", - "ui.badToken": "令牌无效", - "ui.error": "错误", "ui.noPrs": "没有拉取请求", "ui.contributionsAria": "GitHub 贡献:过去一年 {total} 次", "ui.viewAll": "查看全部" diff --git a/public/widgets/github/pullrequests.html b/public/widgets/github/pullrequests.html index 6e40513..c974b4d 100644 --- a/public/widgets/github/pullrequests.html +++ b/public/widgets/github/pullrequests.html @@ -40,6 +40,8 @@ .pr-title{font-weight:500;color:rgba(255,255,255,0.88);white-space:nowrap;overflow:hidden; text-overflow:ellipsis;line-height:1.3;} + .pr-cap{flex:none;padding:2px 2px 1px;} + .widget.size-list .pr-cap{flex:1;justify-content:center;padding:0;} .msg{flex:1;display:flex;align-items:center;justify-content:center; font-size:11px;color:rgba(255,255,255,0.28);text-align:center;} @@ -64,7 +66,7 @@
diff --git a/public/widgets/nowplaying/index.html b/public/widgets/nowplaying/index.html index c87d4ea..69a50eb 100644 --- a/public/widgets/nowplaying/index.html +++ b/public/widgets/nowplaying/index.html @@ -41,6 +41,10 @@ .sub{color:#48484A;margin-top:auto} .player{color:#8E8E93} .idle .title{color:#636366;font-weight:600} + .sticker{--wt-cap-color:#48484A} + .np-cap{flex:none;margin-top:auto} + /* .title sets display. Without this rule the hidden attribute does nothing. */ + .sticker [hidden]{display:none} .window{position:absolute;left:14px;right:14px;top:94px;bottom:14px; background:radial-gradient(135% 135% at 50% 20%,#636366,#48484A 58%,#48484A); @@ -94,6 +98,7 @@
Nothing playing
+
diff --git a/src/content/docs/docs/troubleshooting.md b/src/content/docs/docs/troubleshooting.md index b469f24..4e22c2e 100644 --- a/src/content/docs/docs/troubleshooting.md +++ b/src/content/docs/docs/troubleshooting.md @@ -80,7 +80,7 @@ Stackyard names the items to re-enter. Enter the credential again and save. ## Widgets -### A widget says "Not configured" or shows an error instead of data +### A widget says "Not set up yet" or shows an error instead of data Check three things in order. @@ -88,9 +88,13 @@ Check three things in order. 2. That the container can reach the URL you entered. Right network, right port, no firewall in between. 3. For an HTTPS service with a self-signed certificate, the TLS-skip option. -### A widget briefly shows "Unavailable" then recovers +### A widget dims and shows a line instead of its data -Widgets keep the last good reading through a transient failure and only surface an error after repeated failures. A flash that clears on its own means one poll timed out. +The widget keeps its layout, fades what it was showing, and puts one line where its own labels are. The line names the kind of failure, not the service's own message. See [Messages a widget shows](#messages-a-widget-shows). + +Widgets keep the last good reading through a transient failure and only surface a line after repeated failures. A line that clears on its own means one poll timed out. + +A widget that reached its service and got nothing back stays at full brightness and says so, for example `No data`. That is not a failure. ### Settings unavailable, or a widget is missing from the list @@ -200,6 +204,23 @@ nginx allows 3 MB, above both, so the API is always the component that refuses a The health check runs through nginx to the API, so it covers both processes. Check the logs for either failing to start. A data volume the container's `node` user cannot write to is a common cause. +## Messages a widget shows + +One line, in place of the widget's data. Each names a kind of failure rather than repeating what the service said, because a service's own message names hosts, ports and status codes. + +| Message | Meaning | Where to look | +| --- | --- | --- | +| `Out of reach` | Nothing answered at the address. | The URL, the network, the port. | +| `Took too long` | The service answered too slowly. | The service's own load, or a link that drops packets. | +| `Key rejected` | The credential was refused. | The API key or token, and its scopes. | +| `Request blocked` | Stackyard refused to make the request. | A private address with `ALLOW_PRIVATE_IPS` unset. See [Security](/docs/security/). | +| `Not set up yet` | The widget has no address or credential yet. | The widget's own settings. | +| `Service error` | The service answered with an error. | The service's logs. | +| `Something went wrong` | Anything else. | The container logs. | +| `No data` | The service answered, and had nothing to report. | Nothing. This is not a failure. | + +The Disk health widget marks a single bay `Not reporting` when the drive it names is no longer in the monitoring service's list. The other bays are unaffected. + ## Messages you might see Messages shown in the admin, and what each one means. diff --git a/src/content/docs/docs/widgets/github.mdx b/src/content/docs/docs/widgets/github.mdx index 2c4340e..7e0bbad 100644 --- a/src/content/docs/docs/widgets/github.mdx +++ b/src/content/docs/docs/widgets/github.mdx @@ -47,5 +47,5 @@ The `repo` scope is what lets the widget see pull requests in private repositori The Contributions view reads GitHub's GraphQL API, which classic tokens support in full. A fine-grained token works if you grant it equivalent read access. -The token is stored as a secret. It is kept on the server and never sent back to the browser. If the widget shows **Bad token**, the value is wrong, expired, or missing a scope. +The token is stored as a secret. It is kept on the server and never sent back to the browser. If the widget shows **Key rejected**, the value is wrong, expired, or missing a scope.