Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions public/js/widget-error.js
Original file line number Diff line number Diff line change
@@ -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 = '';
}
},
};
}
52 changes: 23 additions & 29 deletions public/js/widget-toolbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -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();
Expand Down Expand Up @@ -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. */
Expand All @@ -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,
Expand All @@ -325,22 +322,19 @@ 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) {
if (stopped) return;
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) : '' });
}
}

Expand Down Expand Up @@ -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() {
Expand Down
51 changes: 36 additions & 15 deletions public/widgets/backup/backup.html
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -115,7 +120,7 @@
<body>
<div class="widget" id="widget" role="group" aria-label="Backup status"></div>
<script type="module">
import { poll, fetchData, openUrl, html, setHtml, loadStrings, wt } from '/js/widget-toolbox.js?v=432c5712';
import { poll, fetchData, openUrl, html, setHtml, loadStrings, wt, errorState } from '/js/widget-toolbox.js?v=c27f1c81';
await loadStrings();
document.getElementById('widget').setAttribute('aria-label', wt('ui.backupStatus', 'Backup status'));
const id = new URLSearchParams(location.search).get('id') || '';
Expand Down Expand Up @@ -360,13 +365,30 @@
_pollSec = Math.max(10, parseInt(firstSlot?.dupPollSec || 60, 10));
buildShell();
/* The gap shrinks to 5s while any slot reports running. */
poll({ fetch: fetchJobs, render: apply, interval: d => (d?.some(j=>j&&j.status==='running') ? 5 : _pollSec) * 1000,
onError: ({ error, everOk, stale }) => { if (!everOk || stale) applyError({ error: error.message }); } });
const w = document.getElementById('widget');
const state = errorState({
root: w,
content: () => Array.from(w.querySelectorAll('.card-wrap')),
caption: document.getElementById('bk-cap'),
});

poll({ fetch: fetchJobs, interval: d => (d?.some(j=>j&&j.status==='running') ? 5 : _pollSec) * 1000,
render: d => { state.ok(); apply(d); },
onError: ({ error, everOk, stale, since }) => {
if (!everOk || stale) applyError(state.fail(error, { since, inert: everOk }));
} });
}

function buildShell() {
const w = document.getElementById('widget');
w.innerHTML = '';
w.replaceChildren();
const cards = document.createElement('div');
cards.id = 'cards';
const cap = document.createElement('div');
cap.className = 'wt-cap bk-cap';
cap.id = 'bk-cap';
cap.hidden = true;
w.append(cards, cap);

const slots = slotList();
const haikus = assignHaikus(slots);
Expand Down Expand Up @@ -516,7 +538,7 @@
front.appendChild(frontZone);

wrap.append(back, front);
w.appendChild(wrap);
cards.appendChild(wrap);

_canvases[idx] = { front: frontCanvas, back: backCanvas, haiku, seed };

Expand Down Expand Up @@ -587,20 +609,19 @@
back.setAttribute('aria-hidden','true');
}

function applyError(err) {
function applyError(line) {
slotList().forEach((_,idx)=>{
const back = document.getElementById(`back-${idx}`);
const front = document.getElementById(`front-${idx}`);
const bname = document.getElementById(`back-name-${idx}`);
const bmeta = document.getElementById(`back-meta-${idx}`);
const fname = document.getElementById(`front-name-${idx}`);
if(back){back.style.background=NAMED.red;back.style.color='#fff';}
if(front){ front.style.setProperty('--status-color',NAMED.red); front.setAttribute('role','img'); front.setAttribute('aria-label', `${wt('ui.backupError','Backup error')}: ${err?.error||wt('ui.unreachable','Unreachable')}`); }
if(back) back.setAttribute('aria-hidden','true');
if(bname) bname.textContent=wt('ui.error','Error');
/* err.error can carry upstream-derived text. html`` escapes it. */
if(bmeta) setHtml(bmeta, html`${err?.error||wt('ui.unreachable','Unreachable')}<br>&nbsp;`);
if(fname) fname.textContent=wt('ui.error','Error');
/* Set both. An unset colour inherits black onto a dark card. */
if(back){ back.style.background='rgba(255,255,255,0.06)'; back.style.color='#fff'; }
if(front){ front.setAttribute('role','img'); front.setAttribute('aria-label', line); }
if(bname) bname.textContent = line;
if(bmeta) setHtml(bmeta, html`&nbsp;<br>&nbsp;`);
if(fname) fname.textContent = '\u2014';
});
}

Expand Down
Loading