summaryrefslogtreecommitdiffstats
path: root/themes/luci-theme-footstrap/htdocs/luci-static/resources/fs-overview.js
blob: 50ed59d41e90909ef02b622b9db1c3a0e6f2f3eb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
'use strict';
'require baseclass';
'require dom';
'require network';
'require fs-fit as fit';
'require menu-footstrap-common as common';

/* Overview layout only: renders nothing of its own, it re-arranges the STOCK System / Memory /
 * Storage sections into a grid. Content, data and styling stay luci-mod-status's — rendering a
 * custom tree every poll instead (the old 05_footstrap_dashboard.js) flickered and reset mobile
 * scroll. The stock poll fills each section in place via dom.content() and never rebuilds the
 * .cbi-section wrapper, so the wrappers stay inside our grid across polls.
 *
 * This must NOT be filed in LuCI's global include dir (view/status/include/): luci-mod-status
 * evaluates every *.js there, so the file would be fetched and run on routers using another theme,
 * with an `L.env.media` gate only silencing it after the fact. As a chrome module it is
 * unreachable except through this theme's footer partial, which is why nothing below re-checks
 * `L.env.media`. That location also supplied two timing guarantees for free, by evaluating inside
 * index.load(); both are paid for explicitly here — patchOverview() below, and
 * ensureOverviewHelpers() in menu-footstrap-common.js. */
/* section title -> grid role. _() with no msgctxt on purpose: these must resolve to exactly what
 * luci-mod-status resolves to, or the titles stop matching. Built once, not per poll tick. */
const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto' };
/* the data-page value four call sites compare against; a string literal is not mangled, so a
 * repeat is paid in full on flash every time (measured: 24 B x4 -> 37 B, 59 B saved) */

function headerEl(sec) {
	/* two title markups, one per release: 25.12 wraps the heading (`.cbi-title > h3`), 24.10 emits
	 * a bare `<h3>` as the section's first child. Matching only one silently disables the grid on
	 * the other. */
	return sec.querySelector('.cbi-title h3, :scope > h3');
}

function sectionTitle(sec) {
	const h = headerEl(sec);
	if (!h) return '';
	/* the first non-empty TEXT node, not `firstChild`: 25.12 appends a hide/show <span> inside the
	 * same <h3>, so `firstChild` depends on upstream keeping the words first */
	for (const n of h.childNodes) {
		if (n.nodeType !== 3) continue;
		const t = String(n.nodeValue || '').trim();
		if (t) return t;
	}
	return '';
}

/* ---- keyboard disclosure: the card header becomes the toggle, the pill becomes its glyph ----
 *
 * Live on owrt2512 (25.12.4), Status -> Overview carries 14 `[data-clickable]` elements — the
 * topbar poll pill plus one Hide/Show toggle per card — and every one is a bare <span>: no
 * tabindex, no role, no aria-expanded. Tab never reaches one and a screen reader announces a run
 * of text with no name, role or state. WCAG 2.1.1 Keyboard (A), 4.1.2 Name, Role, Value (A).
 * docs/findings.md, "A card cannot be collapsed from the keyboard".
 *
 * index.js's own pill keeps doing the actual show/hide — untouched, so mouse behaviour for anyone
 * clicking it does not change. The header becomes a second, W3C-APG way to reach the SAME handler,
 * not a competing one. */

/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM and fires
 * no mutation record. Same shape as `fsSyncAttr` in menu-footstrap-common.js — restated rather than
 * imported, since that file does not export it. */
function syncAttr(el, name, value) {
	if (value === null) {
		if (el.hasAttribute(name)) el.removeAttribute(name);
	} else if (el.getAttribute(name) !== value) {
		el.setAttribute(name, value);
	}
}

/* index.js's own attribute: "inactive" is expanded (the pill reads "Hide"), "active" is collapsed
 * (it reads "Show") — the chevron mirror in pages/20-overview.css reads the same attribute the
 * same way. */
function pillExpanded(label) {
	return label.getAttribute('data-style') !== 'active';
}

/* The panel `data-style` shows or hides is `.cbi-title`'s next sibling in the 25.12 markup this was
 * measured on. 24.10 emits no `.cbi-title` wrapper (headerEl() above) and is left without
 * aria-controls rather than guessed at — role, tabindex, aria-expanded and the keyboard below still
 * apply there; aria-controls is the one piece this function cannot state with confidence. */
function panelFor(h) {
	const holder = h.closest('.cbi-title');
	return holder ? holder.nextElementSibling : null;
}

let _panelSeq = 0;

/* Promote one card's header to a disclosure control. Idempotent: a tick that finds the header
 * already wired touches only aria-expanded, and only when it actually changed; the click/keydown
 * listeners are added once (`dataset.fsWired`), never re-added. */
function wireDisclosure(sec) {
	const h = headerEl(sec);
	const label = h && h.querySelector('.label[data-indicator="poll-status"]');
	if (!h || !label) return;	/* not this card's shape — left alone rather than guessed at */

	const panel = panelFor(h);
	if (panel && !panel.id) panel.id = 'fs-ovl-panel-' + (_panelSeq++);

	syncAttr(h, 'role', 'button');
	syncAttr(h, 'tabindex', '0');
	syncAttr(h, 'aria-controls', panel ? panel.id : null);
	syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
	/* the header now carries the pill's name, role and state; a screen reader user tabbing past it
	 * to a second, unlabelled clickable span would hear an unexplained duplicate control — same
	 * reasoning as the aria-hidden on svgIcon()'s output, fs-widgets.js:12 */
	syncAttr(label, 'aria-hidden', 'true');

	if (h.dataset.fsWired) return;
	h.dataset.fsWired = '1';

	h.addEventListener('click', (ev) => {
		/* a click landing on the pill itself already ran index.js's own handler; forwarding here
		 * too would toggle the card twice */
		if (ev.target.closest?.('[data-indicator="poll-status"]') !== label) label.click();
		syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
	});
	/* the pill is an <a>-less <span>, so neither key is native here — contrast
	 * fs-widgets.js's wireSpaceKey, written for an <a role="button">, which gets Enter for free
	 * and needs only Space added */
	h.addEventListener('keydown', (ev) => {
		if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
		ev.preventDefault();
		label.click();
	});
}

/* A `.cbi-section` LuCI still renders when a stock include has nothing to show this tick: title
 * "-", the poll pill its only content, 72px tall on the live Overview — seventh there (a fixture is
 * not proof of position: docs/playground.html puts it first). Real and cosmetic, so it is
 * suppressed rather than left as a rung in the tab order with nothing behind its own name. */
function hideEmptyCard(sec) {
	if (sectionTitle(sec) === '-') sec.classList.add('fs-ovl-empty');
}

function tidyCards(view) {
	view.querySelectorAll('.cbi-section').forEach((sec) => {
		hideEmptyCard(sec);
		wireDisclosure(sec);
	});
}

/* the wrapper we built, so the poll-tick fast path costs one property read */
let _wrapEl = null;

/* A port name the card had to cut stays readable on hover: styles/pages/20-overview.css ellipses
 * it at one line so every card takes the width the row can spare, and the name is the one thing on
 * a card that cannot be guessed from the rest.
 *
 * The tooltip is set unconditionally: testing `scrollWidth` against `clientWidth` per card would
 * force a synchronous layout on every poll tick, since 29_ports.js rebuilds these tiles each time.
 *
 * Runs BEFORE arrange()'s fast path, which returns as soon as the grid is intact while the tiles
 * under it are new elements. */
function nameTooltips(view) {
	for (const icon of view.querySelectorAll('img[src*="/port_"]')) {
		const head = icon.closest('.ifacebox')?.firstElementChild;
		const name = head ? head.textContent.trim() : '';
		/* `!==`: a mutation inside the tree we observe is not cheap, even when the write is */
		if (name && head.title !== name)
			head.title = name;
	}
}

function arrange() {
	/* an SPA nav can leave the observer wired while another page renders into #view: detach as soon
	 * as the route stops being the overview. body[data-page] carries the DISPATCH path from both
	 * the server template and the router, so /admin/status (firstchild -> overview) matches. */
	if ((document.body.getAttribute('data-page') || '') !== /* spelled out, not hoisted: tools/page-modules.mjs reads this value out of the module's
	 * SOURCE to check it against the map in menu-footstrap-common.js */
	'admin-status-overview') {
		stopWatch();
		return;
	}
	const view = document.getElementById('view');
	if (!view) return;

	nameTooltips(view);
	/* 20_memory.js, 25_storage.js and 30_network.js each call their OWN local progressbar(), not
	 * the theme's window.progressbar (menu-footstrap-common.js), so the reading and colour those
	 * bars need are stamped on here instead — on the same poll-tick callback nameTooltips() already
	 * runs on, ahead of the fast-path return below: dom.content() rewrites a section's title every
	 * tick even when the wrapper survives, so a bar's reading is stale on every tick this line is
	 * skipped. */
	common.annotateMeters(view);
	/* Same reasoning, same placement: dom.content() rewrites a card's body every tick even when the
	 * wrapper survives, so a card that is only now getting its title text needs wiring on this
	 * pass, not just the first one. tidyCards() is its own idempotent write (wireDisclosure(),
	 * hideEmptyCard() above), so a tick that changes nothing here touches no DOM either. */
	tidyCards(view);

	/* Fast path: the poll lands here on every tick, forever, and the stock poll never rebuilds
	 * the .cbi-section wrappers, so the grid survives. Deliberately not a disconnect() — if a
	 * future luci-mod-status does rebuild a section, the wrapper loses its children and the slow
	 * path below rebuilds the grid. */
	if (_wrapEl && _wrapEl.isConnected && _wrapEl.parentElement === view && _wrapEl.children.length === 3)
		return;

	const found = {};
	view.querySelectorAll(':scope > .cbi-section').forEach((sec) => {
		const r = ROLES[sectionTitle(sec)];
		if (r && !found[r]) found[r] = sec;
	});
	/* wait until all three stock sections exist */
	if (!(found.sys && found.mem && found.sto)) return;
	/* already wrapped? (first tick after a rebuild re-finds the existing grid) */
	if (found.sys.parentElement && found.sys.parentElement.classList.contains('fs-ovl')) {
		_wrapEl = found.sys.parentElement;
		return;
	}
	const wrap = document.createElement('div');
	wrap.className = 'fs-ovl';
	found.sys.parentNode.insertBefore(wrap, found.sys);
	found.sys.classList.add('fs-ovl-sys'); wrap.appendChild(found.sys);
	found.mem.classList.add('fs-ovl-mem'); wrap.appendChild(found.mem);
	found.sto.classList.add('fs-ovl-sto'); wrap.appendChild(found.sto);
	_wrapEl = wrap;
}

/* Stock sections render async and repaint every poll, so watch #view and re-run arrange(),
 * coalesced and one observer per #view node. The SPA router may replace #view between visits, so
 * re-attach when the observed node is no longer the current one — a singleton bound to the first
 * #view would watch a detached tree and the grid would never apply again. */
let _observer = null, _observedRoot = null, _routeObserver = null;
function stopWatch() {
	if (_observer) _observer.disconnect();
	_observer = null;
	_observedRoot = null;
	_wrapEl = null;	/* the grid belongs to the #view we are leaving */
}
function watch() {
	const view = document.getElementById('view');
	/* `#maincontent`, not `#view`: a client navigation builds a fresh `#view` before it is in the
	 * document and swaps it in afterwards, and this runs on the `data-page` stamp, which comes
	 * first — so the observer bound here read `isConnected: false` after one round trip and the
	 * grid stopped being re-arranged on every poll tick, silently, until the next full load. The
	 * shell's column outlives every swap. Same fault, same fix as fs-appearance.js. */
	const root = document.getElementById('maincontent') || view;
	if (_observer && _observedRoot !== root)
		stopWatch();
	arrange();
	/* a chrome module is alive on every page, so without the route check an observer would attach
	 * to #view on, say, the firewall page and re-run arrange() for every table mutation */
	if (_observer || !view ||
	    (document.body.getAttribute('data-page') || '') !== 'admin-status-overview')
		return;
	_observedRoot = root;
	/* one arrange() per frame, however many mutations a poll tick delivers (fit.frame — the
	 * theme's shared coalescer, fs-fit.js) */
	_observer = new MutationObserver(fit.frame(arrange));
	_observer.observe(root, { childList: true, subtree: true });
}

/* A chrome module is instantiated once per page load, so it has to notice SPA navigation itself.
 * `body[data-page]` is the signal — the server template and fs-router both stamp it with the
 * dispatch path — so one attribute observer covers arriving, leaving and coming back. */
function wire() {
	if (_routeObserver || !document.body)
		return;
	_routeObserver = new MutationObserver(() => {
		if ((document.body.getAttribute('data-page') || '') === 'admin-status-overview')
			onOverview();
		else
			stopWatch();
	});
	_routeObserver.observe(document.body, { attributes: true, attributeFilter: [ 'data-page' ] });
	if ((document.body.getAttribute('data-page') || '') === 'admin-status-overview')
		onOverview();
}

/* Arrival at the overview, from a full page load or an SPA navigation. patchOverview() is
 * idempotent (the __fsProgressive flag), so the two paths cannot double-patch. */
function onOverview() {
	patchOverview();
	watch();
}

/* ---- progressive paint ----
 *
 * Stock `view.status.index` calls poll_status(first_load=true), which Promise.all's over every
 * include's load(), and render() withholds the tree until it resolves — #view stays empty for as
 * long as the slowest include takes (measured: 182 ms, of which most sections were ready at 88 ms
 * and waiting on 29_ports and 60_wifi).
 *
 * Replacing poll_status does two things:
 *  1. each section paints when its own data lands (182 -> ~90 ms). Nothing jumps: the frames are
 *     already in the DOM, a section goes hidden -> filled as on any poll tick;
 *  2. drops the redundant re-fetch — stock adds the poller after the first load and Poll.add()
 *     steps at once, re-fetching everything (~250 ms of ubus) right after the first paint.
 *
 * Not a re-implementation: frames, toggles, includes and their render() stay upstream's.
 * fillSection() transcribes stock's loop in the same order so it can be diffed against index.js;
 * if that shape is gone, the patch is skipped and the page runs stock. */
function fillSection(inc, container, res) {
	if (inc.failed)
		return;
	let content = null;
	if (typeof inc.render === 'function')
		content = inc.render(res);
	else if (inc.content != null)
		content = inc.content;
	if (typeof inc.oneshot === 'function') {
		inc.oneshot(res);
		inc.oneshot = null;
	}
	if (content != null) {
		container.parentNode.style.display = '';
		container.parentNode.classList.add('fade-in');
		if (!inc.hide)
			dom.content(container, content);
	}
}

let _inflight = null;
/* Which containers the in-flight run is filling. The guard is module-level because the duplicate
 * load it kills is, but frames are per render: joining a run blindly joins one filling somebody
 * else's frames, and the second arrival's sections then stay at `display:none` for a full poll
 * interval (5.9 s against 0.4 s). */
let _inflightFor = null;

function pollProgressive(includes, containers, first_load) {
	/* a run already fetching this data for THESE frames is joined rather than duplicated; a run for
	 * older frames is left to finish into the detached nodes it owns */
	if (_inflight && _inflightFor === containers)
		return first_load ? Promise.resolve() : _inflight;

	const run = network.flushCache().then(() => Promise.all(
		includes.map((inc, i) => {
			if (inc.hide && !first_load)
				return null;
			const loaded = (typeof inc.load === 'function')
				? Promise.resolve(inc.load()).catch(() => { inc.failed = true; })
				: Promise.resolve(null);
			/* the point of the patch: fill this section when its own data lands, not at the
			 * end of a Promise.all over all of them */
			return loaded.then((res) => {
				try { fillSection(inc, containers[i], res); }
				catch (e) { console.error('footstrap: overview section failed', e); }
			});
		}).filter(Boolean)
	)).then(() => {
		const ssi = document.querySelector('div.includes');
		if (ssi) { ssi.style.display = ''; ssi.classList.add('fade-in'); }
	});

	_inflight = run.finally(() => {
		/* only if still ours: a newer render may have replaced it mid-run */
		if (_inflightFor === containers) { _inflight = null; _inflightFor = null; }
	});
	_inflightFor = containers;
	/* Nobody awaits this on the first load (the caller gets a fresh Promise.resolve()), so a
	 * rejection would surface as an unhandled one. `run` rejects for one ordinary reason:
	 * flushCache() on an expired session, when the user is already being redirected to login.
	 * Section failures cannot reach it — fillSection runs in a try/catch and inc.load() has its
	 * own .catch. */
	_inflight.catch(() => {});

	/* first load resolves now, so index.render() returns its tree and the frames reach #view while
	 * the sections fill themselves; a poll tick resolves when its data is in, as the poller
	 * expects */
	return first_load ? Promise.resolve() : _inflight;
}

/* Patch the stock overview view: replace poll_status so each section paints when its own data
 * lands.
 *
 * Called from the route (wire()), not at module eval: requiring 'view.status.index' at eval would
 * pull the whole stock view into memory on every page, and on a full load it would race
 * index.load(). Hence the `__fsProgressive` guard and the fact that missing the window is
 * harmless — the page then renders the stock way, one Promise.all, ~90 ms later. */
function patchOverview() {
	/* `window.L`, never the bare `L` this factory was handed. require() passes the object it was
	 * called on into the loaded module's factory, and index.js loads its own includes with that
	 * same `L` — 30_network.js then calls `L.itemlist(...)`, which lives on the runtime instance
	 * (`window.L = new LuCI()`), not on the prototype a chrome module receives. require() caches
	 * by class name, so the first caller decides this for everybody: through the bare `L` the
	 * overview dies mid-render on "L.itemlist is not a function", stuck on "Loading view…" (issue
	 * #22 follow-up). docs/spa-router.md. */
	window.L.require('view.status.index').then((idx) => {
		const proto = idx ? Object.getPrototypeOf(idx) : null;
		if (!proto || proto.__fsProgressive || typeof proto.poll_status !== 'function')
			return;
		proto.__fsProgressive = true;
		proto.poll_status = function(includes, containers, first_load) {
			return pollProgressive(includes, containers, first_load);
		};
	}).catch((e) => console.error('footstrap: overview progressive paint not applied', e));
}

/* `progressbar`, `renderBox` and `renderBadge` are defined in menu-footstrap-common.js, not here:
 * a stock include calls them bare from its own render(), so they must exist before the view class
 * does, and this page module is required during the navigation that races it. `annotateMeters` is
 * required from that same module (see arrange()) rather than duplicated: it is not a template
 * global, so nothing forces it to live there, but the threshold split and label lookup it shares
 * with `window.progressbar` would drift into two copies otherwise. */

return baseclass.extend({
	/* called once by menu-footstrap-common's init; everything route-dependent hangs off the
	 * data-page observer inside */
	wire,
});