🍕 Sync 2026-09-14 23:30:37

This commit is contained in:
github-actions[bot] committed 2026-09-14 23:30:37 +08:00
1 parent 0603b094cf
commit 81fe4cd866
214 files changed
+10806 -2630

No files matched your search

@@ -18,6 +18,13 @@ return baseclass.extend({
FETCH_LINES_MAX: 2000 /* ubus poll / logd ring cap (~2000 lines ≈ typical ring) */,
/* DOM budget: ~250 new/updated rows painted per second on typical LuCI routers */
RENDER_CAP_PER_SEC: 250,
/* #306 Layer 2 — measured client RTT → poll cadence (seconds). */
POLL_CADENCE_FAST_S: 1,
POLL_CADENCE_MID_S: 2,
POLL_CADENCE_SLOW_S: 5,
POLL_RTT_FAST_MS: 300,
POLL_RTT_SLOW_MS: 1500,
POLL_RTT_STREAK: 3,
VIEW_MODES: ['simple', 'detailed'],
COLUMN_SETS: {
simple: ['action', 'time', 'iface', 'flow', 'proto', 'rule'],
@@ -11,5 +11,5 @@
* GENERATED — do not edit. Edit fwlive.css and run: node scripts/embed-fwlive-css.js
*/
return baseclass.extend({
styleText: "\n.fwlive-map {\n\tmax-width: none;\n\twidth: 100%;\n\t--fwlive-pass-color: var(--success-color-high, var(--success-color, #46a546));\n\t--fwlive-deny-color: var(--error-color-high, var(--error-color, #ca3c3c));\n\t--fwlive-bg-medium: var(--background-color-medium, var(--white-color-low, #f9f9f9));\n\t--fwlive-paused-color: #b45309;\n}\n/* Wide page in Detail only — Simple keeps LuCI’s 940px column. */\n#maincontent.container:has(.fwlive-map[data-view=\"detailed\"]) {\n\tmax-width: none;\n\tpadding-left: 16px;\n\tpadding-right: 16px;\n}\n.fwlive-map[data-row-tint=\"accessible\"] {\n\t--fwlive-pass-color: #0d9488;\n\t--fwlive-deny-color: #c2410c;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row,\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tbackground: rgba(180, 83, 9, 0.08);\n\tbackground: color-mix(in srgb, var(--fwlive-paused-color) 8%, transparent);\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row {\n\tmargin: 0 -8px;\n\tpadding: 6px 8px 0;\n\tborder-radius: 3px 3px 0 0;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tmargin: 0 -8px 10px;\n\tpadding: 8px;\n\tborder-radius: 0 0 3px 3px;\n\tborder-bottom-color: transparent;\n}\n.fwlive-title-row {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: baseline;\n\tjustify-content: space-between;\n\tgap: 8px 16px;\n\tmargin: 0 0 4px;\n}\n.fwlive-title-row h2 {\n\tmargin: 0;\n\tflex: 0 1 auto;\n}\n.fwlive-title-status {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 8px;\n\tflex-wrap: wrap;\n\tmin-width: 0;\n\tmargin-left: auto;\n}\n.fwlive-watch-strip {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: stretch;\n\tjustify-content: flex-start;\n\tgap: 0;\n\tmin-height: 40px;\n\tpadding: 8px 0 10px;\n\tborder-bottom: 1px solid var(--border-color-medium);\n\tmargin-bottom: 10px;\n}\n.fwlive-watch-group {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 6px;\n\tmin-height: 28px;\n\tpadding: 0 10px;\n\tborder-left: 1px solid var(--border-color-medium);\n\tflex-wrap: wrap;\n}\n.fwlive-watch-group:last-child {\n\tpadding-right: 0;\n}\n.fwlive-watch-group-label {\n\tmargin-right: 2px;\n\tcolor: var(--text-color-medium);\n\tfont-size: 0.72em;\n\tfont-weight: 700;\n\tletter-spacing: 0.08em;\n\ttext-transform: uppercase;\n\twhite-space: nowrap;\n}\n.fwlive-watch-seg {\n\tdisplay: inline-flex;\n\talign-items: stretch;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\toverflow: hidden;\n}\n.fwlive-watch-seg .fwlive-seg-btn {\n\tmargin: 0;\n\tborder: 0;\n\tborder-radius: 0;\n\tborder-right: 1px solid var(--border-color-medium);\n\tbackground: transparent;\n\tpadding: 4px 10px;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n}\n.fwlive-watch-seg .fwlive-seg-btn:last-child {\n\tborder-right: 0;\n}\n.fwlive-watch-seg .fwlive-seg-btn[aria-pressed=\"true\"] {\n\tbackground: rgba(26, 111, 156, 0.12);\n\tbackground: color-mix(in srgb, var(--primary-color-high) 12%, transparent);\n\tcolor: var(--primary-color-high);\n\tfont-weight: 600;\n}\n.fwlive-watch-label { font-weight: 600; }\n.fwlive-watch-paused .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-map.fwlive-watch-paused .fwlive-title-status .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-dot {\n\twidth: 8px;\n\theight: 8px;\n\tborder-radius: 50%;\n\tbackground: var(--text-color-low, var(--border-color-high));\n\tdisplay: inline-block;\n\tflex-shrink: 0;\n}\n.fwlive-dot.fwlive-dot-on {\n\tbackground: var(--fwlive-pass-color);\n\tbox-shadow: 0 0 0 3px color-mix(in srgb, var(--fwlive-pass-color) 22%, transparent);\n}\n.fwlive-btn-ghost {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n}\n.fwlive-btn-quiet {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n\tcolor: var(--text-color-medium);\n\tfont-weight: 500;\n}\n.fwlive-log-merged {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tpadding: 4px 10px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-medium, var(--white-color-low, var(--background-color-high)));\n\tcolor: var(--text-color-high);\n\tfont-weight: 500;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n\tcursor: pointer;\n}\n.fwlive-log-merged:hover {\n\tbackground: var(--background-color-low, var(--background-color-medium));\n}\n.fwlive-log-merged:disabled {\n\topacity: 0.65;\n\tcursor: default;\n}\n.fwlive-log-on-dot {\n\twidth: 6px;\n\theight: 6px;\n\tborder-radius: 50%;\n\tbackground: var(--fwlive-pass-color);\n\tflex-shrink: 0;\n}\n.fwlive-log-rate {\n\tcolor: var(--text-color-medium);\n\tfont-weight: 400;\n}\n.fwlive-display-bar {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: center;\n\tgap: 8px 14px;\n\tmargin: 0 0 12px;\n\tpadding: 8px 12px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-high);\n}\n.fwlive-display-bar-label {\n\tflex: 0 0 auto;\n\tfoLine truncated
styleText: "\n.fwlive-map {\n\tmax-width: none;\n\twidth: 100%;\n\t--fwlive-pass-color: var(--success-color-high, var(--success-color, #46a546));\n\t--fwlive-deny-color: var(--error-color-high, var(--error-color, #ca3c3c));\n\t--fwlive-bg-medium: var(--background-color-medium, var(--white-color-low, #f9f9f9));\n\t--fwlive-paused-color: #b45309;\n}\n/* Wide page in Detail only — Simple keeps LuCI’s 940px column. */\n#maincontent.container:has(.fwlive-map[data-view=\"detailed\"]) {\n\tmax-width: none;\n\tpadding-left: 16px;\n\tpadding-right: 16px;\n}\n.fwlive-map[data-row-tint=\"accessible\"] {\n\t--fwlive-pass-color: #0d9488;\n\t--fwlive-deny-color: #c2410c;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row,\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tbackground: rgba(180, 83, 9, 0.08);\n\tbackground: color-mix(in srgb, var(--fwlive-paused-color) 8%, transparent);\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row {\n\tmargin: 0 -8px;\n\tpadding: 6px 8px 0;\n\tborder-radius: 3px 3px 0 0;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tmargin: 0 -8px 10px;\n\tpadding: 8px;\n\tborder-radius: 0 0 3px 3px;\n\tborder-bottom-color: transparent;\n}\n.fwlive-title-row {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: baseline;\n\tjustify-content: space-between;\n\tgap: 8px 16px;\n\tmargin: 0 0 4px;\n}\n.fwlive-title-row h2 {\n\tmargin: 0;\n\tflex: 0 1 auto;\n}\n.fwlive-title-status {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 8px;\n\tflex-wrap: wrap;\n\tmin-width: 0;\n\tmargin-left: auto;\n}\n.fwlive-watch-strip {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: stretch;\n\tjustify-content: flex-start;\n\tgap: 0;\n\tmin-height: 40px;\n\tpadding: 8px 0 10px;\n\tborder-bottom: 1px solid var(--border-color-medium);\n\tmargin-bottom: 10px;\n}\n.fwlive-watch-group {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 6px;\n\tmin-height: 28px;\n\tpadding: 0 10px;\n\tborder-left: 1px solid var(--border-color-medium);\n\tflex-wrap: wrap;\n}\n.fwlive-watch-group:last-child {\n\tpadding-right: 0;\n}\n.fwlive-watch-group-label {\n\tmargin-right: 2px;\n\tcolor: var(--text-color-medium);\n\tfont-size: 0.72em;\n\tfont-weight: 700;\n\tletter-spacing: 0.08em;\n\ttext-transform: uppercase;\n\twhite-space: nowrap;\n}\n.fwlive-watch-seg {\n\tdisplay: inline-flex;\n\talign-items: stretch;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\toverflow: hidden;\n}\n.fwlive-watch-seg .fwlive-seg-btn {\n\tmargin: 0;\n\tborder: 0;\n\tborder-radius: 0;\n\tborder-right: 1px solid var(--border-color-medium);\n\tbackground: transparent;\n\tpadding: 4px 10px;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n}\n.fwlive-watch-seg .fwlive-seg-btn:last-child {\n\tborder-right: 0;\n}\n.fwlive-watch-seg .fwlive-seg-btn[aria-pressed=\"true\"] {\n\tbackground: rgba(26, 111, 156, 0.12);\n\tbackground: color-mix(in srgb, var(--primary-color-high) 12%, transparent);\n\tcolor: var(--primary-color-high);\n\tfont-weight: 600;\n}\n.fwlive-watch-label { font-weight: 600; }\n.fwlive-watch-paused .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-map.fwlive-watch-paused .fwlive-title-status .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-dot {\n\twidth: 8px;\n\theight: 8px;\n\tborder-radius: 50%;\n\tbackground: var(--text-color-low, var(--border-color-high));\n\tdisplay: inline-block;\n\tflex-shrink: 0;\n}\n.fwlive-dot.fwlive-dot-on {\n\tbackground: var(--fwlive-pass-color);\n\tbox-shadow: 0 0 0 3px color-mix(in srgb, var(--fwlive-pass-color) 22%, transparent);\n}\n.fwlive-btn-ghost {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n}\n.fwlive-btn-quiet {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n\tcolor: var(--text-color-medium);\n\tfont-weight: 500;\n}\n.fwlive-log-merged {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tpadding: 4px 10px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-medium, var(--white-color-low, var(--background-color-high)));\n\tcolor: var(--text-color-high);\n\tfont-weight: 500;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n\tcursor: pointer;\n}\n.fwlive-log-merged:hover {\n\tbackground: var(--background-color-low, var(--background-color-medium));\n}\n.fwlive-log-merged:disabled {\n\topacity: 0.65;\n\tcursor: default;\n}\n.fwlive-log-on-dot {\n\twidth: 6px;\n\theight: 6px;\n\tborder-radius: 50%;\n\tbackground: var(--fwlive-pass-color);\n\tflex-shrink: 0;\n}\n.fwlive-log-rate {\n\tcolor: var(--text-color-medium);\n\tfont-weight: 400;\n}\n.fwlive-display-bar {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: center;\n\tgap: 8px 14px;\n\tmargin: 0 0 12px;\n\tpadding: 8px 12px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-high);\n}\n.fwlive-display-bar-label {\n\tflex: 0 0 auto;\n\tfoLine truncated
});
@@ -660,6 +660,16 @@
color: var(--text-color-high);
font-size: 0.92em;
}
.fwlive-adaptive {
display: none;
margin: 0 0 10px;
padding: 8px 12px;
background: var(--background-color-low);
border: 1px solid var(--border-color-medium);
border-radius: 3px;
color: var(--text-color-high);
font-size: 0.92em;
}
.fwlive-tint-warn {
display: none;
color: var(--warn-color-high);
@@ -35,8 +35,8 @@ const callFwliveRules = rpc.declare({
const callFwliveResolve = rpc.declare({
object: 'fwlive',
method: 'resolve',
params: ['addresses'],
expect: { names: {} }
params: ['addresses']
/* Full reply kept so disabled:"load" reaches the view (#306 Layer 2). */
});
const callFwliveLoggingStatus = rpc.declare({
@@ -102,12 +102,25 @@ return view.extend({
resumeMerge: false,
pollFn: null,
pollDataInFlight: false,
/* #306 Layer 2 — visibility / RTT cadence / shed surfacing. */
pollEpoch: 0,
pollCadenceSec: 1,
rttStreakKind: null,
rttStreakCount: 0,
serverAdaptive: 1,
serverTruncated: 0,
serverShed: null,
degradedSampling: false,
resolveLoadShed: false,
visibilityBound: false,
renderRaf: 0,
filterInputTimer: null,
messageLayout: 'wrap',
renderBucket: constants.RENDER_CAP_PER_SEC,
renderBucketMs: 0,
floodSuppressed: false,
pendingForceRender: false,
pendingRenderEpoch: null,
lastPollNewEvents: 0,
showHostnames: false,
rowTint: constants.DEFAULT_ROW_TINT,
@@ -702,7 +715,11 @@ return view.extend({
async fetchEntries() {
if (!this.sessionSeen) this.sessionSeen = new Set();
const epoch = this.pollEpoch;
const resumeMerge = !!this.resumeMerge;
const t0 = this.nowMs();
let reply;
let errored = false;
try {
/* Raw logd lines, not post-filter rows. Fetch a multiple of the
* display limit so mixed syslog still fills the table; pause
@@ -714,25 +731,44 @@ return view.extend({
addresses: [String(fetchLines)]
});
} catch (e) {
this.lastPollError = true;
return;
errored = true;
reply = null;
}
/* Full poll object (no rpc expect strip). error → banner; log → rows. */
if (epoch !== this.pollEpoch) return;
const rtt = this.nowMs() - t0;
if (!reply || typeof reply !== 'object' || Array.isArray(reply)) {
this.lastPollError = true;
this.notePollRtt(rtt, true);
this.updateAdaptiveBanner();
return;
}
if (reply.error) {
this.lastPollError = true;
this.notePollRtt(rtt, true);
this.updateAdaptiveBanner();
return;
}
const raw = reply.log;
if (!Array.isArray(raw)) {
this.lastPollError = true;
this.notePollRtt(rtt, true);
this.updateAdaptiveBanner();
return;
}
this.lastPollError = false;
this.serverAdaptive = reply.adaptive === 0 || reply.adaptive === false ? 0 : 1;
this.serverTruncated = reply.truncated ? 1 : 0;
this.serverShed =
reply.shed && typeof reply.shed === 'object' && !Array.isArray(reply.shed)
? reply.shed
: null;
this.notePollRtt(rtt, errored);
this.updateAdaptiveBanner();
const batch = this.normalizePollBatch(raw);
this.lastPollNewEvents = batch.pollNew;
@@ -740,10 +776,13 @@ return view.extend({
/* Oldest-first ring buffer; filteredRows() reverses for newest-first display. */
this.entries = buffer.applyFetchedEntries(this.entries, batch.rows, {
paused: this.paused,
resumeMerge: this.resumeMerge,
resumeMerge: resumeMerge,
rowLimit: this.rowLimit,
fetchLinesMax: constants.FETCH_LINES_MAX
});
/* A stale request returns above. Keep this obligation until a current
* request has actually applied the merged batch. */
if (resumeMerge) this.resumeMerge = false;
},
rememberSessionId(id) {
@@ -772,11 +811,217 @@ return view.extend({
const cap = this.ingestCap();
if (this.entries.length >= cap && cap > 0) bits.push(_('buffer full'));
if (this.floodSuppressed) bits.push(_('render paused (high rate)'));
if (this.degradedSampling && this.serverAdaptive !== 0) bits.push(_('Degraded — sampling'));
if (this.serverTruncated && this.serverAdaptive !== 0) bits.push(_('truncated'));
if (this.resolveLoadShed && this.serverAdaptive !== 0)
bits.push(_('resolve paused (load)'));
if (!this.paused && !this.followLive)
bits.push(_('scroll frozen — scroll to top to follow live'));
return bits.length ? ' — ' + bits.join(', ') : '';
},
nowMs() {
if (typeof performance !== 'undefined' && performance.now) return performance.now();
return Date.now();
},
isTabHidden() {
return typeof document !== 'undefined' && !!document.hidden;
},
/* Classify one RTT sample into hysteresis buckets (#306).
* error and >1.5s share the slow/degraded streak so mixed failures still trip N=3. */
rttKindFromMs(ms, errored) {
if (errored) return 'slow';
if (!(ms >= 0)) return 'slow';
if (ms < constants.POLL_RTT_FAST_MS) return 'fast';
if (ms <= constants.POLL_RTT_SLOW_MS) return 'mid';
return 'slow';
},
cadenceForKind(kind) {
if (kind === 'fast') return constants.POLL_CADENCE_FAST_S;
if (kind === 'mid') return constants.POLL_CADENCE_MID_S;
return constants.POLL_CADENCE_SLOW_S;
},
resetRttHistory() {
this.rttStreakKind = null;
this.rttStreakCount = 0;
},
bumpPollEpoch() {
this.pollEpoch = (this.pollEpoch || 0) + 1;
return this.pollEpoch;
},
clientBackoffEnabled() {
return this.serverAdaptive !== 0;
},
setPollCadence(sec) {
const next = sec > 0 ? sec : constants.POLL_CADENCE_FAST_S;
if (!this.pollFn) {
this.pollCadenceSec = next;
return;
}
if (this.isTabHidden()) {
this.pollCadenceSec = next;
return;
}
try {
poll.remove(this.pollFn);
} catch (e) {
/* poll gone */
}
this.pollCadenceSec = next;
try {
poll.add(this.pollFn, next);
} catch (e) {
/* poll gone */
}
},
notePollRtt(ms, errored) {
if (!this.clientBackoffEnabled()) {
this.degradedSampling = false;
/* Drop any partial streak so a slow sample from before the
* adaptive:0 window cannot trip degraded on re-enable. */
this.resetRttHistory();
if (this.pollCadenceSec !== constants.POLL_CADENCE_FAST_S)
this.setPollCadence(constants.POLL_CADENCE_FAST_S);
return;
}
const kind = this.rttKindFromMs(ms, errored);
if (kind === this.rttStreakKind) this.rttStreakCount++;
else {
this.rttStreakKind = kind;
this.rttStreakCount = 1;
}
if (this.rttStreakCount < constants.POLL_RTT_STREAK) return;
const cadence = this.cadenceForKind(kind);
this.degradedSampling = cadence === constants.POLL_CADENCE_SLOW_S;
if (cadence !== this.pollCadenceSec) this.setPollCadence(cadence);
},
stopPollingForHidden() {
if (!this.pollFn) return;
try {
poll.remove(this.pollFn);
} catch (e) {
/* poll gone */
}
},
resumePollingAfterVisible() {
this.bumpPollEpoch();
this.resetRttHistory();
/* In-flight poll from the hidden epoch must not apply after catch-up. */
this.pollDataInFlight = false;
if (!this.pollFn) this.pollFn = this.pollData.bind(this);
this.setPollCadence(this.pollCadenceSec || constants.POLL_CADENCE_FAST_S);
this.pollData();
},
onVisibilityChange() {
if (this.isTabHidden()) {
this.stopPollingForHidden();
this.bumpPollEpoch();
return;
}
this.resumePollingAfterVisible();
},
bindVisibility() {
if (this.visibilityBound) return;
if (typeof document === 'undefined' || !document.addEventListener) return;
this.visibilityBound = true;
this.visibilityHandler = function () {
this.onVisibilityChange();
}.bind(this);
document.addEventListener('visibilitychange', this.visibilityHandler);
},
unbindVisibility() {
if (
typeof document !== 'undefined' &&
document.removeEventListener &&
this.visibilityHandler
) {
try {
document.removeEventListener('visibilitychange', this.visibilityHandler);
} catch (e) {
/* document gone */
}
}
this.visibilityHandler = null;
this.visibilityBound = false;
},
updateAdaptiveBanner() {
const el = document.getElementById('fwlive-adaptive');
if (!el) return;
if (!el.style) el.style = { display: '' };
if (!this.clientBackoffEnabled()) {
el.style.display = 'none';
el.textContent = '';
return;
}
const parts = [];
if (this.degradedSampling)
parts.push(_('Degraded — sampling (slow poll RTT; cadence reduced).'));
if (this.serverShed && this.serverShed.limit)
parts.push(
_('Server shedding — at most %d log lines per poll.').format(this.serverShed.limit)
);
else if (this.serverTruncated) parts.push(_('Server truncated this poll (adaptive cap).'));
if (this.resolveLoadShed)
parts.push(_('Hostname resolve paused while the router is under load.'));
if (parts.length) {
el.style.display = 'block';
el.textContent = parts.join(' ');
} else {
el.style.display = 'none';
el.textContent = '';
}
},
scheduleRenderRows(force) {
const doForce = !!force;
const epoch = this.pollEpoch;
if (typeof requestAnimationFrame !== 'function') {
this.renderRows(doForce);
return;
}
if (this.renderRaf) {
this.pendingForceRender = this.pendingForceRender || doForce;
this.pendingRenderEpoch = epoch;
return;
}
this.renderRaf = requestAnimationFrame(
function () {
this.renderRaf = 0;
const f = doForce || !!this.pendingForceRender;
const pendingEpoch = this.pendingRenderEpoch;
this.pendingForceRender = false;
this.pendingRenderEpoch = null;
if (epoch !== this.pollEpoch) {
/* A newer epoch may have requested a render while this frame was
* queued. Drop stale paint and requeue only that current request. */
if (pendingEpoch === this.pollEpoch) this.scheduleRenderRows(f);
return;
}
this.renderRows(f);
}.bind(this)
);
},
refillRenderBucket() {
const now = Date.now();
if (!this.renderBucketMs) this.renderBucketMs = now;
@@ -828,6 +1073,7 @@ return view.extend({
updateFloodBanner() {
const el = document.getElementById('fwlive-flood');
if (!el) return;
if (!el.style) el.style = { display: '' };
if (this.floodSuppressed) {
el.style.display = 'block';
@@ -879,11 +1125,13 @@ return view.extend({
if (this.lastPollError) {
status.className = 'fwlive-status fwlive-status-error';
status.textContent = _('Connection lost — retrying…') + suffix;
this.updateAdaptiveBanner();
return;
}
status.className = this.paused ? 'fwlive-status fwlive-status-paused' : 'fwlive-status';
status.textContent = this.compactCountText(matchCount);
this.updateAdaptiveBanner();
},
readRowLimit() {
@@ -960,13 +1208,13 @@ return view.extend({
this.followLive = true;
/* Merge pause buffer with the first live poll — do not replace (#43). */
this.resumeMerge = true;
const epoch = this.pollEpoch;
this.fetchEntries()
.then(() => this.renderRows(true))
.finally(
function () {
this.resumeMerge = false;
}.bind(this)
);
.then(() => {
/* A hide/show bump abandons this epoch; the catch-up poll paints. */
if (epoch === this.pollEpoch) this.renderRows(true);
})
.catch(function () {});
}
},
@@ -983,8 +1231,11 @@ return view.extend({
this.pendingForceRender = true;
if (!this.paused) this.renderRows(true);
else this.updateStatus();
const epoch = this.pollEpoch;
this.fetchEntries()
.then(() => {
/* A hide/show bump abandons this epoch; the catch-up poll paints. */
if (epoch !== this.pollEpoch) return;
if (this.paused) this.updateStatus();
else this.renderRows(true);
})
@@ -1063,6 +1314,9 @@ return view.extend({
const ips = this.collectIpsFromEntries(entries);
const need = [];
const now = Date.now();
/* While the router sheds resolve load, hold the paused banner without
* re-asking every poll — retry after the cooldown expires. */
if (this.resolveLoadShed && now < (this.resolveShedUntil || 0)) return;
for (let i = 0; i < ips.length && need.length < 32; i++) {
const ip = ips[i];
@@ -1081,8 +1335,28 @@ return view.extend({
const res = await callFwliveResolve({ addresses: need });
if (gen !== this.resolveGeneration) return;
/* rpc.declare expect: { names: {} } already unwraps — res IS the map (#243). */
const names = res || {};
if (res && typeof res === 'object' && res.disabled === 'load') {
this.resolveLoadShed = true;
this.resolveShedUntil = Date.now() + 60000;
this.updateAdaptiveBanner();
return;
}
this.resolveLoadShed = false;
this.resolveShedUntil = 0;
/* RPC-level failure (no_resolver, jshn_missing, invalid_input): the
* reply carries no per-address signal, so return without failMark —
* otherwise every address looks like "no PTR" and retries stall
* for the failure TTL. */
if (res && typeof res === 'object' && typeof res.error === 'string' && res.error)
return;
/* Full reply: names map under .names; legacy expect-unwrap was the map. */
const names =
res && typeof res === 'object' && res.names && typeof res.names === 'object'
? res.names
: res && typeof res === 'object' && !Array.isArray(res)
? res
: {};
let updated = false;
for (let i = 0; i < need.length; i++) {
@@ -1096,7 +1370,8 @@ return view.extend({
}
}
if (updated) this.renderRows(true);
this.updateAdaptiveBanner();
if (updated) this.scheduleRenderRows(true);
} catch (e) {
/* resolve unavailable — show IPs */
} finally {
@@ -1221,7 +1496,7 @@ return view.extend({
renderRows(force) {
const el = document.getElementById('fwlive-table');
if (!el) return;
if (!el || typeof el.querySelector !== 'function') return;
const body = el.querySelector('tbody');
const empty = document.getElementById('fwlive-empty');
@@ -1369,18 +1644,25 @@ return view.extend({
},
async pollData() {
if (this.isTabHidden()) return;
if (this.pollDataInFlight) return;
const epoch = this.pollEpoch;
this.pollDataInFlight = true;
try {
try {
await this.fetchEntries();
} catch (e) {
/* fetchEntries already accounts the poll RTT for every rpc
* outcome; a throw here is a local normalize/buffer bug, not
* network slowness, so count nothing further. */
this.lastPollError = true;
}
if (epoch !== this.pollEpoch) return;
if (this.paused) this.updateStatus();
else this.renderRows(!!this.pendingForceRender);
else this.scheduleRenderRows(!!this.pendingForceRender);
try {
await this.resolveHostnamesForEntries(this.filteredRows());
@@ -1388,19 +1670,25 @@ return view.extend({
/* resolve unavailable — show IPs */
}
} finally {
this.pollDataInFlight = false;
/* Only the owning epoch clears — a stale hidden-era poll must not
* drop the catch-up guard after resume (Grok #331). Resume clears
* explicitly before starting the catch-up poll. */
if (epoch === this.pollEpoch) this.pollDataInFlight = false;
}
},
load() {
this.bindVisibility();
if (!this.pollFn) {
this.pollFn = this.pollData.bind(this);
poll.add(this.pollFn, 1);
this.pollCadenceSec = constants.POLL_CADENCE_FAST_S;
if (!this.isTabHidden()) poll.add(this.pollFn, this.pollCadenceSec);
/* Best-effort teardown when leaving the page (LuCI SPA may full-reload). */
if (typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener(
'pagehide',
function () {
this.unbindVisibility();
if (this.pollFn) {
try {
poll.remove(this.pollFn);
@@ -1553,6 +1841,7 @@ return view.extend({
)
]),
E('div', { 'id': 'fwlive-flood', 'class': 'fwlive-flood' }, ['']),
E('div', { 'id': 'fwlive-adaptive', 'class': 'fwlive-adaptive' }, ['']),
E('div', { 'id': 'fwlive-display-drawer', 'class': 'fwlive-display-bar' }, [
E('span', { 'class': 'fwlive-display-bar-label' }, [_('Display options')]),
E('div', { 'class': 'fwlive-display-controls' }, [