⛅ Sync 2026-09-16 03:46:54
Merge-upstream / merge (push) Canceled after 0s

This commit is contained in:
github-actions[bot] committed 2026-09-16 03:46:54 +08:00
1 parent c13653dc17
commit 77ae924426
29 files changed
+1215 -429

No files matched your search

@@ -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
});
@@ -670,6 +670,25 @@
color: var(--text-color-high);
font-size: 0.92em;
}
.fwlive-summary {
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);
}
.fwlive-summary-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.fwlive-summary-body {
margin: 8px 0 0;
font: inherit;
white-space: pre-wrap;
}
.fwlive-tint-warn {
display: none;
color: var(--warn-color-high);
@@ -14,7 +14,7 @@
* callbacks - {} (unused; present for API consistency)
*
* renderRows(host, state, callbacks) → void
* host - <tbody> element (cleared and rebuilt; element itself is kept)
* host - <tbody> element (kept; normal polls reuse keyed rows)
* state - shallow copy: { rows, columns, viewMode, messageLayout,
* expandedRowId, rowTint, showHostnames,
* hostnameCache, firewallBackend }
@@ -24,8 +24,9 @@
* Internals (not a second public contract): columnLabel, columnCellClass,
* flowCell, buildColumnCell.
*
* Modules must not mutate state. host contents are cleared then rebuilt
* (idempotent replace). Does not touch #fwlive-scroll or #fwlive-empty.
* Modules must not mutate state. Forced renders clear and rebuild the host;
* normal polls reuse unchanged keyed rows and only build changed rows. Does
* not touch #fwlive-scroll or #fwlive-empty.
*/
function columnLabel(col) {
@@ -255,49 +256,146 @@ function renderThead(host, state, _callbacks) {
}
}
function rowRenderKey(row, state, columns) {
return JSON.stringify([
row.id,
row.timestamp,
row.action,
row.rule_hint,
row.rule_label,
row.interface_in,
row.interface_out,
row.direction,
row.proto,
row.src,
row.sport,
row.dst,
row.dport,
row.flags,
row.length,
row.message,
columns,
state.viewMode,
state.messageLayout,
state.expandedRowId === row.id,
!!state.rowTint,
!!state.showHostnames,
state.firewallBackend
]);
}
function rowClass(index, row, state, callbacks) {
return [
index % 2 ? 'fwlive-row-alt' : '',
state.viewMode === 'simple' ? 'fwlive-row-clickable' : '',
state.expandedRowId === row.id ? 'fwlive-row-expanded' : '',
state.rowTint ? callbacks.actionRowTintClass(row.action) : ''
]
.filter(Boolean)
.join(' ');
}
function buildRow(row, index, state, columns, callbacks) {
const cells = [];
for (let c = 0; c < columns.length; c++)
cells.push(buildColumnCell(columns[c], row, state, callbacks));
const tr = E(
'tr',
{
'class': rowClass(index, row, state, callbacks),
'click': state.viewMode === 'simple' ? (ev) => callbacks.onRowClick(row.id, ev) : null
},
cells
);
tr._fwliveRowId = String(row.id);
tr._fwliveRowKey = rowRenderKey(row, state, columns);
return tr;
}
function buildExpansionRow(row, state, columns) {
const expansion = E('tr', { 'class': 'fwlive-msg-expand' }, [
E('td', { 'colspan': String(columns.length) }, [
E('div', { 'class': 'fwlive-msg-expand-label' }, [_('Message')]),
E('pre', { 'class': 'fwlive-msg-expand-body' }, [
log.formatMessageDisplay(row.message, 'wrap') || '—'
])
])
]);
expansion._fwliveExpansionFor = String(row.id);
expansion._fwliveExpansionKey = rowRenderKey(row, state, columns);
return expansion;
}
function renderAllRows(host, rows, state, columns, callbacks) {
host.innerHTML = '';
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
host.appendChild(buildRow(row, i, state, columns, callbacks));
if (state.viewMode === 'simple' && state.expandedRowId === row.id)
host.appendChild(buildExpansionRow(row, state, columns));
}
}
function canReuseRows(host) {
return (
host &&
host.childNodes &&
typeof host.childNodes.length === 'number' &&
typeof host.insertBefore === 'function' &&
typeof host.removeChild === 'function'
);
}
function renderRows(host, state, callbacks) {
const rows = state.rows || [];
const columns = state.columns || [];
host.innerHTML = '';
if (state.forceRender || !canReuseRows(host)) {
renderAllRows(host, rows, state, columns, callbacks);
return;
}
const existingRows = new Map();
const existingExpansions = new Map();
const oldChildren = Array.prototype.slice.call(host.childNodes);
for (let i = 0; i < oldChildren.length; i++) {
const child = oldChildren[i];
if (child._fwliveRowId != null) existingRows.set(child._fwliveRowId, child);
else if (child._fwliveExpansionFor != null)
existingExpansions.set(child._fwliveExpansionFor, child);
}
const desired = [];
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
const rowClass = [
i % 2 ? 'fwlive-row-alt' : '',
state.viewMode === 'simple' ? 'fwlive-row-clickable' : '',
state.expandedRowId === r.id ? 'fwlive-row-expanded' : '',
state.rowTint ? callbacks.actionRowTintClass(r.action) : ''
]
.filter(Boolean)
.join(' ');
const cells = [];
for (let c = 0; c < columns.length; c++)
cells.push(buildColumnCell(columns[c], r, state, callbacks));
const row = rows[i];
const id = String(row.id);
const key = rowRenderKey(row, state, columns);
let tr = existingRows.get(id);
if (!tr || tr._fwliveRowKey !== key) tr = buildRow(row, i, state, columns, callbacks);
else tr.setAttribute('class', rowClass(i, row, state, callbacks));
tr._fwliveRowId = id;
tr._fwliveRowKey = key;
desired.push(tr);
const tr = E(
'tr',
{
'class': rowClass,
'click': state.viewMode === 'simple' ? (ev) => callbacks.onRowClick(r.id, ev) : null
},
cells
);
host.appendChild(tr);
if (state.viewMode === 'simple' && state.expandedRowId === r.id) {
host.appendChild(
E('tr', { 'class': 'fwlive-msg-expand' }, [
E('td', { 'colspan': String(columns.length) }, [
E('div', { 'class': 'fwlive-msg-expand-label' }, [_('Message')]),
E('pre', { 'class': 'fwlive-msg-expand-body' }, [
log.formatMessageDisplay(r.message, 'wrap') || '—'
])
])
])
);
if (state.viewMode === 'simple' && state.expandedRowId === row.id) {
let expansion = existingExpansions.get(id);
if (!expansion || expansion._fwliveExpansionKey !== key)
expansion = buildExpansionRow(row, state, columns);
desired.push(expansion);
}
}
for (let i = 0; i < desired.length; i++) {
const current = host.childNodes[i] || null;
if (current !== desired[i]) host.insertBefore(desired[i], current);
}
const used = new Set(desired);
const currentChildren = Array.prototype.slice.call(host.childNodes);
for (let i = 0; i < currentChildren.length; i++) {
if (!used.has(currentChildren[i])) host.removeChild(currentChildren[i]);
}
}
return baseclass.extend({
@@ -102,6 +102,12 @@ return view.extend({
resumeMerge: false,
pollFn: null,
pollDataInFlight: false,
/* Every refresh trigger goes through this coordinator. A request made
* while one is active is coalesced into one follow-up, never overlapped. */
pollRequestPromise: null,
pollRequestQueued: false,
pollRequestWaiters: [],
pollRequestQueuedWaiters: [],
/* Layer 2 — visibility / RTT cadence / shed surfacing. */
pollEpoch: 0,
pollCadenceSec: 1,
@@ -111,6 +117,10 @@ return view.extend({
serverTruncated: 0,
serverShed: null,
degradedSampling: false,
/* Layer 2 summary fallback — rows remain available behind an explicit toggle. */
summaryMode: false,
summaryRowsShown: false,
summaryData: null,
resolveLoadShed: false,
visibilityBound: false,
renderRaf: 0,
@@ -769,6 +779,14 @@ return view.extend({
this.notePollRtt(rtt, errored);
this.updateAdaptiveBanner();
if (this.clientBackoffEnabled() && rtt > constants.POLL_RTT_SLOW_MS) {
if (!this.summaryMode) this.enterSummaryMode(reply.summary);
else {
this.summaryData =
reply.summary && typeof reply.summary === 'object' ? reply.summary : null;
this.renderSummary();
}
}
const batch = this.normalizePollBatch(raw);
this.lastPollNewEvents = batch.pollNew;
@@ -884,6 +902,7 @@ return view.extend({
notePollRtt(ms, errored) {
if (!this.clientBackoffEnabled()) {
if (this.summaryMode) this.leaveSummaryMode();
this.degradedSampling = false;
/* Drop any partial streak so a slow sample from before the
* adaptive:0 window cannot trip degraded on re-enable. */
@@ -905,6 +924,7 @@ return view.extend({
const cadence = this.cadenceForKind(kind);
this.degradedSampling = cadence === constants.POLL_CADENCE_SLOW_S;
if (cadence !== this.pollCadenceSec) this.setPollCadence(cadence);
if (kind === 'fast' && this.summaryMode) this.leaveSummaryMode();
},
stopPollingForHidden() {
@@ -919,11 +939,12 @@ return view.extend({
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();
/* An old hidden-epoch request may still be on the wire. Queue the
* catch-up behind it; stale-reply checks discard its application, while
* the coordinator prevents a second server request from overlapping. */
this.requestPoll();
},
onVisibilityChange() {
@@ -992,6 +1013,93 @@ return view.extend({
}
},
summaryListText(label, values) {
const lines = [label + ':'];
if (!Array.isArray(values) || !values.length) {
lines.push(_('none'));
return lines;
}
for (let i = 0; i < values.length; i++) {
const item = values[i];
if (!item || typeof item.value !== 'string') continue;
const count = Number.isFinite(Number(item.count)) ? Number(item.count) : 0;
lines.push('%s — %d'.format(item.value, count));
}
if (lines.length === 1) lines.push(_('none'));
return lines;
},
renderSummary() {
const card = document.getElementById('fwlive-summary');
const body = document.getElementById('fwlive-summary-body');
if (!card || !body) return;
const summary = this.summaryData;
const lines = [_('Showing a compact summary of the top of the shown sample.')];
if (!summary || typeof summary !== 'object') {
lines.push(_('Summary data is unavailable; show rows to inspect the sample.'));
} else if (summary.truncated) {
lines.push(_('Summary was bounded before all categories could be included.'));
} else {
this.summaryListText(_('Top talkers'), summary.top_talkers).forEach((line) =>
lines.push(line)
);
this.summaryListText(_('Top drops'), summary.top_drops).forEach((line) =>
lines.push(line)
);
this.summaryListText(_('Top rules'), summary.top_rules).forEach((line) =>
lines.push(line)
);
}
body.textContent = lines.join('\n');
this.updateSummaryUi();
},
updateSummaryUi() {
const card = document.getElementById('fwlive-summary');
const scroll = document.getElementById('fwlive-scroll');
const empty = document.getElementById('fwlive-empty');
const toggle = document.getElementById('fwlive-summary-rows');
if (card) {
if (!card.style) card.style = { display: '' };
card.style.display = this.summaryMode ? 'block' : 'none';
}
if (scroll) {
if (!scroll.style) scroll.style = { display: '' };
scroll.style.display = this.summaryMode && !this.summaryRowsShown ? 'none' : '';
}
if (empty) {
if (!empty.style) empty.style = { display: '' };
empty.style.display = this.summaryMode && !this.summaryRowsShown ? 'none' : '';
}
if (toggle) {
toggle.textContent = this.summaryRowsShown ? _('Hide rows') : _('Show rows');
toggle.setAttribute('aria-pressed', this.summaryRowsShown ? 'true' : 'false');
}
},
enterSummaryMode(summary) {
this.summaryMode = true;
this.summaryRowsShown = false;
this.summaryData = summary && typeof summary === 'object' ? summary : null;
this.renderSummary();
},
leaveSummaryMode() {
if (!this.summaryMode) return;
this.summaryMode = false;
this.summaryRowsShown = false;
this.summaryData = null;
this.updateSummaryUi();
this.renderRows(true);
},
onSummaryRowsToggle() {
if (!this.summaryMode) return;
this.summaryRowsShown = !this.summaryRowsShown;
this.updateSummaryUi();
if (this.summaryRowsShown) this.renderRows(true);
},
scheduleRenderRows(force) {
const doForce = !!force;
const epoch = this.pollEpoch;
@@ -1193,7 +1301,7 @@ return view.extend({
if (!wasPaused && this.paused) {
this.pauseBufferLoading = true;
this.updateStatus();
this.fetchEntries()
this.requestPoll()
.catch(function () {})
.finally(
function () {
@@ -1209,7 +1317,7 @@ return view.extend({
/* Merge pause buffer with the first live poll — do not replace. */
this.resumeMerge = true;
const epoch = this.pollEpoch;
this.fetchEntries()
this.requestPoll()
.then(() => {
/* A hide/show bump abandons this epoch; the catch-up poll paints. */
if (epoch === this.pollEpoch) this.renderRows(true);
@@ -1232,7 +1340,7 @@ return view.extend({
if (!this.paused) this.renderRows(true);
else this.updateStatus();
const epoch = this.pollEpoch;
this.fetchEntries()
this.requestPoll()
.then(() => {
/* A hide/show bump abandons this epoch; the catch-up poll paints. */
if (epoch !== this.pollEpoch) return;
@@ -1533,6 +1641,7 @@ return view.extend({
{
rows: rows.slice(),
columns: this.activeColumns().slice(),
forceRender: !!force,
viewMode: this.viewMode,
messageLayout: this.messageLayout,
expandedRowId: this.expandedRowId,
@@ -1641,14 +1750,82 @@ return view.extend({
const tintSel = document.getElementById('fwlive-row-tint');
if (tintSel) tintSel.addEventListener('change', this.onRowTintPaletteChange.bind(this));
const summaryRows = document.getElementById('fwlive-summary-rows');
if (summaryRows) summaryRows.addEventListener('click', this.onSummaryRowsToggle.bind(this));
},
async pollData() {
if (this.isTabHidden()) return;
if (this.pollDataInFlight) return;
return this.requestPoll();
},
requestPoll() {
if (this.isTabHidden()) return Promise.resolve();
const waiter = {};
const promise = new Promise(function (resolve) {
waiter.resolve = resolve;
});
if (this.pollRequestPromise) {
/* Keep one pending refresh intent. The latest view state is read when
* that follow-up begins, so Pause/Resume/Limit changes coalesce safely. */
this.pollRequestQueued = true;
this.pollRequestQueuedWaiters.push(waiter);
return promise;
}
if (this.pollRequestQueued) {
this.pollRequestQueuedWaiters.push(waiter);
this.startPollRequest(this.pollRequestQueuedWaiters);
this.pollRequestQueuedWaiters = [];
this.pollRequestQueued = false;
return promise;
}
this.startPollRequest([waiter]);
return promise;
},
startPollRequest(waiters) {
const epoch = this.pollEpoch;
this.pollDataInFlight = true;
const run = this.runPollRequest(epoch);
this.pollRequestPromise = run;
this.pollRequestWaiters = waiters;
run.then(
function (value) {
this.finishPollRequest(run, value);
}.bind(this),
function () {
/* runPollRequest normally absorbs local failures so the poll loop
* remains alive; settle waiters even if a future change rejects. */
this.finishPollRequest(run);
}.bind(this)
);
},
finishPollRequest(run, value) {
if (this.pollRequestPromise !== run) return;
const waiters = this.pollRequestWaiters;
this.pollRequestWaiters = [];
this.pollRequestPromise = null;
this.pollDataInFlight = false;
for (let i = 0; i < waiters.length; i++) {
waiters[i].resolve(value);
}
/* If visibility changed while the request was active, retain the
* queued intent until the visible catch-up can start it. */
if (this.pollRequestQueued && !this.isTabHidden()) {
const queued = this.pollRequestQueuedWaiters;
this.pollRequestQueuedWaiters = [];
this.pollRequestQueued = false;
this.startPollRequest(queued);
}
},
async runPollRequest(epoch) {
try {
try {
await this.fetchEntries();
@@ -1661,7 +1838,11 @@ return view.extend({
if (epoch !== this.pollEpoch) return;
/* Pause freezes row rendering but polling remains active for health and
* cadence state; summary mode can therefore appear while rows are paused
* and stays behind the explicit Show rows control. */
if (this.paused) this.updateStatus();
else if (this.summaryMode) this.renderSummary();
else this.scheduleRenderRows(!!this.pendingForceRender);
try {
@@ -1669,11 +1850,10 @@ return view.extend({
} catch (e) {
/* resolve unavailable — show IPs */
}
} finally {
/* Only the owning epoch clears — a stale hidden-era poll must not
* drop the catch-up guard after resume. Resume clears
* explicitly before starting the catch-up poll. */
if (epoch === this.pollEpoch) this.pollDataInFlight = false;
} catch (e) {
/* Keep the coordinator promise settling so a queued refresh cannot
* be stranded by an unexpected local rendering failure. */
this.lastPollError = true;
}
},
@@ -1706,7 +1886,7 @@ return view.extend({
}
}
return Promise.all([this.loadRulesMap(), this.loadLoggingStatus()]).then(() =>
this.fetchEntries()
this.requestPoll()
);
},
@@ -1842,6 +2022,28 @@ return view.extend({
]),
E('div', { 'id': 'fwlive-flood', 'class': 'fwlive-flood' }, ['']),
E('div', { 'id': 'fwlive-adaptive', 'class': 'fwlive-adaptive' }, ['']),
E(
'div',
{ 'id': 'fwlive-summary', 'class': 'fwlive-summary', 'style': 'display:none' },
[
E('div', { 'class': 'fwlive-summary-head' }, [
E('strong', {}, [_('Summary mode')]),
E(
'button',
{
'id': 'fwlive-summary-rows',
'class': 'cbi-button fwlive-btn-ghost',
'type': 'button',
'aria-pressed': 'false'
},
[_('Show rows')]
)
]),
E('pre', { 'id': 'fwlive-summary-body', 'class': 'fwlive-summary-body' }, [
''
])
]
),
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' }, [