mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-31 08:35:13 +08:00
This commit is contained in:
1 parent
b71e04d39a
commit
c4ac5442bc
11 files changed
+857
-32
No files matched your search
@@ -154,6 +154,7 @@ const GEOM_DFLT = { contentMin: 500, sidebarW: 224, railW: 68, contentPad: 56 };
|
||||
let _geom = null, _geomDensity = null, _geomWarned = false;
|
||||
function shellGeometry() {
|
||||
const density = document.documentElement.getAttribute('data-density') || '';
|
||||
/* the gutter is re-asked even on the memo hit: it moves with the WIDTH, not with the density */
|
||||
if (_geom && _geomDensity === density) return _geom;
|
||||
_geomDensity = density;
|
||||
const px = (name, dflt) => resolveLen(name, dflt);
|
||||
@@ -161,7 +162,9 @@ function shellGeometry() {
|
||||
contentMin: px('--fs-content-min', GEOM_DFLT.contentMin),
|
||||
sidebarW: px('--fs-sidebar-w', GEOM_DFLT.sidebarW),
|
||||
railW: px('--fs-rail-w', GEOM_DFLT.railW),
|
||||
/* the token is ONE side's padding; the column loses it twice */
|
||||
/* the token is ONE side's padding; the column loses it twice. Kept as `tokenPad` because it
|
||||
* is only the FALLBACK — the gutter that counts is measured, see measureShell() */
|
||||
tokenPad: px('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2,
|
||||
contentPad: px('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2
|
||||
};
|
||||
/* Plausibility, and it costs one comparison: the rail IS the sidebar collapsed, so
|
||||
@@ -183,21 +186,67 @@ function shellGeometry() {
|
||||
+ 'renamed, or a foreign sheet is reaching the measurement probe.');
|
||||
}
|
||||
_geom = sane ? g : Object.assign({}, GEOM_DFLT);
|
||||
if (_geom.tokenPad === undefined) _geom.tokenPad = _geom.contentPad;
|
||||
/* the token is only the fallback; the gutter that counts is the one measureShell() read */
|
||||
if (_shellPad != null) _geom.contentPad = _shellPad;
|
||||
return _geom;
|
||||
}
|
||||
|
||||
/* THE GUTTER IS MEASURED WHERE IT IS APPLIED, not read off the token that usually supplies it, and
|
||||
* SO IS THE WINDOW — but both are read HERE, from a fitter, and nowhere else.
|
||||
*
|
||||
* `--fs-content-pad` is what `.fs-content` uses at desktop widths, and `theme/20-shell.css` gives
|
||||
* the same element `padding: var(--fs-space-4)` below 767px: the token says 28px a side while the
|
||||
* column's gutter is 16px there. That is a 24px error in a model this file exists to keep honest,
|
||||
* and `live-audit` found it on every page at 320, 390 and 568 — the same trap the alert-message rule
|
||||
* under that media query carries a paragraph about, repeated in the JS. The breakpoint itself may
|
||||
* NOT come here: a width literal copied into JS is exactly what these reads exist to avoid, and a
|
||||
* media query has no `data-*` to key on either. Asking the element what it actually got is the only
|
||||
* form that cannot drift.
|
||||
*
|
||||
* WHY IT IS A SEPARATE FUNCTION AND WHY ONLY A FITTER CALLS IT. `clientWidth` is a LAYOUT read and
|
||||
* `getComputedStyle` resolves style; `contentWidth()` below is called mid-scroll, by the one pass in
|
||||
* this theme that must answer without reading either (fs-select's, for a table the poll brought in
|
||||
* under the reader's thumb). So the numbers are taken when a fitter runs — which is on every resize
|
||||
* and every content mutation, and never during a flick, because `fitChrome()` defers exactly like
|
||||
* every other measuring pass — and everything after that reads what was stored. Nothing they
|
||||
* describe can change without a resize, and a resize schedules a fit.
|
||||
*
|
||||
* Unlike the token probe above, a hostile app's declaration is not a threat here: `.fs-content`
|
||||
* carries no chrome mark, so an app CAN restyle it, and if it did then that padding IS the column's
|
||||
* real gutter. What the probe must not report is a foreign answer for OUR layout; this reports the
|
||||
* layout as it stands. Before any fitter has run — the login page has no `.fs-content` at all — the
|
||||
* token stands in. */
|
||||
let _shellOuter = 0, _shellPad = null;
|
||||
function measureShell() {
|
||||
_shellOuter = document.documentElement.clientWidth;
|
||||
const host = document.querySelector('.fs-content');
|
||||
const cs = host ? getComputedStyle(host) : null;
|
||||
const v = cs ? parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight) : NaN;
|
||||
if (Number.isFinite(v) && v >= 0) {
|
||||
_shellPad = v;
|
||||
if (_geom) _geom.contentPad = v;
|
||||
}
|
||||
}
|
||||
|
||||
function columnWidth(g, state) {
|
||||
/* narrow OR top: in both the chrome is above the content, not beside it */
|
||||
const cut = (state.narrow || state.top) ? 0 : (state.rail ? g.railW : g.sidebarW);
|
||||
return Math.max(0, state.outerW - cut - g.contentPad);
|
||||
}
|
||||
|
||||
function fitShell() {
|
||||
const root = document.documentElement;
|
||||
/* the one place the window and the gutter are read — see measureShell(). It runs in BOTH
|
||||
* branches: the bar layout decides nothing here, but `contentWidth()` still answers in it. */
|
||||
measureShell();
|
||||
if (prefs.currentLayout() === 'top') { /* no sidebar, no cut, nothing to decide */
|
||||
root.removeAttribute('data-narrow');
|
||||
return;
|
||||
}
|
||||
const g = shellGeometry();
|
||||
const cut = prefs.currentRail() ? g.railW : g.sidebarW;
|
||||
/* clientWidth, not innerWidth: the column the content actually gets excludes a classic
|
||||
* scrollbar, and innerWidth includes it — 15-17px of phantom room on Linux/Windows, i.e. the
|
||||
* 500px floor really fired at ~484. */
|
||||
const content = document.documentElement.clientWidth - cut - g.contentPad;
|
||||
/* asked UNCOLLAPSED — this is the measurement that decides `data-narrow`, so it may not read it */
|
||||
const content = columnWidth(g, { outerW: _shellOuter, rail: prefs.currentRail() });
|
||||
/* toggleAttribute, NOT setAttribute: a same-value setAttribute still QUEUES a mutation record
|
||||
* (measured in Chromium: 5 identical setAttribute('data-narrow','') -> 5 records; toggleAttribute
|
||||
* on an already-present attribute -> 0). fitShell runs from fitChrome, which fs-fit calls on every
|
||||
@@ -476,15 +525,30 @@ return baseclass.extend({
|
||||
* a known amount of the window and the shell adds a known padding, and all three are already
|
||||
* memoised against the density attribute for `fitShell()`. It is exported because a pass that
|
||||
* must answer mid-scroll — fs-select's, for a table the poll has just brought in — otherwise has
|
||||
* only `window.innerWidth`, which in the sidebar layout is the wrong number by exactly the
|
||||
* only the window's own width, which in the sidebar layout is the wrong number by exactly the
|
||||
* sidebar: at an 800px window the column is 520px, so the cheap judgement said "plenty of room"
|
||||
* for a table that then overflowed and was clipped. */
|
||||
* for a table that then overflowed and was clipped.
|
||||
*
|
||||
* The arithmetic itself is columnWidth()'s, shared with fitShell — see there. All this adds is
|
||||
* the page's current state, which fitShell is in the middle of deciding and this one reads. */
|
||||
contentWidth() {
|
||||
const g = shellGeometry();
|
||||
const narrow = document.documentElement.hasAttribute('data-narrow');
|
||||
/* in the bar layout the chrome is above the content, not beside it */
|
||||
const cut = narrow ? 0 : (prefs.currentRail() ? g.railW : g.sidebarW);
|
||||
return Math.max(0, window.innerWidth - cut - (g.contentPad * 2));
|
||||
/* NO LAYOUT READ, and that is the whole point of this export: the window's width and the
|
||||
* column's gutter are whatever the last fitter measured (measureShell), and the three
|
||||
* attributes below are style, not layout. The bootstrap read is for a caller that arrives
|
||||
* before any fitter has run, which cannot happen mid-scroll. */
|
||||
if (!_shellOuter) measureShell();
|
||||
const root = document.documentElement;
|
||||
return columnWidth(shellGeometry(), {
|
||||
outerW: _shellOuter,
|
||||
narrow: root.hasAttribute('data-narrow'),
|
||||
top: prefs.isTopLayout(),
|
||||
rail: prefs.currentRail()
|
||||
});
|
||||
},
|
||||
|
||||
/* exported for the unit suite (tests/chrome-geometry.test.mjs): the cut is pure arithmetic over
|
||||
* four measured numbers, and driving it directly is the only way to hold every combination of
|
||||
* layout, rail and width without a browser */
|
||||
columnWidth,
|
||||
wireRail
|
||||
});
|
||||
@@ -37,25 +37,38 @@ const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals =
|
||||
if (window.__fsIntervalsHooked) return;
|
||||
window.__fsIntervalsHooked = true;
|
||||
const _si = window.setInterval, _ci = window.clearInterval;
|
||||
/* THE ID THE CALLER GOT IS THE ID IT KEEPS, whatever the pause below does underneath. The map is
|
||||
* keyed by that first id and the entry carries `live`, the id the platform has armed RIGHT NOW
|
||||
* (null while paused) — so a view holding its handle can still stop its own poller after a trip
|
||||
* through a hidden tab, which is exactly what re-arming under a fresh id took away from it.
|
||||
* The arguments are kept for the same reason: a `setInterval` id carries none of them back. */
|
||||
window.setInterval = function (fn, ms) {
|
||||
const id = _si.apply(window, arguments);
|
||||
/* the arguments are kept, not just the id: a paused timer has to be re-armed with what it
|
||||
* was armed with, and a `setInterval` id carries none of that back */
|
||||
_viewIntervals.set(id, { fn, ms, rest: Array.prototype.slice.call(arguments, 2) });
|
||||
_viewIntervals.set(id, { fn, ms, rest: Array.prototype.slice.call(arguments, 2), live: id });
|
||||
return id;
|
||||
};
|
||||
window.clearInterval = function (id) {
|
||||
const spec = _viewIntervals.get(id);
|
||||
_viewIntervals.delete(id);
|
||||
/* A PAUSED TIMER IS ALREADY DISARMED, and its number is the platform's to hand out again —
|
||||
* clearing it here would stop whatever timer holds it now. Untracked ids fall through
|
||||
* unchanged: the hook must stay a pass-through for everything it did not arm. */
|
||||
if (spec) return (spec.live == null) ? undefined : _ci.call(window, spec.live);
|
||||
return _ci.apply(window, arguments);
|
||||
};
|
||||
/* A HIDDEN TAB MUST NOT KEEP CALLING THE ROUTER. `wireVisibility()` below stops LuCI's own poll
|
||||
* when the tab goes away, which is most of the traffic — but a view is free to run a plain
|
||||
* `setInterval` of its own (luci-app-podkop's log tailer does), and those kept hammering ubus in
|
||||
* a background tab for as long as it stayed open. The registry that navigation already uses to
|
||||
* clear them is enough to pause them too: cleared on hide, re-armed on show with the same
|
||||
* clear them is enough to pause them too: disarmed on hide, re-armed on show with the same
|
||||
* callback and period, so a view that was polling every 3 s is polling every 3 s again and one
|
||||
* that was cleared meanwhile stays cleared. */
|
||||
let _paused = [];
|
||||
* that was cleared meanwhile stays cleared.
|
||||
*
|
||||
* A PAUSED TIMER STAYS IN THE REGISTRY, armed on nothing. It was carried in a private list
|
||||
* beside it, and a list is not what the navigation sweep reads: hide the tab while a navigation
|
||||
* is in flight (a click, then straight to another tab), and coming back re-armed the timers of
|
||||
* the page that navigation had already replaced — the sweep had run while they were somewhere
|
||||
* it could not see. In the map, `clearViewIntervals()` takes them like any other. */
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
/* LuCI'S OWN TICK IS NOT OURS TO PAUSE, and taking it made the page poll FASTER the
|
||||
@@ -75,18 +88,22 @@ const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals =
|
||||
* costs a doubling that never stops. Same judgement as clearViewIntervals(). */
|
||||
const keep = pollTickId();
|
||||
if (keep === false) return;
|
||||
_paused = [];
|
||||
for (const [ id, spec ] of _viewIntervals) {
|
||||
if (id === keep) continue;
|
||||
_paused.push(spec);
|
||||
_ci.call(window, id);
|
||||
_viewIntervals.delete(id);
|
||||
if (id === keep || spec.live == null) continue;
|
||||
_ci.call(window, spec.live);
|
||||
spec.live = null;
|
||||
}
|
||||
}
|
||||
else {
|
||||
const back = _paused;
|
||||
_paused = [];
|
||||
for (const spec of back) window.setInterval(spec.fn, spec.ms, ...spec.rest);
|
||||
for (const spec of _viewIntervals.values()) {
|
||||
if (spec.live != null) continue;
|
||||
/* `_si`, not the hook: this timer is already in the registry under the id its
|
||||
* caller is holding, and re-registering it would key a second entry to a number
|
||||
* nobody has. The fresh id lives in `spec.live` alone and is never a KEY, so it
|
||||
* cannot collide with an entry — and the platform hands ids out in sequence, so a
|
||||
* re-arm cannot be given a number some earlier caller is still holding either. */
|
||||
spec.live = _si.call(window, spec.fn, spec.ms, ...spec.rest);
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -306,6 +323,22 @@ function markExpired() {
|
||||
* ended, and the very next click sends the user to a login form. */
|
||||
console.warn('footstrap: the LuCI session is gone — every navigation from here is a full load.');
|
||||
}
|
||||
/* AND THE VERDICT IS NOT A LATCH. It was, and the shape was wrong for a signal read off somebody
|
||||
* else's reply: an interceptor sees `msg` only once the transport succeeded and the body parsed
|
||||
* (rpc.js rejects before that), so a missing frame is not a network flap — but it IS a captive
|
||||
* portal's page, a proxy's error body, one truncated reply. Any of those took the client router off
|
||||
* for the rest of the document while the session was alive throughout, explained by one console
|
||||
* line nobody reads until later.
|
||||
*
|
||||
* A clean `session.access` is the same call the failing one was, so it is evidence in the other
|
||||
* direction and it is taken as such. If the session really has ended no clean one arrives, because
|
||||
* every ubus call carries the same dead sid — the router stays off exactly as long as it should. */
|
||||
function markAlive() {
|
||||
if (!_expired) return;
|
||||
_expired = false;
|
||||
console.warn('footstrap: the LuCI session answers again — client navigation is back on.');
|
||||
}
|
||||
function sessionExpired() { return _expired; }
|
||||
function watchSession() {
|
||||
if (_sessionWired) return;
|
||||
_sessionWired = true;
|
||||
@@ -325,9 +358,22 @@ function watchSession() {
|
||||
rpc.addInterceptor((msg, r) => {
|
||||
try {
|
||||
if (!r || r.object !== 'session' || r.method !== 'access') return;
|
||||
if (!msg || msg.jsonrpc !== '2.0' ||
|
||||
(msg.error && msg.error.code && msg.error.message))
|
||||
markExpired();
|
||||
if (!msg || msg.jsonrpc !== '2.0') return;
|
||||
/* an `error` carrying both a code and a message is what handleCallReply() rejects
|
||||
* on, and a rejected session probe is the signal. A frame that is not JSON-RPC 2.0
|
||||
* is rejected there too, but it says nothing about the SESSION. */
|
||||
if (msg.error && msg.error.code && msg.error.message) { markExpired(); return; }
|
||||
/* AND ONLY `access: true` SAYS THE SESSION IS THERE — measured on the stands, because
|
||||
* this is the one place where guessing costs the whole fix. A dead sid does not make
|
||||
* this call fail: `session.access` answers `[0, {access:false}]` with HTTP 200 and no
|
||||
* error frame at all (the `-32002` arrives on the ORDINARY call, which is what makes
|
||||
* luci-base fire this probe in the first place). So "the reply parsed" would have
|
||||
* been read as "the session is back", and the verdict a 403 had just reached would
|
||||
* be cleared by the very probe that confirms it. `access:false` stays out of BOTH
|
||||
* answers: an ACL denial for a restricted user looks exactly the same, which is why
|
||||
* it may not expire a session either. */
|
||||
if (Array.isArray(msg.result) && msg.result[1] && msg.result[1].access === true)
|
||||
markAlive();
|
||||
}
|
||||
catch (e) { /* ditto */ }
|
||||
});
|
||||
@@ -1503,6 +1549,11 @@ return baseclass.extend({
|
||||
/* exported for the unit suite (tests/router-contract.test.mjs), which drives it against a
|
||||
* hand-broken `L` — the one way to see the OFF branch without a router that ships one */
|
||||
contractBreaks,
|
||||
/* likewise: tests/interval-pause.test.mjs drives the navigation sweep around a visibilitychange,
|
||||
* and tests/session-expiry.test.mjs reads the verdict the interceptors reached. navigate() is
|
||||
* the real caller of the first and `_expired` gates the second — nothing else may call either. */
|
||||
clearViewIntervals,
|
||||
sessionExpired,
|
||||
/* fs-search warms the pages this admin actually uses (its recents) and the arrow-key-highlighted
|
||||
* result, both of which the pointer/focus triggers above cannot see. The edge points that way
|
||||
* round — search → router — because the router must keep no dependency on the palette. */
|
||||
|
||||
@@ -651,6 +651,34 @@ function fitTables() {
|
||||
return;
|
||||
}
|
||||
|
||||
/* AND THE TABLE ITSELF HAS TO BE MEASURABLE, WHICH ON ITS FIRST PASS IT IS NOT — because
|
||||
* THIS FILE'S OWN GATE is what hides it. `theme/30-tables.css` holds a `.fs-dt` out of the
|
||||
* layout until something marks it `.fs-fitted`, so the very first pass over a fresh table
|
||||
* reads `scrollWidth: 0` — a `display: none` box has no content width — and 0 overflows
|
||||
* nothing. The pass therefore concluded "it fits", wrote `.fs-fitted`, and CACHED that
|
||||
* answer against the slot; the table then appeared at its natural width, 777px inside a
|
||||
* 712px column, columns cut off by `.fs-main { overflow-x: clip }` with no scrollbar and
|
||||
* nothing to say so.
|
||||
*
|
||||
* It usually corrected itself, which is what made it a report rather than a gate failure: a
|
||||
* second pass ~60 ms later measured the now-visible table and broke its widest column. But
|
||||
* that pass only exists if something mutates `#view` again, and Status → Processes renders
|
||||
* once and then stands still. Measured on the stand at 768px, entering the page directly:
|
||||
* 2 of 8 arrivals on 0.13.1 and 3 of 8 here left the table past the column, and one
|
||||
* mutation of any kind fixed it instantly.
|
||||
*
|
||||
* So a zero measurement is not an answer to anything: lift the gate so the next frame can
|
||||
* see the table, ask for that frame, and write NOTHING to the slot. The schedule is asked
|
||||
* for once — on the pass that lifts the gate — so a table hidden for somebody else's reason
|
||||
* (a closed section, a tab pane) cannot turn this into a frame loop. */
|
||||
if (t.scrollWidth === 0) {
|
||||
if (!t.classList.contains('fs-fitted')) {
|
||||
t.classList.add('fs-fitted');
|
||||
fit.schedule();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* the answer this slot already has, for a table of the same shape in the same room — UNLESS the
|
||||
* table has since outgrown it. Room and column count are not the whole input: a poll tick can
|
||||
* put longer values into the same columns, and then a table that fitted a second ago does not.
|
||||
|
||||
Reference in new issue
Block a user