mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-07 20:54:20 +08:00
315 lines
12 KiB
JavaScript
315 lines
12 KiB
JavaScript
'use strict';
|
|
'require baseclass';
|
|
'require dom';
|
|
'require network';
|
|
'require fs-fit as fit';
|
|
'require fs-chrome as chrome';
|
|
|
|
/* Overview layout and meters: re-arranges the stock System / Memory / Storage sections into a grid,
|
|
* marks the meters up (aria, and data-fs-level warn/danger at the FS_METER_* thresholds), turns
|
|
* card headings into disclosure buttons and draws progressively through a wrapped poll_status.
|
|
* Content and data stay luci-mod-status's. */
|
|
|
|
/* the data-page value this module acts on; the loader's PAGE_MODULES map must name the same page */
|
|
const PAGE = 'admin-status-overview';
|
|
const onPage = () => document.body.getAttribute('data-page') === PAGE;
|
|
const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto' };
|
|
|
|
/* warn/danger split for the meter fill (theme/25-progressbar.css): a decision, not a measurement —
|
|
* 20% of an 84 MiB overlay and 20% of an 8 GiB disk are different news at the same reading. */
|
|
const FS_METER_WARN = 80;
|
|
const FS_METER_DANGER = 92;
|
|
/* Row labels this theme has seen misread, matched like `ROLES`: `_(msgid)` against
|
|
* luci-mod-status's own msgids, no msgctxt. */
|
|
const FS_METER_INVERTED = new Set([ _('Total Available'), _('Swap free') ]);
|
|
const FS_METER_NEUTRAL = new Set([ _('Buffered'), _('Cached') ]);
|
|
|
|
/* The meter's name, if the markup already states one — never invented: a `.cbi-value` row's own
|
|
* label or a key/value table row's first cell. */
|
|
function findProgressbarLabel(pg) {
|
|
const row = pg.closest('.cbi-value');
|
|
let label = row ? row.querySelector('.cbi-value-title') : null;
|
|
if (!label) {
|
|
const tr = pg.closest('.tr');
|
|
if (tr) {
|
|
const own = pg.closest('.td, td');
|
|
const cell = tr.querySelector('.td, td');
|
|
if (cell && cell !== own) label = cell;
|
|
}
|
|
}
|
|
return label;
|
|
}
|
|
|
|
/* The percentage inside a meter's `title`: `'X / Y (Z%)'` or a bare `'97%'`, both end-anchored so
|
|
* a stray "%" earlier in a localised reading is ignored. */
|
|
function parseMeterPercent(title) {
|
|
const m = (/\((-?\d+)%\)\s*$/).exec(title) || (/(-?\d+)%\s*$/).exec(title);
|
|
return m ? parseInt(m[1], 10) : null;
|
|
}
|
|
|
|
/* The attribute work for one `.cbi-progressbar`: Overview's 20_memory.js, 25_storage.js and
|
|
* 30_network.js declare and call their own local `progressbar()`, so the theme's
|
|
* window.progressbar never stamps them. */
|
|
function annotateMeter(pg) {
|
|
const title = pg.getAttribute('title');
|
|
const pc = parseMeterPercent(title);
|
|
if (pc == null) return;
|
|
const level = pc < 0 ? 0 : (pc > 100 ? 100 : pc);
|
|
chrome.syncAttr(pg, 'role', 'progressbar');
|
|
chrome.syncAttr(pg, 'aria-valuemin', '0');
|
|
chrome.syncAttr(pg, 'aria-valuemax', '100');
|
|
chrome.syncAttr(pg, 'aria-valuenow', String(level));
|
|
chrome.syncAttr(pg, 'aria-valuetext', title);
|
|
const label = findProgressbarLabel(pg);
|
|
const name = label ? label.textContent.trim() : '';
|
|
chrome.syncAttr(pg, 'aria-label', label ? name : null);
|
|
/* Polarity: an unrecognised bar keeps the plain fill-based rule, on purpose: a wrong red is worse
|
|
* than a missing colour. */
|
|
let dataLevel = null;
|
|
if (!FS_METER_NEUTRAL.has(name)) {
|
|
const v = FS_METER_INVERTED.has(name) ? 100 - level : level;
|
|
dataLevel = v >= FS_METER_DANGER ? 'danger' : (v >= FS_METER_WARN ? 'warn' : null);
|
|
}
|
|
chrome.syncAttr(pg, 'data-fs-level', dataLevel);
|
|
}
|
|
|
|
function annotateMeters(root) {
|
|
root.querySelectorAll('.cbi-progressbar[title]').forEach(annotateMeter);
|
|
}
|
|
|
|
function headerEl(sec) {
|
|
return sec.querySelector('.cbi-title h3, :scope > h3');
|
|
}
|
|
|
|
function sectionTitle(sec) {
|
|
const h = headerEl(sec);
|
|
if (!h) return '';
|
|
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 ----
|
|
* On 25.12 Status -> Overview carries 14 `[data-clickable]` bare <span>s (the topbar poll pill plus
|
|
* one Hide/Show pill per card), clickable by mouse only. */
|
|
|
|
function pillExpanded(label) {
|
|
return label.getAttribute('data-style') !== 'active';
|
|
}
|
|
|
|
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 it wired
|
|
* touches only aria-expanded. */
|
|
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++);
|
|
|
|
chrome.syncAttr(h, 'role', 'button');
|
|
chrome.syncAttr(h, 'tabindex', '0');
|
|
chrome.syncAttr(h, 'aria-controls', panel ? panel.id : null);
|
|
chrome.syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
|
|
chrome.syncAttr(label, 'aria-hidden', 'true');
|
|
|
|
if (h.dataset.fsWired) return;
|
|
h.dataset.fsWired = '1';
|
|
|
|
h.addEventListener('click', (ev) => {
|
|
if (ev.target.closest?.('[data-indicator="poll-status"]') !== label) label.click();
|
|
chrome.syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
|
|
});
|
|
chrome.wireActivate(h, () => 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. */
|
|
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: the stylesheet ellipses it at one line,
|
|
* and the name cannot be guessed from the rest of the card. */
|
|
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() {
|
|
if (!onPage()) {
|
|
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, so the reading and colour those bars need are stamped here. */
|
|
annotateMeters(view);
|
|
tidyCards(view);
|
|
|
|
/* Fast path: the poll lands here on every tick, and the stock poll never rebuilds the .cbi-section
|
|
* wrappers, so the grid survives. Deliberately not a disconnect(). */
|
|
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;
|
|
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 #maincontent and re-run arrange(),
|
|
* coalesced. #maincontent outlives every SPA swap, so one observer is attached once. */
|
|
let _observer = null;
|
|
function stopWatch() {
|
|
if (_observer) _observer.disconnect();
|
|
_observer = 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, so an observer on it would watch a detached node. */
|
|
const root = document.getElementById('maincontent');
|
|
arrange();
|
|
if (_observer || !view)
|
|
return;
|
|
_observer = new MutationObserver(fit.frame(arrange));
|
|
_observer.observe(root, { childList: true, subtree: true });
|
|
}
|
|
|
|
/* Called by the loader on every arrival at the overview, full load or SPA navigation;
|
|
* patchOverview() and watch() are idempotent. */
|
|
function wire() {
|
|
if (!onPage()) {
|
|
stopWatch();
|
|
return;
|
|
}
|
|
patchOverview();
|
|
watch();
|
|
}
|
|
|
|
/* ---- progressive paint ----
|
|
* Stock `view.status.index` calls poll_status(first_load=true), which Promise.all's over every
|
|
* include's load(): #view stays empty until the slowest is in (182 ms, most sections). */
|
|
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;
|
|
let _inflightFor = null;
|
|
|
|
function pollProgressive(includes, containers, first_load) {
|
|
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);
|
|
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, so a rejection would surface as an unhandled one; `run`
|
|
* rejects for one ordinary reason: flushCache() on an expired session. */
|
|
_inflight.catch(() => {});
|
|
|
|
/* first load resolves now, so index.render() returns its tree 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 wire(), not at module eval, which would pull the whole status view in on
|
|
* every page. */
|
|
function patchOverview() {
|
|
/* `window.L`, never the bare `L` this factory was handed: index.js loads its own includes with the
|
|
* `L` require() passes the loaded module (the two-L trap). */
|
|
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.js: a stock
|
|
* include calls them bare from its own render(), so they must exist before the view class does. */
|
|
|
|
return baseclass.extend({
|
|
/* called by menu-footstrap's loader on every data-page stamp that maps here */
|
|
wire,
|
|
});
|