🤞 Sync 2026-09-30 23:44:05

This commit is contained in:
github-actions[bot] committed 2026-09-30 23:44:05 +08:00
1 parent e3fde93105
commit 44bb740ab2
206 files changed
+5736 -2799

No files matched your search

@@ -9,7 +9,11 @@
*
* While paused, ingest grows up to fetchLinesMax. Live mode caps at rowLimit.
* On resume, merge (do not replace) so pause-accumulated rows survive the first
* live poll.
* live poll. In live mode, an empty normalized poll preserves the current rows:
* the buffer cannot tell inactivity from a cleared server log. The view reports
* how long ago it last received a non-empty batch after its stale threshold.
* A repeated non-empty window resets that receipt clock even if all events are
* old; the hint measures received batches, not the age of the last new event.
*/
function ingestCap(paused, rowLimit, fetchLinesMax) {
@@ -23,7 +23,7 @@ function chipFieldLabel(spec) {
}
function chipValueNodes(spec, val) {
const key = spec.key;
const contains = log.isSubstringFilterField(spec.key);
const label = chipFieldLabel(spec);
const p = log.parseFilterValue(val);
if (!p.value) return [''];
@@ -31,7 +31,7 @@ function chipValueNodes(spec, val) {
const valueNode = p.negate ? E('span', { 'class': 'fwlive-chip-strike' }, [p.value]) : p.value;
if (!p.negate) {
if (key === 'q' || key === 'src' || key === 'dst')
if (contains)
return [
E('span', { 'class': 'fwlive-chip-polarity' }, [_('contains')]),
' ',
@@ -44,7 +44,7 @@ function chipValueNodes(spec, val) {
];
}
if (key === 'q' || key === 'src' || key === 'dst')
if (contains)
return [
label + ': ',
E('strong', { 'class': 'fwlive-chip-not' }, [_('does not contain')]),
@@ -28,9 +28,19 @@ return baseclass.extend({
POLL_CADENCE_FAST_S: 1,
POLL_CADENCE_MID_S: 2,
POLL_CADENCE_SLOW_S: 5,
/* Three slow polls; keep empty-poll status quiet through adaptive cadence changes. */
EMPTY_POLL_STALE_AFTER_MS: 15000,
POLL_RTT_FAST_MS: 300,
POLL_RTT_SLOW_MS: 1500,
POLL_RTT_STREAK: 3,
/* Rules-map retry backoff; attempts include the initial read, a reload starts fresh. */
RULES_RETRY_BASE_MS: 5000,
RULES_RETRY_MAX_DELAY_MS: 60000,
RULES_RETRY_MAX_ATTEMPTS: 6,
/* Addresses per fwlive.resolve call; must equal rpcd RESOLVE_MAX. */
RESOLVE_BATCH_MAX: 32,
RESOLVE_SHED_COOLDOWN_MS: 60000,
FILTER_INPUT_DEBOUNCE_MS: 100,
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/* SPDX-License-Identifier: Apache-2.0 */\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-dLine truncated
styleText: "\n/* SPDX-License-Identifier: Apache-2.0 */\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-dLine truncated
});
@@ -216,19 +216,23 @@
}
/* Draw our own disclosure arrow — themes that restyle summary drop the native marker. */
.fwlive-more-filters > summary,
.fwlive-help > summary {
.fwlive-help > summary,
.fwlive-rules-details > summary {
cursor: pointer;
list-style: none;
}
.fwlive-more-filters > summary::-webkit-details-marker,
.fwlive-help > summary::-webkit-details-marker { display: none; }
.fwlive-help > summary::-webkit-details-marker,
.fwlive-rules-details > summary::-webkit-details-marker { display: none; }
.fwlive-more-filters > summary::before,
.fwlive-help > summary::before {
.fwlive-help > summary::before,
.fwlive-rules-details > summary::before {
content: "▸ ";
color: var(--text-color-medium);
}
.fwlive-more-filters[open] > summary::before,
.fwlive-help[open] > summary::before { content: "▾ "; }
.fwlive-help[open] > summary::before,
.fwlive-rules-details[open] > summary::before { content: "▾ "; }
.fwlive-ctl {
display: inline-flex;
align-items: center;
@@ -754,6 +758,15 @@
color: var(--warn-color-high);
font-weight: 600;
}
.fwlive-rules-details {
margin: 4px 0 8px;
color: var(--text-color-high);
}
#fwlive-rules-details-body {
margin: 6px 0;
white-space: pre-line;
overflow-wrap: anywhere;
}
.fwlive-filter-panel { margin-bottom: 12px; }
.fwlive-map[data-view="simple"] .fwlive-grid-core {
display: grid;
@@ -32,6 +32,20 @@ return baseclass.extend({
return value;
},
/* Scoped IPv6 (`fe80::1%eth0`) resolves and caches under the bare address. */
stripZone: function (addr) {
if (typeof addr !== 'string') return addr;
const zone = addr.lastIndexOf('%');
return zone === -1 ? addr : addr.slice(0, zone);
},
/* Display reads refresh recency so visible names stay warm in the LRU. */
cachedName: function (map, addr) {
if (!map || !addr) return null;
const value = this.lruGet(map, this.stripZone(addr));
return value == null ? null : String(value);
},
failIsHot: function (failedMap, ip, nowMs, ttlMs) {
if (!failedMap || !failedMap.has(ip)) return false;
const at = failedMap.get(ip);
@@ -79,19 +79,7 @@ function filterLink(field, value, label, onFilterClick) {
function addrFilterLink(field, ip, showHostnames, hostnameCache, onFilterClick) {
if (!ip) return log.formatCell(ip);
let name = null;
if (showHostnames && hostnameCache) {
/* Resolve stores the bare address; scoped rows still look up that key. */
const cacheKey =
typeof ip === 'string' && ip.lastIndexOf('%') !== -1
? ip.slice(0, ip.lastIndexOf('%'))
: ip;
/* Display reads refresh recency so visible names remain warm in the LRU. */
name =
hostname && typeof hostname.lruGet === 'function'
? hostname.lruGet(hostnameCache, cacheKey)
: hostnameCache.get(cacheKey);
}
const name = showHostnames ? hostname.cachedName(hostnameCache, ip) : null;
const display = name || ip;
const title = name ? ip : _('Filter by %s').format(log.filterFieldLabel(field));
@@ -593,6 +593,11 @@ return baseclass.extend({
return p.negate ? !hit : hit;
},
/* Fields matchesFilter compares by substring ("contains"); the rest match exactly. */
isSubstringFilterField: function (key) {
return key === 'q' || key === 'src' || key === 'dst';
},
matchesFilter: function (row, filters) {
if (filters.q && !this.matchesQueryField(row, filters.q)) return false;
if (filters.action && !this.matchesActionField(row, filters.action)) return false;
@@ -25,6 +25,9 @@
* state - loggingState + { showConsent }
* callbacks - { onEnable(), onDismissConsent(persist) }
*
* toggleFailureNotice(code, fallback) → string
* rpcd enable/disable error code → notice; unknown codes return fallback.
*
* Modules must not mutate state. host is cleared then rebuilt (idempotent replace).
*/
@@ -46,18 +49,51 @@ function persistConsentDismissed() {
}
}
/* Shared by enable and disable; codes a toggle cannot emit fall through. */
function toggleFailureNotice(code, fallback) {
switch (code) {
case 'nf_log_missing':
return _('Cannot enable logging until kernel log modules are installed.');
case 'firewall_changes_pending':
return _('Another change is staged for the firewall; apply or revert it first.');
case 'no_wan_zone':
return _('No WAN zone found; cannot toggle logging without one.');
case 'lock_failed':
return _('Could not acquire the logging lock.');
case 'rollback_tracking_failed':
return _('Could not track the logging change safely; logging was not changed.');
case 'baseline_snapshot_failed':
return _('Could not snapshot the current logging state.');
case 'firewall_reload_failed':
return _('The firewall did not reload; saved and live logging may differ.');
case 'uci_set_failed':
return _('Could not write the WAN zone log option.');
case 'uci_delete_failed':
return _('Could not clear the WAN zone log option.');
case 'uci_commit_failed':
return _('Could not save the firewall configuration.');
case 'firewall_commit_raced':
return _(
'Another change overwrote WAN logging after it was saved; check the current state.'
);
default:
return fallback;
}
}
function enableLoggingButton(state, callbacks) {
return E(
'button',
{
'class': 'cbi-button cbi-button-action',
'type': 'button',
'title': _('Enable WAN zone drop/reject logging (same as Network → Firewall).'),
'disabled': state.loggingBusy ? '' : null,
'click': function () {
callbacks.onEnable();
}
},
[state.loggingBusy ? _('Enabling…') : _('Enable WAN drop/reject logging')]
[state.loggingBusy ? _('Enabling…') : _('Enable logging')]
);
}
@@ -180,21 +216,7 @@ function renderToolbar(host, state, callbacks) {
return;
}
host.appendChild(
E(
'button',
{
'class': 'cbi-button cbi-button-action',
'type': 'button',
'title': _('Enable WAN zone drop/reject logging (same as Network → Firewall).'),
'disabled': state.loggingBusy ? '' : null,
'click': function () {
callbacks.onEnable();
}
},
[state.loggingBusy ? _('Enabling…') : _('Enable logging')]
)
);
host.appendChild(enableLoggingButton(state, callbacks));
appendLoggingNotice(host, state);
}
@@ -359,7 +381,9 @@ function buildEmptyStateNodes(state, callbacks) {
])
);
nodes.push(
E('p', { 'class': 'fwlive-empty-muted' }, [_('Nothing changes until you click Enable.')])
E('p', { 'class': 'fwlive-empty-muted' }, [
_('Nothing changes until you click Enable logging.')
])
);
nodes.push(
E('p', {}, [
@@ -399,6 +423,7 @@ return baseclass.extend({
CONSENT_STORAGE_KEY: CONSENT_STORAGE_KEY,
consentDismissedPermanent: consentDismissedPermanent,
persistConsentDismissed: persistConsentDismissed,
toggleFailureNotice: toggleFailureNotice,
renderToolbar: renderToolbar,
buildEmptyStateNodes: buildEmptyStateNodes,
renderEmptyState: renderEmptyState,
@@ -4,6 +4,7 @@
'require baseclass';
'require fwlive.log as log';
'require fwlive.links as links';
'require fwlive.hostname as hostname';
/**
* Table thead/rows DOM renderer for luci-app-fwlive.
@@ -271,13 +272,8 @@ function renderThead(host, state, _callbacks) {
}
function hostnameCacheValue(ip, state) {
if (!state.showHostnames || !state.hostnameCache || !ip) return '';
const key =
typeof ip === 'string' && ip.lastIndexOf('%') !== -1
? ip.slice(0, ip.lastIndexOf('%'))
: ip;
const value = state.hostnameCache.get ? state.hostnameCache.get(key) : undefined;
return value == null ? '' : String(value);
if (!state.showHostnames) return '';
return hostname.cachedName(state.hostnameCache, ip) || '';
}
function rowRenderKey(row, state, columns) {
@@ -363,21 +359,11 @@ function renderAllRows(host, rows, state, columns, callbacks) {
}
}
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 || [];
if (state.forceRender || !canReuseRows(host)) {
if (state.forceRender) {
renderAllRows(host, rows, state, columns, callbacks);
return;
}
@@ -23,9 +23,8 @@
'require fwlive.render-policy as renderPolicy';
'require fwlive.render-scheduler as renderScheduler';
const RULES_RETRY_BASE_MS = 5000;
/* Includes the initial read; a page reload starts a fresh retry budget. */
const RULES_RETRY_MAX_ATTEMPTS = 6;
/* Poll error codes that only a reinstall fixes; retrying cannot help. */
const INSTALL_POLL_ERRORS = ['timeout_missing', 'jsonfilter_missing', 'classifier_missing'];
const callFwlivePoll = rpc.declare({
object: 'fwlive',
@@ -112,12 +111,6 @@ function isIpv4Address(addr) {
return true;
}
/* Scoped IPv6 (`fe80::1%eth0`) still resolves as the bare address. */
function stripIpZone(addr) {
const zone = addr.lastIndexOf('%');
return zone === -1 ? addr : addr.slice(0, zone);
}
return view.extend({
rowLimit: constants.DEFAULT_ROW_LIMIT,
fetchMode: constants.DEFAULT_FETCH_MODE,
@@ -159,6 +152,9 @@ return view.extend({
messageLayout: 'wrap',
/* Session-new IDs from the last applied batch; this is not buffer growth. */
lastBatchNewIdCount: 0,
/* Monotonic time for age hints after successful empty poll batches. */
lastNonEmptyBatchAt: null,
lastSuccessfulBatchEmpty: false,
showHostnames: false,
rowTint: constants.DEFAULT_ROW_TINT,
/* Last non-off palette so toggling tint back on restores Classic/Accessible. */
@@ -172,6 +168,8 @@ return view.extend({
lastPollError: false,
lastPollErrorCode: null,
lastRulesError: null,
rulesMapTruncated: false,
rulesMapLoaded: false,
rulesRetryAttempt: 0,
nextRulesRetryAt: 0,
followLive: true,
@@ -599,7 +597,7 @@ return view.extend({
isLikelyIp(addr) {
if (typeof addr !== 'string' || !addr) return false;
addr = stripIpZone(addr);
addr = hostname.stripZone(addr);
if (!addr) return false;
if (isIpv4Address(addr)) return true;
if (!addr.includes(':') || !/^[\da-f:.]+$/i.test(addr) || addr.includes(':::'))
@@ -708,17 +706,16 @@ return view.extend({
const res = await callFwliveRules();
if (!this.isCurrentPollEpoch(epoch)) return;
this.rulesMap = (res && res.rules) || {};
this.rulesMapTruncated = !!(res && res.truncated === true);
this.rulesMapLoaded = true;
this.firewallBackend = (res && res.backend) || 'nft';
/* Bounds / mktemp failures are reply.error — same idea as poll. */
/* Truncation belongs to this map; error describes its lookup failure. */
this.noteRulesMapOutcome((res && res.error) || null);
if (this.lastRulesError) console.warn('fwlive rules map error:', this.lastRulesError);
this.refreshBufferedRuleLabels();
} catch (_e) {
if (!this.isCurrentPollEpoch(epoch)) return;
if (!(this.rulesMap && Object.keys(this.rulesMap).length)) {
this.rulesMap = {};
this.firewallBackend = 'nft';
}
/* Retain the entire last snapshot, including an empty truncated map. */
this.noteRulesMapOutcome('rules_unavailable');
}
this.updateBackendUi();
@@ -735,15 +732,15 @@ return view.extend({
/* Back off consecutive failures, then stop after a bounded retry budget. */
this.rulesRetryAttempt = Math.min(
(this.rulesRetryAttempt || 0) + 1,
RULES_RETRY_MAX_ATTEMPTS
constants.RULES_RETRY_MAX_ATTEMPTS
);
if (this.rulesRetryAttempt >= RULES_RETRY_MAX_ATTEMPTS) {
if (this.rulesRetryAttempt >= constants.RULES_RETRY_MAX_ATTEMPTS) {
this.nextRulesRetryAt = 0;
return;
}
const delay = Math.min(
RULES_RETRY_BASE_MS * Math.pow(2, this.rulesRetryAttempt - 1),
60000
constants.RULES_RETRY_BASE_MS * Math.pow(2, this.rulesRetryAttempt - 1),
constants.RULES_RETRY_MAX_DELAY_MS
);
this.nextRulesRetryAt = this.nowMs() + delay;
},
@@ -753,6 +750,56 @@ return view.extend({
return '';
},
rulesDiagnosticCause(error) {
switch (error) {
case 'mktemp_failed':
return _('Could not create a temporary file for rule names.');
case 'tsv_failed':
return _('Could not process firewall rule names.');
case 'no_backend':
return _('Could not read the active firewall rules.');
case 'timeout_missing':
return _('The required timeout utility is unavailable.');
case 'rules_unavailable':
if (!this.rulesMapLoaded) return _('Could not load rule names.');
if (this.rulesMap && Object.keys(this.rulesMap).length)
return _(
'Could not refresh rule names; previously loaded names are still shown.'
);
return _('Could not refresh rule names.');
default:
return _('Rule-name lookup failed.');
}
},
rulesDiagnosticLines() {
const lines = [];
if (this.rulesMapTruncated)
lines.push(_('Rule-name lookup reached a safety limit.') + ' (truncated=true)');
if (this.lastRulesError) {
const cause = this.rulesDiagnosticCause(this.lastRulesError);
/* Unknown RPC codes are bounded diagnostic text, never HTML. */
const code =
typeof this.lastRulesError === 'string' ? this.lastRulesError.slice(0, 80) : '';
lines.push(cause + (code ? ' (' + code + ')' : ''));
}
lines.push(_('Rule-name lookup does not change your firewall rules.'));
lines.push(
_(
'Some entries may show names derived from their log prefixes. Searching by a friendly rule name may miss those entries.'
)
);
return lines;
},
/* timeout_missing is an install problem shown elsewhere, not a rules-map gap. */
rulesMapDegraded() {
return !!(
this.rulesMapTruncated ||
(this.lastRulesError && this.lastRulesError !== 'timeout_missing')
);
},
updateBackendUi() {
const map = document.querySelector('.fwlive-map');
if (map) map.setAttribute('data-backend', this.firewallBackend || 'unknown');
@@ -762,13 +809,8 @@ return view.extend({
const warnings = (this.loggingStatus && this.loggingStatus.warnings) || [];
let text = this.backendDisplayLabel();
let degraded = false;
if (this.lastRulesError && this.lastRulesError !== 'timeout_missing') {
let err = '';
if (this.lastRulesError === 'rules_truncated')
err = _('Rule labels incomplete — map truncated');
else if (this.lastRulesError === 'mktemp_failed')
err = _('Rule labels unavailable — temp file failed');
else err = _('Rule labels unavailable');
if (this.rulesMapDegraded()) {
const err = _('Some rule names may be missing');
text = text ? text + ' \u00b7 ' + err : err;
degraded = true;
}
@@ -782,6 +824,14 @@ return view.extend({
label.textContent = text;
label.classList.toggle('fwlive-backend-warn', degraded);
}
const details = document.getElementById('fwlive-rules-details');
const body = document.getElementById('fwlive-rules-details-body');
if (details && body) {
const degraded = this.rulesMapDegraded();
details.style.display = degraded ? '' : 'none';
body.textContent = degraded ? this.rulesDiagnosticLines().join('\n\n') : '';
if (!degraded) details.open = false;
}
this.updateEmptyStateUi();
},
@@ -820,6 +870,7 @@ return view.extend({
this.updateLoggingToolbarUi();
this.updateEmptyStateUi();
this.updateStatus();
if (wasWeakDevice !== this.weakDevice) this.updateAdaptiveBanner();
if (wasWeakDevice !== this.weakDevice && document.getElementById('fwlive-table'))
this.renderRows(true);
},
@@ -876,35 +927,8 @@ return view.extend({
this.updateEmptyStateUi();
this.updateLoggingToolbarUi();
},
failureNotice: (res) => {
if (res && res.error === 'nf_log_missing')
return _('Cannot enable logging until kernel log modules are installed.');
if (res && res.error === 'firewall_changes_pending')
return _(
'Another change is staged for the firewall; apply or revert it first.'
);
if (res && res.error === 'no_wan_zone')
return _('No WAN zone found; cannot toggle logging without one.');
if (res && res.error === 'lock_failed')
return _('Could not acquire the logging lock.');
if (res && res.error === 'rollback_tracking_failed')
return _('Could not track the logging change safely; logging was not changed.');
if (res && res.error === 'baseline_snapshot_failed')
return _('Could not snapshot the current logging state.');
if (res && res.error === 'firewall_reload_failed')
return _('The firewall did not reload; saved and live logging may differ.');
if (res && res.error === 'uci_set_failed')
return _('Could not write the WAN zone log option.');
if (res && res.error === 'uci_delete_failed')
return _('Could not clear the WAN zone log option.');
if (res && res.error === 'uci_commit_failed')
return _('Could not save the firewall configuration.');
if (res && res.error === 'firewall_commit_raced')
return _(
'Another change overwrote WAN logging after it was saved; check the current state.'
);
return _('Could not enable logging.');
},
failureNotice: (res) =>
logging.toggleFailureNotice(res && res.error, _('Could not enable logging.')),
successNotice: (res) =>
res.changed
? _(
@@ -921,31 +945,8 @@ return view.extend({
wanLog: false,
call: () => callFwliveDisableLogging(),
initialUi: () => this.updateLoggingToolbarUi(),
failureNotice: (res) => {
if (res && res.error === 'firewall_changes_pending')
return _(
'Another change is staged for the firewall; apply or revert it first.'
);
if (res && res.error === 'no_wan_zone')
return _('No WAN zone found; cannot toggle logging without one.');
if (res && res.error === 'lock_failed')
return _('Could not acquire the logging lock.');
if (res && res.error === 'rollback_tracking_failed')
return _('Could not track the logging change safely; logging was not changed.');
if (res && res.error === 'firewall_reload_failed')
return _('The firewall did not reload; saved and live logging may differ.');
if (res && res.error === 'uci_set_failed')
return _('Could not write the WAN zone log option.');
if (res && res.error === 'uci_delete_failed')
return _('Could not clear the WAN zone log option.');
if (res && res.error === 'uci_commit_failed')
return _('Could not save the firewall configuration.');
if (res && res.error === 'firewall_commit_raced')
return _(
'Another change overwrote WAN logging after it was saved; check the current state.'
);
return _('Could not disable logging.');
},
failureNotice: (res) =>
logging.toggleFailureNotice(res && res.error, _('Could not disable logging.')),
successNotice: (res) => (res.changed ? _('WAN drop/reject logging is off.') : ''),
catchNotice: () => _('Administrator access is required to disable logging.')
});
@@ -1174,6 +1175,8 @@ return view.extend({
const batch = this.normalizePollBatch(raw);
this.lastBatchNewIdCount = batch.pollNew;
this.lastSuccessfulBatchEmpty = batch.rows.length === 0;
if (batch.rows.length) this.lastNonEmptyBatchAt = this.nowMs();
/* Oldest-first ring buffer; filteredRows() reverses for newest-first display. */
this.entries = buffer.applyFetchedEntries(this.entries, batch.rows, {
@@ -1224,7 +1227,7 @@ return view.extend({
}
} else if (
this.lastRulesError === 'rules_unavailable' &&
this.rulesRetryAttempt < RULES_RETRY_MAX_ATTEMPTS &&
this.rulesRetryAttempt < constants.RULES_RETRY_MAX_ATTEMPTS &&
!this.tablePaused &&
!this.lastPollError &&
this.nowMs() >= (this.nextRulesRetryAt || 0)
@@ -1269,16 +1272,14 @@ return view.extend({
const cap = this.ingestCap();
if (this.entries.length >= cap && cap > 0) bits.push(_('buffer full'));
if (this.ensureRenderScheduler().isFloodSuppressed())
bits.push(_('render paused (high rate)'));
bits.push(_('table refresh throttled'));
if (this.weakDevice && this.rowLimit > constants.WEAK_DEVICE_DISPLAY_ROW_CAP)
bits.push(
_('Display limited to %d rows on this device').format(
constants.WEAK_DEVICE_DISPLAY_ROW_CAP
)
_('display limited to %d rows').format(constants.WEAK_DEVICE_DISPLAY_ROW_CAP)
);
if (this.degradedSampling) bits.push(_('Degraded — sampling'));
if (this.serverTruncated) bits.push(_('truncated'));
if (this.resolveLoadShed) bits.push(_('resolve paused (load)'));
if (this.degradedSampling) bits.push(_('polling slowed'));
if (this.serverTruncated) bits.push(_('server limited fetch'));
if (this.resolveLoadShed) bits.push(_('hostname lookup paused'));
if (!this.tablePaused && !this.followLive)
bits.push(_('scroll frozen — scroll to top to follow live'));
return bits.length ? ' — ' + bits.join(', ') : '';
@@ -1445,21 +1446,33 @@ return view.extend({
else if (this.serverAdaptive === 0)
parts.push(_('Server adaptive protection is disabled.'));
if (this.degradedSampling)
parts.push(_('Degraded — sampling (slow poll RTT; cadence reduced).'));
parts.push(
_('Polling slowed: the router is responding slowly, so polls run less often.')
);
if (this.serverShed && this.serverShed.limit)
parts.push(
_('Server shedding — at most %d log lines per poll.').format(this.serverShed.limit)
_(
'Server limited fetch: the router is under load; at most %d log lines per poll.'
).format(this.serverShed.limit)
);
else if (this.serverTruncated)
parts.push(
_('Server limited fetch: this poll returned fewer lines to protect the router.')
);
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.'));
parts.push(_('Hostname lookup paused while the router is under load.'));
if (this.weakDevice && this.rowLimit > constants.WEAK_DEVICE_DISPLAY_ROW_CAP)
parts.push(
_(
'Display limited to %d rows on this device; the larger Limit still buffers rows.'
).format(constants.WEAK_DEVICE_DISPLAY_ROW_CAP)
);
return parts;
},
updateAdaptiveBanner() {
const el = document.getElementById('fwlive-adaptive');
if (!el) return;
if (!el.style) el.style = { display: '' };
const parts = this.adaptiveBannerParts();
@@ -1534,11 +1547,9 @@ return view.extend({
const scroll = document.getElementById('fwlive-scroll');
const empty = document.getElementById('fwlive-empty');
if (scroll) {
if (!scroll.style) scroll.style = { display: '' };
scroll.style.display = hideTable ? 'none' : '';
}
if (empty) {
if (!empty.style) empty.style = { display: '' };
empty.style.display = hideTable ? 'none' : rowCount ? 'none' : 'block';
}
},
@@ -1547,7 +1558,6 @@ return view.extend({
const card = document.getElementById('fwlive-summary');
const toggle = document.getElementById('fwlive-summary-rows');
if (card) {
if (!card.style) card.style = { display: '' };
card.style.display = this.summaryMode ? 'block' : 'none';
}
this.syncEmptyScrollVisibility(this.filteredRowsState().rows.length);
@@ -1602,12 +1612,11 @@ return view.extend({
updateFloodBanner() {
const el = document.getElementById('fwlive-flood');
if (!el) return;
if (!el.style) el.style = { display: '' };
if (this.ensureRenderScheduler().isFloodSuppressed()) {
el.style.display = 'block';
el.textContent = _(
'High event rate — table refresh is throttled to protect the browser. The buffer still updates; refresh will resume automatically.'
'Table refresh throttled: the event rate is high, so the table repaints less often to protect the browser. The buffer still updates; refresh resumes automatically.'
);
} else {
el.style.display = 'none';
@@ -1659,12 +1668,37 @@ return view.extend({
return this.filteredRowsState().rows.slice();
},
formatAgeShort(seconds) {
if (seconds < 60) return _('%ds').format(seconds);
if (seconds < 3600) return _('%dm').format(Math.floor(seconds / 60));
if (seconds < 86400) return _('%dh').format(Math.floor(seconds / 3600));
return _('%dd').format(Math.floor(seconds / 86400));
},
stalenessHint() {
if (
!this.entries.length ||
!this.lastSuccessfulBatchEmpty ||
!Number.isFinite(this.lastNonEmptyBatchAt)
)
return '';
const elapsed = Math.max(0, this.nowMs() - this.lastNonEmptyBatchAt);
if (elapsed < constants.EMPTY_POLL_STALE_AFTER_MS) return '';
const age = this.formatAgeShort(Math.floor(elapsed / 1000));
return _('Firewall log has no current entries; showing rows last received %s ago.').format(
age
);
},
compactCountText(filtered) {
const stored = this.entries.length;
/* Keep the stored-buffer denominator tied to the user's Limit. A weak
* device's rendered-row cap is called out separately in statusSuffix(). */
const limit = this.rowLimit;
const suffix = this.statusSuffix();
const stale = this.stalenessHint();
const suffix = this.statusSuffix() + (stale ? ' — ' + stale : '');
/* The filter state contains both visible rows and the full match count, so
* paused status can report matches beyond the display cap without rescanning. */
const state =
@@ -1680,6 +1714,15 @@ return view.extend({
return '';
},
/* No reply is a transport failure; a typed code means rpcd answered. */
pollErrorText(suffix) {
const code = this.lastPollErrorCode;
if (INSTALL_POLL_ERRORS.indexOf(code) >= 0)
return _('Installation is incomplete. Reinstall luci-app-fwlive.');
if (code) return _('The router could not read the firewall log — retrying…') + suffix;
return _('Connection lost — retrying…') + suffix;
},
updateStatus(filtered) {
const status = document.getElementById('fwlive-status');
if (!status) return;
@@ -1690,10 +1733,7 @@ return view.extend({
if (this.lastPollError) {
status.className = 'fwlive-status fwlive-status-error';
status.textContent =
this.lastPollErrorCode === 'timeout_missing'
? _('Installation is incomplete. Reinstall luci-app-fwlive.')
: _('Connection lost — retrying…') + suffix;
status.textContent = this.pollErrorText(suffix);
this.updateAdaptiveBanner();
return;
}
@@ -1834,6 +1874,7 @@ return view.extend({
this.applyRowLimit(n);
this.saveRowLimit();
this.updateHash(this.readFilters());
this.updateAdaptiveBanner();
/* Reset flood throttle so Limit changes paint even during ping -A. */
this.ensureRenderScheduler().resetBudget();
const cancelForce = this.ensureRenderScheduler().forceNextRender();
@@ -1903,8 +1944,8 @@ return view.extend({
for (let i = 0; i < entries.length; i++) {
const r = entries[i];
if (r.src && this.isLikelyIp(r.src)) ips.add(stripIpZone(r.src));
if (r.dst && this.isLikelyIp(r.dst)) ips.add(stripIpZone(r.dst));
if (r.src && this.isLikelyIp(r.src)) ips.add(hostname.stripZone(r.src));
if (r.dst && this.isLikelyIp(r.dst)) ips.add(hostname.stripZone(r.dst));
}
return Array.from(ips);
@@ -1934,12 +1975,12 @@ return view.extend({
const ips = this.collectIpsFromEntries(entries);
const need = [];
const now = Date.now();
const now = this.nowMs();
/* 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++) {
for (let i = 0; i < ips.length && need.length < constants.RESOLVE_BATCH_MAX; i++) {
const ip = ips[i];
if (hostname.lruGet(this.hostnameCache, ip) !== undefined) continue;
if (hostname.failIsHot(this.hostnameFailed, ip, now)) continue;
@@ -1958,7 +1999,7 @@ return view.extend({
if (this.isLoadShedReply(res)) {
this.resolveLoadShed = true;
this.resolveShedUntil = Date.now() + 60000;
this.resolveShedUntil = this.nowMs() + constants.RESOLVE_SHED_COOLDOWN_MS;
this.updateAdaptiveBanner();
return;
}
@@ -2115,7 +2156,6 @@ return view.extend({
const body = el.querySelector('tbody');
const scroll = document.getElementById('fwlive-scroll');
this.updateHash(this.readFilters());
const filtered = this.filteredRowsState();
const rows = filtered.rows.slice();
@@ -2184,6 +2224,7 @@ return view.extend({
},
onFilterInput() {
this.updateHash(this.readFilters());
this.renderRows(true);
},
@@ -2194,7 +2235,7 @@ return view.extend({
this.filterInputTimer = null;
this.onFilterInput();
}.bind(this),
100
constants.FILTER_INPUT_DEBOUNCE_MS
);
},
@@ -2281,10 +2322,7 @@ return view.extend({
/* 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. */
if (epoch === this.currentPollEpoch()) {
this.lastPollError = true;
this.lastPollErrorCode = null;
}
this.markLocalPollFailure(epoch);
}
if (epoch !== this.currentPollEpoch()) return;
@@ -2317,13 +2355,17 @@ return view.extend({
} catch (_e) {
/* Keep the coordinator promise settling so a queued refresh cannot
* be stranded by an unexpected local rendering failure. */
if (epoch === this.currentPollEpoch()) {
this.lastPollError = true;
this.lastPollErrorCode = null;
}
this.markLocalPollFailure(epoch);
}
},
/* No error code: the status line reports it as a lost connection. */
markLocalPollFailure(epoch) {
if (epoch !== this.currentPollEpoch()) return;
this.lastPollError = true;
this.lastPollErrorCode = null;
},
load() {
/* A late LuCI lifecycle callback may re-enter load() after pagehide has
* made disposal terminal; do not restore state or re-register polling. */
@@ -2442,7 +2484,14 @@ return view.extend({
)
]),
E('li', {}, [
_('Display options on the bar set Limit, row tint, palette, and hostnames.')
_(
'Display options set Limit, Fetch budget, Maximum raw lines, row tint, palette, and hostnames.'
)
]),
E('li', {}, [
_(
'Summary mode: when the router responds slowly, a compact summary replaces the table. Use Show rows to see the table; it returns automatically after three fast polls.'
)
]),
E('li', {}, [
_(
@@ -2550,6 +2599,18 @@ return view.extend({
)
])
]),
E(
'details',
{
'id': 'fwlive-rules-details',
'class': 'fwlive-rules-details',
'style': 'display:none'
},
[
E('summary', { 'aria-label': _('Rule name details') }, [_('Details')]),
E('div', { 'id': 'fwlive-rules-details-body' }, [])
]
),
E('div', { 'id': 'fwlive-watch-strip', 'class': 'fwlive-watch-strip' }, [
E('div', { 'class': 'fwlive-watch-group' }, [
E(
@@ -2797,6 +2858,7 @@ return view.extend({
this.updateEmptyStateUi();
this.updateBackendUi();
this.updateTintWarnUi();
this.updateHash(this.readFilters());
this.renderRows(true);
const testLi = document.getElementById('fwlive-manual-test');
if (testLi)