mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-10 18:34:18 +08:00
286 lines
14 KiB
JavaScript
286 lines
14 KiB
JavaScript
'use strict';
|
|
'require baseclass';
|
|
'require dom';
|
|
'require network';
|
|
'require fs-fit as fit';
|
|
|
|
/* 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 sectionTitle(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. */
|
|
const h = sec.querySelector('.cbi-title h3, :scope > h3');
|
|
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 '';
|
|
}
|
|
|
|
/* 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);
|
|
|
|
/* 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. */
|
|
|
|
return baseclass.extend({
|
|
/* called once by menu-footstrap-common's init; everything route-dependent hangs off the
|
|
* data-page observer inside */
|
|
wire,
|
|
});
|