mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-15 04:44:47 +08:00
2093 lines
60 KiB
JavaScript
2093 lines
60 KiB
JavaScript
'use strict';
|
|
/* SPDX-License-Identifier: Apache-2.0 */
|
|
/* Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com> */
|
|
/*
|
|
* LuCI Firewall Live View — client-side view (view.extend + ubus fwlive.poll).
|
|
* UI interaction patterns inspired by OPNsense Live View; original implementation
|
|
* for OpenWrt (Apache-2.0). See docs/fwlive-ui-design-target.md in the fwlive repo.
|
|
*/
|
|
'require view';
|
|
'require poll';
|
|
'require rpc';
|
|
'require fwlive.log as log';
|
|
'require fwlive.constants as constants';
|
|
'require fwlive.css as css';
|
|
'require fwlive.tint as tint';
|
|
'require fwlive.chips as chips';
|
|
'require fwlive.logging as logging';
|
|
'require fwlive.table as table';
|
|
'require fwlive.buffer as buffer';
|
|
'require fwlive.hostname as hostname';
|
|
'require fwlive.proto as proto';
|
|
|
|
const callFwlivePoll = rpc.declare({
|
|
object: 'fwlive',
|
|
method: 'poll',
|
|
params: ['addresses']
|
|
/* Full reply object kept so reply.error reaches fetchEntries (#233). */
|
|
});
|
|
|
|
const callFwliveRules = rpc.declare({
|
|
object: 'fwlive',
|
|
method: 'rules'
|
|
});
|
|
|
|
const callFwliveResolve = rpc.declare({
|
|
object: 'fwlive',
|
|
method: 'resolve',
|
|
params: ['addresses']
|
|
/* Full reply kept so disabled:"load" reaches the view (#306 Layer 2). */
|
|
});
|
|
|
|
const callFwliveLoggingStatus = rpc.declare({
|
|
object: 'fwlive',
|
|
method: 'logging_status',
|
|
expect: {
|
|
'': {
|
|
wan_zone: null,
|
|
wan_log: false,
|
|
wan_log_limit: null,
|
|
nf_log_ipv4: false,
|
|
nf_log_ipv6: false,
|
|
ready: false,
|
|
blockers: [],
|
|
warnings: []
|
|
}
|
|
}
|
|
});
|
|
|
|
const callFwliveEnableLogging = rpc.declare({
|
|
object: 'fwlive',
|
|
method: 'enable_wan_logging',
|
|
expect: { '': { ok: false, changed: false, wan_zone: null } }
|
|
});
|
|
|
|
const callFwliveDisableLogging = rpc.declare({
|
|
object: 'fwlive',
|
|
method: 'disable_wan_logging',
|
|
expect: { '': { ok: false, changed: false, wan_zone: null } }
|
|
});
|
|
|
|
function storedValue(key, fallback) {
|
|
try {
|
|
const v = localStorage.getItem(key);
|
|
return v === null ? fallback : v;
|
|
} catch (e) {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function storeValue(key, value) {
|
|
try {
|
|
localStorage.setItem(key, value);
|
|
} catch (e) {
|
|
/* private mode / no storage */
|
|
}
|
|
}
|
|
|
|
function optionNodes(pairs) {
|
|
const opts = [];
|
|
for (let i = 0; i < pairs.length; i++)
|
|
opts.push(E('option', { 'value': pairs[i][0] }, [pairs[i][1]]));
|
|
return opts;
|
|
}
|
|
|
|
return view.extend({
|
|
rowLimit: constants.DEFAULT_ROW_LIMIT,
|
|
entries: [],
|
|
sessionSeen: null,
|
|
pauseBufferLoading: false,
|
|
paused: false,
|
|
/* One-shot: first live poll after unpause merges instead of replacing (#43). */
|
|
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,
|
|
/* Last non-off palette so toggling tint back on restores Classic/Accessible. */
|
|
rowTintPalette: 'classic',
|
|
hostnameCache: null,
|
|
hostnameFailed: null,
|
|
resolveInFlight: false,
|
|
resolveGeneration: 0,
|
|
lastPollError: false,
|
|
lastRulesError: null,
|
|
lastRenderedRowCount: 0,
|
|
lastRenderedHeadId: '',
|
|
followLive: true,
|
|
rulesMap: {},
|
|
firewallBackend: 'nft',
|
|
viewMode: 'simple',
|
|
expandedRowId: null,
|
|
loggingStatus: null,
|
|
loggingBusy: false,
|
|
loggingNotice: '',
|
|
/* Session-only dismiss of first-run consent (Not now without checkbox). */
|
|
consentDismissedSession: false,
|
|
_loggingToolbarSig: '',
|
|
_loggingEmptySig: '',
|
|
tintFallbackActive: false,
|
|
tintProbeDone: false,
|
|
|
|
FILTER_CHIP_FIELDS: [
|
|
{ key: 'q', label: 'search' },
|
|
{ key: 'action', label: 'action' },
|
|
{ key: 'interface', label: 'iface' },
|
|
{ key: 'proto', label: 'proto' },
|
|
{ key: 'src', label: 'src' },
|
|
{ key: 'dst', label: 'dst' },
|
|
{ key: 'sport', label: 'sport' },
|
|
{ key: 'dport', label: 'dport' }
|
|
],
|
|
|
|
readFilters() {
|
|
const val = (id) => {
|
|
const el = document.getElementById(id);
|
|
return el ? el.value || '' : '';
|
|
};
|
|
return {
|
|
q: val('fwlive-q').trim(),
|
|
action: val('fwlive-action'),
|
|
interface: val('fwlive-interface'),
|
|
proto: proto.readProtoFilter(),
|
|
src: val('fwlive-src').trim(),
|
|
dst: val('fwlive-dst').trim(),
|
|
sport: val('fwlive-sport').trim(),
|
|
dport: val('fwlive-dport').trim()
|
|
};
|
|
},
|
|
|
|
updateHash(filters) {
|
|
const parts = Object.keys(filters)
|
|
.filter((k) => filters[k])
|
|
.map((k) => '%s=%s'.format(encodeURIComponent(k), encodeURIComponent(filters[k])));
|
|
if (this.rowLimit !== constants.DEFAULT_ROW_LIMIT)
|
|
parts.push('limit=%s'.format(encodeURIComponent(this.rowLimit)));
|
|
if (this.viewMode === 'detailed') parts.push('view=detailed');
|
|
location.hash = parts.join('&');
|
|
},
|
|
|
|
applyHash() {
|
|
if (!location.hash || location.hash.length < 2) return;
|
|
|
|
const entries = location.hash.substring(1).split('&');
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const kv = entries[i].split('=');
|
|
if (kv.length !== 2) continue;
|
|
const key = decodeURIComponent(kv[0]);
|
|
const val = decodeURIComponent(kv[1]);
|
|
if (key === 'limit') {
|
|
const n = parseInt(val, 10);
|
|
if (isFinite(n) && constants.ROW_LIMIT_OPTIONS.indexOf(n) >= 0) {
|
|
this.applyRowLimit(n);
|
|
this.saveRowLimit();
|
|
}
|
|
continue;
|
|
}
|
|
if (key === 'view') {
|
|
if (val === 'advanced' || val === 'detailed') this.viewMode = 'detailed';
|
|
else if (val === 'simple') this.viewMode = 'simple';
|
|
continue;
|
|
}
|
|
const el = document.getElementById('fwlive-' + key);
|
|
if (key === 'proto') {
|
|
proto.setProtoFilterValue(val);
|
|
continue;
|
|
}
|
|
if (el) el.value = val;
|
|
}
|
|
},
|
|
|
|
readViewMode() {
|
|
const v = storedValue('fwlive-view-mode', null);
|
|
if (v === 'advanced' || v === 'detailed') return 'detailed';
|
|
if (v === 'simple') return 'simple';
|
|
return 'simple';
|
|
},
|
|
|
|
saveViewMode() {
|
|
storeValue('fwlive-view-mode', this.viewMode);
|
|
},
|
|
|
|
readShowHostnames() {
|
|
return storedValue('fwlive-show-hostnames', '') === '1';
|
|
},
|
|
|
|
saveShowHostnames() {
|
|
storeValue('fwlive-show-hostnames', this.showHostnames ? '1' : '0');
|
|
},
|
|
|
|
readRowTint() {
|
|
const v = storedValue('fwlive-row-tint', null);
|
|
if (v === null) return constants.DEFAULT_ROW_TINT;
|
|
/* Migrate pre-mode checkbox storage. */
|
|
if (v === '1' || v === 'true') return 'classic';
|
|
if (v === '0' || v === 'false') return 'off';
|
|
if (constants.ROW_TINT_OPTIONS.indexOf(v) >= 0) return v;
|
|
return constants.DEFAULT_ROW_TINT;
|
|
},
|
|
|
|
saveRowTint() {
|
|
storeValue('fwlive-row-tint', this.rowTint);
|
|
},
|
|
|
|
rowTintPaletteOptions() {
|
|
return optionNodes([
|
|
['classic', _('Classic (green/red)')],
|
|
['accessible', _('Accessible (teal/orange)')]
|
|
]);
|
|
},
|
|
|
|
rowTintEnabled() {
|
|
return this.rowTint === 'classic' || this.rowTint === 'accessible';
|
|
},
|
|
|
|
applyRowTintMode() {
|
|
const map = document.querySelector('.fwlive-map');
|
|
if (!map) return;
|
|
|
|
map.setAttribute('data-row-tint', this.rowTint);
|
|
if (!this.rowTintEnabled() && this.tintFallbackActive) this.clearTintFallback(map);
|
|
},
|
|
|
|
onRowTintEnabledChange(ev) {
|
|
const on = !!(ev && ev.target && ev.target.checked);
|
|
if (on) {
|
|
const pal = this.rowTintPalette === 'accessible' ? 'accessible' : 'classic';
|
|
this.rowTintPalette = pal;
|
|
this.rowTint = pal;
|
|
} else {
|
|
if (this.rowTintEnabled()) this.rowTintPalette = this.rowTint;
|
|
this.rowTint = 'off';
|
|
}
|
|
this.commitRowTintChange();
|
|
},
|
|
|
|
commitRowTintChange() {
|
|
this.saveRowTint();
|
|
this.tintProbeDone = false;
|
|
this.applyRowTintMode();
|
|
this.updateRowTintUi();
|
|
this.renderRows(true);
|
|
},
|
|
|
|
onRowTintPaletteChange(ev) {
|
|
const v = ev && ev.target ? ev.target.value : 'classic';
|
|
const pal = v === 'accessible' ? 'accessible' : 'classic';
|
|
this.rowTintPalette = pal;
|
|
if (!this.rowTintEnabled()) return;
|
|
this.rowTint = pal;
|
|
this.commitRowTintChange();
|
|
},
|
|
|
|
updateRowTintUi() {
|
|
const on = this.rowTintEnabled();
|
|
const cb = document.getElementById('fwlive-row-tint-toggle');
|
|
if (cb) cb.checked = on;
|
|
|
|
const wrap = document.getElementById('fwlive-row-tint-palette-wrap');
|
|
if (wrap) {
|
|
if (on) wrap.classList.remove('fwlive-hidden');
|
|
else wrap.classList.add('fwlive-hidden');
|
|
}
|
|
|
|
const tintSel = document.getElementById('fwlive-row-tint');
|
|
if (tintSel) {
|
|
const pal = on ? this.rowTint : this.rowTintPalette;
|
|
tintSel.value = pal === 'accessible' ? 'accessible' : 'classic';
|
|
}
|
|
},
|
|
|
|
actionRowTintClass(action) {
|
|
const a = (action || '').toLowerCase();
|
|
if (a === 'pass') return 'fwlive-row-pass';
|
|
if (a === 'drop' || a === 'reject' || a === 'block') return 'fwlive-row-deny';
|
|
return '';
|
|
},
|
|
|
|
applyTintFallback(map) {
|
|
if (!map) return;
|
|
|
|
const pair = tint.hexPairForMode(this.rowTint);
|
|
map.style.setProperty('--fwlive-pass-color', pair.pass);
|
|
map.style.setProperty('--fwlive-deny-color', pair.deny);
|
|
map.setAttribute('data-tint-fallback', '1');
|
|
this.tintFallbackActive = true;
|
|
this.updateTintWarnUi();
|
|
},
|
|
|
|
clearTintFallback(map) {
|
|
if (!map) return;
|
|
|
|
map.style.removeProperty('--fwlive-pass-color');
|
|
map.style.removeProperty('--fwlive-deny-color');
|
|
map.removeAttribute('data-tint-fallback');
|
|
this.tintFallbackActive = false;
|
|
this.updateTintWarnUi();
|
|
},
|
|
|
|
updateTintWarnUi() {
|
|
const el = document.getElementById('fwlive-tint-warn');
|
|
if (!el) return;
|
|
|
|
el.style.display = this.tintFallbackActive ? 'inline' : 'none';
|
|
},
|
|
|
|
probeRowTintPaint() {
|
|
const map = document.querySelector('.fwlive-map');
|
|
const body = document.querySelector('#fwlive-table tbody');
|
|
if (!map || !body) return;
|
|
|
|
/* Prefer a non-alt row — zebra --background-color-medium can look "tinted" when transparent. */
|
|
let tr = body.querySelector('tr:not(.fwlive-row-alt)');
|
|
if (!tr) tr = body.querySelector('tr');
|
|
if (!tr) return;
|
|
|
|
const td = tr.querySelector('td');
|
|
if (!td || typeof getComputedStyle !== 'function') return;
|
|
|
|
const hadPass = tr.classList.contains('fwlive-row-pass');
|
|
const hadDeny = tr.classList.contains('fwlive-row-deny');
|
|
const probeClass = hadDeny ? 'fwlive-row-deny' : 'fwlive-row-pass';
|
|
|
|
tr.classList.remove('fwlive-row-pass', 'fwlive-row-deny');
|
|
const offBg = getComputedStyle(td).backgroundColor;
|
|
tr.classList.add(probeClass);
|
|
const onBg = getComputedStyle(td).backgroundColor;
|
|
|
|
tr.classList.remove('fwlive-row-pass', 'fwlive-row-deny');
|
|
if (hadPass) tr.classList.add('fwlive-row-pass');
|
|
if (hadDeny) tr.classList.add('fwlive-row-deny');
|
|
|
|
const passToken = getComputedStyle(map).getPropertyValue('--fwlive-pass-color').trim();
|
|
const paintDelta = tint.cssColorPaintDelta(onBg, offBg);
|
|
map.setAttribute('data-tint-probe-delta', String(paintDelta));
|
|
map.setAttribute('data-tint-probe-on', onBg || '');
|
|
map.setAttribute('data-tint-probe-off', offBg || '');
|
|
const broken = tint.tintShouldEngageFallback({
|
|
paintDelta: paintDelta,
|
|
tokenResolved: !!passToken,
|
|
minDelta: tint.PAINT_DELTA_MIN
|
|
});
|
|
|
|
this.tintProbeDone = true;
|
|
if (broken) this.applyTintFallback(map);
|
|
else if (this.tintFallbackActive) this.clearTintFallback(map);
|
|
else this.updateTintWarnUi();
|
|
},
|
|
|
|
isLikelyIp(addr) {
|
|
if (!addr) return false;
|
|
|
|
return /^[\da-fA-F:.]+$/.test(addr);
|
|
},
|
|
|
|
activeColumns() {
|
|
return constants.COLUMN_SETS[this.viewMode] || constants.COLUMN_SETS.simple;
|
|
},
|
|
|
|
setViewMode(mode) {
|
|
if (constants.VIEW_MODES.indexOf(mode) < 0 || mode === this.viewMode) return;
|
|
|
|
this.viewMode = mode;
|
|
this.expandedRowId = null;
|
|
this.saveViewMode();
|
|
this.updateDetailToggleUi();
|
|
this.renderThead();
|
|
this.updateHash(this.readFilters());
|
|
this.renderRows(true);
|
|
},
|
|
|
|
updateDetailToggleUi() {
|
|
const simpleBtn = document.getElementById('fwlive-view-simple');
|
|
const detailBtn = document.getElementById('fwlive-view-detail');
|
|
const detailed = this.viewMode === 'detailed';
|
|
if (simpleBtn) simpleBtn.setAttribute('aria-pressed', detailed ? 'false' : 'true');
|
|
if (detailBtn) detailBtn.setAttribute('aria-pressed', detailed ? 'true' : 'false');
|
|
|
|
const map = document.querySelector('.fwlive-map');
|
|
if (map) map.setAttribute('data-view', this.viewMode);
|
|
|
|
this.updateFilterPanelUi();
|
|
},
|
|
|
|
updateFilterPanelUi() {
|
|
const details = document.getElementById('fwlive-more-filters');
|
|
if (!details) return;
|
|
|
|
if (this.viewMode === 'detailed') {
|
|
details.open = true;
|
|
return;
|
|
}
|
|
|
|
const filters = this.readFilters();
|
|
const hasExtra = !!(
|
|
filters.interface ||
|
|
filters.src ||
|
|
filters.dst ||
|
|
filters.sport ||
|
|
filters.dport
|
|
);
|
|
if (hasExtra) details.open = true;
|
|
},
|
|
|
|
onRowClick(rowId, ev) {
|
|
if (this.viewMode !== 'simple') return;
|
|
|
|
if (ev && ev.target && ev.target.closest && ev.target.closest('a.fwlive-filter-link'))
|
|
return;
|
|
|
|
this.expandedRowId = this.expandedRowId === rowId ? null : rowId;
|
|
this.renderRows(true);
|
|
},
|
|
|
|
renderThead() {
|
|
const el = document.getElementById('fwlive-table');
|
|
if (!el) return;
|
|
|
|
table.renderThead(el, { columns: this.activeColumns().slice() }, {});
|
|
},
|
|
|
|
async loadRulesMap() {
|
|
try {
|
|
const res = await callFwliveRules();
|
|
this.rulesMap = (res && res.rules) || {};
|
|
this.firewallBackend = (res && res.backend) || 'nft';
|
|
/* Bounds / mktemp failures are reply.error — same idea as poll (#245). */
|
|
this.lastRulesError = (res && res.error) || null;
|
|
if (this.lastRulesError) console.warn('fwlive rules map error:', this.lastRulesError);
|
|
} catch (e) {
|
|
this.rulesMap = {};
|
|
this.firewallBackend = 'nft';
|
|
this.lastRulesError = 'rules_unavailable';
|
|
}
|
|
this.updateBackendUi();
|
|
},
|
|
|
|
backendDisplayLabel() {
|
|
if (this.firewallBackend === 'iptables') return _('using iptables');
|
|
if (this.firewallBackend === 'nft') return _('using fw4');
|
|
return '';
|
|
},
|
|
|
|
updateBackendUi() {
|
|
const map = document.querySelector('.fwlive-map');
|
|
if (map) map.setAttribute('data-backend', this.firewallBackend || 'unknown');
|
|
|
|
const label = document.getElementById('fwlive-backend');
|
|
if (label) {
|
|
let text = this.backendDisplayLabel();
|
|
let degraded = false;
|
|
if (this.lastRulesError) {
|
|
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');
|
|
text = text ? text + ' \u00b7 ' + err : err;
|
|
degraded = true;
|
|
}
|
|
const warnings = (this.loggingStatus && this.loggingStatus.warnings) || [];
|
|
if (warnings.indexOf('timeout_missing') >= 0) {
|
|
const warn = _('Limited diagnostics — timeout command missing');
|
|
text = text ? text + ' \u00b7 ' + warn : warn;
|
|
degraded = true;
|
|
}
|
|
label.textContent = text;
|
|
label.classList.toggle('fwlive-backend-warn', degraded);
|
|
}
|
|
|
|
this.updateEmptyStateUi();
|
|
},
|
|
|
|
async loadLoggingStatus() {
|
|
try {
|
|
this.loggingStatus = await callFwliveLoggingStatus();
|
|
} catch (e) {
|
|
this.loggingStatus = null;
|
|
}
|
|
this.updateBackendUi();
|
|
this.updateLoggingToolbarUi();
|
|
this.updateEmptyStateUi();
|
|
},
|
|
|
|
async handleEnableLogging() {
|
|
if (this.loggingBusy) return;
|
|
|
|
this.loggingBusy = true;
|
|
this.loggingNotice = '';
|
|
this.updateEmptyStateUi();
|
|
this.updateLoggingToolbarUi();
|
|
|
|
try {
|
|
const res = await callFwliveEnableLogging();
|
|
if (!res || !res.ok) {
|
|
if (res && res.error === 'nf_log_missing')
|
|
this.loggingNotice = _(
|
|
'Cannot enable logging until kernel log modules are installed.'
|
|
);
|
|
else if (res && res.error === 'firewall_changes_pending')
|
|
this.loggingNotice = _(
|
|
'Another change is staged for the firewall; apply or revert it first.'
|
|
);
|
|
else this.loggingNotice = _('Could not enable logging.');
|
|
await this.loadLoggingStatus();
|
|
return;
|
|
}
|
|
|
|
if (res.changed)
|
|
this.loggingNotice = _(
|
|
'WAN drop/reject logging is on. Blocked inbound traffic should appear here as it happens — not normal LAN browsing.'
|
|
);
|
|
else this.loggingNotice = _('WAN logging is already enabled.');
|
|
|
|
logging.persistConsentDismissed();
|
|
await this.loadLoggingStatus();
|
|
} catch (e) {
|
|
this.loggingNotice = _('Administrator access is required to enable logging.');
|
|
await this.loadLoggingStatus();
|
|
} finally {
|
|
this.loggingBusy = false;
|
|
this.updateEmptyStateUi();
|
|
this.updateLoggingToolbarUi();
|
|
}
|
|
},
|
|
|
|
async handleDisableLogging() {
|
|
if (this.loggingBusy) return;
|
|
|
|
this.loggingBusy = true;
|
|
this.loggingNotice = '';
|
|
this.updateLoggingToolbarUi();
|
|
|
|
try {
|
|
const res = await callFwliveDisableLogging();
|
|
if (!res || !res.ok) {
|
|
if (res && res.error === 'firewall_changes_pending')
|
|
this.loggingNotice = _(
|
|
'Another change is staged for the firewall; apply or revert it first.'
|
|
);
|
|
else this.loggingNotice = _('Could not disable logging.');
|
|
await this.loadLoggingStatus();
|
|
return;
|
|
}
|
|
|
|
if (res.changed) this.loggingNotice = _('WAN drop/reject logging is off.');
|
|
await this.loadLoggingStatus();
|
|
} catch (e) {
|
|
this.loggingNotice = _('Administrator access is required to disable logging.');
|
|
await this.loadLoggingStatus();
|
|
} finally {
|
|
this.loggingBusy = false;
|
|
this.updateEmptyStateUi();
|
|
this.updateLoggingToolbarUi();
|
|
}
|
|
},
|
|
|
|
shouldShowLoggingConsent() {
|
|
const st = this.loggingStatus;
|
|
if (!st || st.wan_log) return false;
|
|
const blockers = st.blockers || [];
|
|
if (blockers.length) return false;
|
|
if (logging.consentDismissedPermanent()) return false;
|
|
if (this.consentDismissedSession) return false;
|
|
return true;
|
|
},
|
|
|
|
handleDismissConsent(persist) {
|
|
if (persist) logging.persistConsentDismissed();
|
|
this.consentDismissedSession = true;
|
|
this._loggingEmptySig = '';
|
|
this.updateEmptyStateUi();
|
|
},
|
|
|
|
loggingState() {
|
|
return {
|
|
loggingStatus: this.loggingStatus,
|
|
loggingBusy: this.loggingBusy,
|
|
entriesLength: this.entries.length,
|
|
loggingNotice: this.loggingNotice,
|
|
showConsent: this.shouldShowLoggingConsent()
|
|
};
|
|
},
|
|
|
|
/* Stable signature so poll/renderRows does not wipe the logging button every
|
|
second (destroys the node between mousedown and click → needs a 2nd click). */
|
|
loggingUiSignature() {
|
|
const st = this.loggingStatus;
|
|
/* Sort blockers so unstable backend order does not force a rebuild. */
|
|
const blockers = st && st.blockers ? st.blockers.slice().sort().join(',') : '';
|
|
return [
|
|
st ? (st.wan_log ? '1' : '0') : 'x',
|
|
st ? String(st.wan_log_limit || '') : '',
|
|
blockers,
|
|
this.loggingBusy ? '1' : '0',
|
|
this.loggingNotice || '',
|
|
this.shouldShowLoggingConsent() ? 'c1' : 'c0'
|
|
].join('|');
|
|
},
|
|
|
|
updateLoggingToolbarUi() {
|
|
const bar = document.getElementById('fwlive-logging-bar');
|
|
if (!bar) return;
|
|
|
|
const sig = this.loggingUiSignature();
|
|
if (sig === this._loggingToolbarSig) return;
|
|
|
|
logging.renderToolbar(bar, this.loggingState(), {
|
|
onEnable: () => this.handleEnableLogging(),
|
|
onDisable: () => this.handleDisableLogging()
|
|
});
|
|
this._loggingToolbarSig = sig;
|
|
},
|
|
|
|
updateEmptyStateUi() {
|
|
const empty = document.getElementById('fwlive-empty');
|
|
if (!empty) return;
|
|
|
|
const sig = this.loggingUiSignature();
|
|
if (sig === this._loggingEmptySig) return;
|
|
|
|
const visible = empty.style.display !== 'none';
|
|
logging.renderEmptyState(empty, this.loggingState(), {
|
|
onEnable: () => this.handleEnableLogging(),
|
|
onDismissConsent: (persist) => this.handleDismissConsent(persist)
|
|
});
|
|
if (visible) empty.style.display = 'block';
|
|
this._loggingEmptySig = sig;
|
|
},
|
|
|
|
resolveRuleLabel(hint) {
|
|
if (!hint) return '';
|
|
|
|
if (this.rulesMap[hint]) return this.rulesMap[hint];
|
|
|
|
const slug = hint.toLowerCase();
|
|
if (this.rulesMap[slug]) return this.rulesMap[slug];
|
|
|
|
return log.formatRuleLabel(hint);
|
|
},
|
|
|
|
enrichEntry(row) {
|
|
row.rule_label = this.resolveRuleLabel(row.rule_hint);
|
|
return row;
|
|
},
|
|
|
|
normalizePollBatch(raw) {
|
|
const normalized = [];
|
|
const seen = {};
|
|
let pollNew = 0;
|
|
|
|
for (let i = 0; i < raw.length; i++) {
|
|
if (!log.isFirewallEvent(raw[i])) continue;
|
|
|
|
const row = this.enrichEntry(log.normalizeEntry(raw[i]));
|
|
if (seen[row.id]) continue;
|
|
seen[row.id] = true;
|
|
if (this.rememberSessionId(row.id)) pollNew++;
|
|
normalized.push(row);
|
|
}
|
|
|
|
return { rows: normalized, pollNew: pollNew };
|
|
},
|
|
|
|
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
|
|
* reads the ring cap so the buffer can catch up. */
|
|
const fetchLines = this.paused
|
|
? constants.FETCH_LINES_MAX
|
|
: Math.min(Math.max(this.rowLimit * 4, 100), constants.FETCH_LINES_MAX);
|
|
reply = await callFwlivePoll({
|
|
addresses: [String(fetchLines)]
|
|
});
|
|
} catch (e) {
|
|
errored = true;
|
|
reply = null;
|
|
}
|
|
|
|
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;
|
|
|
|
/* Oldest-first ring buffer; filteredRows() reverses for newest-first display. */
|
|
this.entries = buffer.applyFetchedEntries(this.entries, batch.rows, {
|
|
paused: this.paused,
|
|
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) {
|
|
if (!this.sessionSeen) this.sessionSeen = new Set();
|
|
if (this.sessionSeen.has(id)) return false;
|
|
|
|
this.sessionSeen.add(id);
|
|
const cap = Math.max(this.rowLimit * 2, constants.FETCH_LINES_MAX);
|
|
while (this.sessionSeen.size > cap) {
|
|
const oldest = this.sessionSeen.values().next().value;
|
|
this.sessionSeen.delete(oldest);
|
|
}
|
|
return true;
|
|
},
|
|
|
|
ingestCap() {
|
|
return buffer.ingestCap(this.paused, this.rowLimit, constants.FETCH_LINES_MAX);
|
|
},
|
|
|
|
statusSuffix() {
|
|
const bits = [];
|
|
if (this.paused) {
|
|
if (this.pauseBufferLoading) bits.push(_('loading buffer'));
|
|
}
|
|
|
|
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;
|
|
|
|
const elapsed = now - this.renderBucketMs;
|
|
this.renderBucketMs = now;
|
|
this.renderBucket = Math.min(
|
|
constants.RENDER_CAP_PER_SEC,
|
|
this.renderBucket + (elapsed * constants.RENDER_CAP_PER_SEC) / 1000
|
|
);
|
|
},
|
|
|
|
consumeRenderBudget(cost) {
|
|
if (cost <= 0) {
|
|
this.floodSuppressed = false;
|
|
return true;
|
|
}
|
|
|
|
this.refillRenderBucket();
|
|
if (cost <= this.renderBucket) {
|
|
this.renderBucket -= cost;
|
|
this.floodSuppressed = false;
|
|
return true;
|
|
}
|
|
|
|
this.floodSuppressed = true;
|
|
return false;
|
|
},
|
|
|
|
/** Charge by new log events per poll, not full table size (avoids false throttle at high limits). */
|
|
renderBudgetCost(rows) {
|
|
const count = rows ? rows.length : 0;
|
|
const headId = count ? rows[0].id : '';
|
|
|
|
if (!count && !this.lastRenderedRowCount) return 0;
|
|
|
|
if (count === this.lastRenderedRowCount && headId === this.lastRenderedHeadId) return 0;
|
|
|
|
/*
|
|
* Visible row-count changes (Limit up/down, trim) must not be skipped by
|
|
* the flood throttle — otherwise status can show N/limit while the table
|
|
* still paints the previous size under ping -A.
|
|
*/
|
|
if (count !== this.lastRenderedRowCount) return 1;
|
|
|
|
return Math.max(1, this.lastPollNewEvents || 1);
|
|
},
|
|
|
|
updateFloodBanner() {
|
|
const el = document.getElementById('fwlive-flood');
|
|
if (!el) return;
|
|
if (!el.style) el.style = { display: '' };
|
|
|
|
if (this.floodSuppressed) {
|
|
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.'
|
|
);
|
|
} else {
|
|
el.style.display = 'none';
|
|
el.textContent = '';
|
|
}
|
|
},
|
|
|
|
filteredRows() {
|
|
const filters = this.readFilters();
|
|
return this.entries
|
|
.filter((row) => log.matchesFilter(row, filters))
|
|
.slice(-this.rowLimit)
|
|
.reverse();
|
|
},
|
|
|
|
compactCountText(matchCount) {
|
|
const stored = this.entries.length;
|
|
const limit = this.rowLimit;
|
|
const suffix = this.statusSuffix();
|
|
/* While paused the buffer can grow past the display limit — count matches
|
|
* over the full buffer so "matching" is not capped at visibleRows (#83). */
|
|
let shown = matchCount;
|
|
if (this.paused) {
|
|
const filters = this.readFilters();
|
|
shown = this.entries.filter((row) => log.matchesFilter(row, filters)).length;
|
|
}
|
|
|
|
if (this.pauseBufferLoading && stored === 0) return _('loading…') + suffix;
|
|
|
|
if (shown) return _('%d matching · %d/%d stored').format(shown, stored, limit) + suffix;
|
|
|
|
if (stored) return _('0 matching · %d/%d stored').format(stored, limit) + suffix;
|
|
|
|
return '';
|
|
},
|
|
|
|
updateStatus(rows) {
|
|
const status = document.getElementById('fwlive-status');
|
|
if (!status) return;
|
|
|
|
const matchCount = rows ? rows.length : this.filteredRows().length;
|
|
const suffix = this.statusSuffix();
|
|
|
|
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() {
|
|
const n = parseInt(storedValue('fwlive-row-limit', null), 10);
|
|
if (constants.ROW_LIMIT_OPTIONS.indexOf(n) >= 0) return n;
|
|
return constants.DEFAULT_ROW_LIMIT;
|
|
},
|
|
|
|
saveRowLimit() {
|
|
storeValue('fwlive-row-limit', String(this.rowLimit));
|
|
},
|
|
|
|
applyRowLimit(limit) {
|
|
const n =
|
|
constants.ROW_LIMIT_OPTIONS.indexOf(limit) >= 0 ? limit : constants.DEFAULT_ROW_LIMIT;
|
|
this.rowLimit = n;
|
|
if (!this.paused && this.entries.length > n) this.entries = this.entries.slice(-n);
|
|
},
|
|
|
|
updateStreamControlsUi() {
|
|
const map = document.querySelector('.fwlive-map');
|
|
const dot = document.getElementById('fwlive-watch-dot');
|
|
const label = document.getElementById('fwlive-watch-label');
|
|
const pauseBtn = document.getElementById('fwlive-pause');
|
|
const sel = document.getElementById('fwlive-limit');
|
|
const hostCb = document.getElementById('fwlive-show-hostnames');
|
|
|
|
if (map) {
|
|
if (this.paused) map.classList.add('fwlive-watch-paused');
|
|
else map.classList.remove('fwlive-watch-paused');
|
|
}
|
|
if (dot) {
|
|
if (this.paused) dot.classList.remove('fwlive-dot-on');
|
|
else dot.classList.add('fwlive-dot-on');
|
|
}
|
|
if (label) label.textContent = this.paused ? _('Paused') : _('Watching');
|
|
if (pauseBtn) pauseBtn.textContent = this.paused ? _('Resume') : _('Pause');
|
|
if (sel) sel.value = String(this.rowLimit);
|
|
if (hostCb) hostCb.checked = !!this.showHostnames;
|
|
this.updateRowTintUi();
|
|
},
|
|
|
|
onShowHostnamesChange(ev) {
|
|
this.showHostnames = !!(ev && ev.target && ev.target.checked);
|
|
this.saveShowHostnames();
|
|
/* Invalidate any in-flight resolve from the previous toggle state. */
|
|
this.resolveGeneration = (this.resolveGeneration || 0) + 1;
|
|
this.resolveInFlight = false;
|
|
|
|
if (this.showHostnames) this.resolveHostnamesForEntries(this.filteredRows());
|
|
else this.renderRows(true);
|
|
},
|
|
|
|
onPauseClick() {
|
|
const wasPaused = this.paused;
|
|
this.paused = !this.paused;
|
|
this.updateStreamControlsUi();
|
|
|
|
if (!wasPaused && this.paused) {
|
|
this.pauseBufferLoading = true;
|
|
this.updateStatus();
|
|
this.fetchEntries()
|
|
.catch(function () {})
|
|
.finally(
|
|
function () {
|
|
this.pauseBufferLoading = false;
|
|
this.updateStatus();
|
|
}.bind(this)
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (wasPaused && !this.paused) {
|
|
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(() => {
|
|
/* A hide/show bump abandons this epoch; the catch-up poll paints. */
|
|
if (epoch === this.pollEpoch) this.renderRows(true);
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
},
|
|
|
|
onRowLimitChange(ev) {
|
|
const n = parseInt(ev && ev.target ? ev.target.value : '', 10);
|
|
if (!isFinite(n) || constants.ROW_LIMIT_OPTIONS.indexOf(n) < 0) return;
|
|
|
|
this.applyRowLimit(n);
|
|
this.saveRowLimit();
|
|
this.updateHash(this.readFilters());
|
|
/* Reset flood throttle so Limit changes paint even during ping -A. */
|
|
this.renderBucket = constants.RENDER_CAP_PER_SEC;
|
|
this.floodSuppressed = false;
|
|
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);
|
|
})
|
|
.finally(() => {
|
|
this.pendingForceRender = false;
|
|
});
|
|
},
|
|
|
|
limitSelectOptions() {
|
|
const pairs = [];
|
|
for (let i = 0; i < constants.ROW_LIMIT_OPTIONS.length; i++) {
|
|
const n = constants.ROW_LIMIT_OPTIONS[i];
|
|
pairs.push([String(n), String(n)]);
|
|
}
|
|
return optionNodes(pairs);
|
|
},
|
|
|
|
/* G Grouped: Common / Also seen / Exclude — curated PROTO values from logs. */
|
|
protoSelectOptions() {
|
|
return [
|
|
E('option', { 'value': '' }, [_('Any protocol')]),
|
|
E('optgroup', { 'label': _('Common') }, [
|
|
E('option', { 'value': 'TCP' }, ['TCP']),
|
|
E('option', { 'value': 'UDP' }, ['UDP']),
|
|
E('option', { 'value': 'ICMP' }, ['ICMP']),
|
|
E('option', { 'value': 'ICMPV6' }, [_('ICMPv6')])
|
|
]),
|
|
E('optgroup', { 'label': _('Also seen') }, [
|
|
E('option', { 'value': 'IGMP' }, ['IGMP']),
|
|
E('option', { 'value': 'GRE' }, ['GRE']),
|
|
E('option', { 'value': 'ESP' }, ['ESP']),
|
|
E('option', { 'value': 'AH' }, ['AH']),
|
|
E('option', { 'value': 'SCTP' }, ['SCTP'])
|
|
]),
|
|
E('optgroup', { 'label': _('Exclude') }, [
|
|
E('option', { 'value': '!TCP' }, [_('not TCP')]),
|
|
E('option', { 'value': '!UDP' }, [_('not UDP')]),
|
|
E('option', { 'value': '!ICMP' }, [_('not ICMP')]),
|
|
E('option', { 'value': '!ICMPV6' }, [_('not ICMPv6')]),
|
|
E('option', { 'value': '!IGMP' }, [_('not IGMP')]),
|
|
E('option', { 'value': '!GRE' }, [_('not GRE')]),
|
|
E('option', { 'value': '!ESP' }, [_('not ESP')]),
|
|
E('option', { 'value': '!AH' }, [_('not AH')]),
|
|
E('option', { 'value': '!SCTP' }, [_('not SCTP')])
|
|
])
|
|
];
|
|
},
|
|
|
|
filterClick(field, value, ev) {
|
|
if (ev && ev.preventDefault) ev.preventDefault();
|
|
|
|
if (!value) return;
|
|
|
|
this.setFilterFieldValue(field, value);
|
|
this.onFilterInput();
|
|
},
|
|
|
|
collectIpsFromEntries(entries) {
|
|
const ips = new Set();
|
|
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const r = entries[i];
|
|
if (r.src && this.isLikelyIp(r.src)) ips.add(r.src);
|
|
if (r.dst && this.isLikelyIp(r.dst)) ips.add(r.dst);
|
|
}
|
|
|
|
return Array.from(ips);
|
|
},
|
|
|
|
async resolveHostnamesForEntries(entries) {
|
|
if (!this.showHostnames || this.resolveInFlight) return;
|
|
|
|
if (!this.hostnameCache) this.hostnameCache = new Map();
|
|
if (!this.hostnameFailed) this.hostnameFailed = new Map();
|
|
|
|
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];
|
|
if (this.hostnameCache.has(ip)) continue;
|
|
if (hostname.failIsHot(this.hostnameFailed, ip, now)) continue;
|
|
need.push(ip);
|
|
}
|
|
|
|
if (!need.length) return;
|
|
|
|
this.resolveGeneration = (this.resolveGeneration || 0) + 1;
|
|
const gen = this.resolveGeneration;
|
|
this.resolveInFlight = true;
|
|
|
|
try {
|
|
const res = await callFwliveResolve({ addresses: need });
|
|
if (gen !== this.resolveGeneration) return;
|
|
|
|
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++) {
|
|
const ip = need[i];
|
|
if (names[ip]) {
|
|
hostname.lruSet(this.hostnameCache, ip, names[ip]);
|
|
this.hostnameFailed.delete(ip);
|
|
updated = true;
|
|
} else {
|
|
hostname.failMark(this.hostnameFailed, ip, now);
|
|
}
|
|
}
|
|
|
|
this.updateAdaptiveBanner();
|
|
if (updated) this.scheduleRenderRows(true);
|
|
} catch (e) {
|
|
/* resolve unavailable — show IPs */
|
|
} finally {
|
|
if (gen === this.resolveGeneration) this.resolveInFlight = false;
|
|
}
|
|
},
|
|
|
|
setFilterFieldValue(field, value) {
|
|
if (field === 'proto') return proto.setProtoFilterValue(value);
|
|
|
|
const el = document.getElementById('fwlive-' + field);
|
|
if (!el) return false;
|
|
|
|
/* SELECT: ensure uncommon click-to-filter / hash values remain selectable. */
|
|
if (el.tagName === 'SELECT' && value) {
|
|
let found = false;
|
|
for (let i = 0; i < el.options.length; i++) {
|
|
if (el.options[i].value === value) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!found) el.appendChild(E('option', { 'value': value }, [value]));
|
|
}
|
|
|
|
el.value = value;
|
|
if (el.tagName === 'SELECT') el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
|
return true;
|
|
},
|
|
|
|
clearFilter(field, ev) {
|
|
if (ev && ev.preventDefault) ev.preventDefault();
|
|
|
|
this.setFilterFieldValue(field, '');
|
|
this.onFilterInput();
|
|
},
|
|
|
|
invertFilter(field, ev) {
|
|
if (ev) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
}
|
|
|
|
if (field === 'proto') {
|
|
const cur = proto.readProtoFilter();
|
|
if (!cur) return;
|
|
proto.setProtoFilterValue(log.toggleFilterNegation(cur));
|
|
this.onFilterInput();
|
|
return;
|
|
}
|
|
|
|
const el = document.getElementById('fwlive-' + field);
|
|
if (!el || !el.value) return;
|
|
|
|
this.setFilterFieldValue(field, log.toggleFilterNegation(el.value));
|
|
this.onFilterInput();
|
|
},
|
|
|
|
clearAllFilters(ev) {
|
|
if (ev && ev.preventDefault) ev.preventDefault();
|
|
|
|
for (let i = 0; i < this.FILTER_CHIP_FIELDS.length; i++) {
|
|
const el = document.getElementById('fwlive-' + this.FILTER_CHIP_FIELDS[i].key);
|
|
if (el) el.value = '';
|
|
}
|
|
const protoCustom = document.getElementById('fwlive-proto-custom');
|
|
if (protoCustom) protoCustom.value = '';
|
|
|
|
this.onFilterInput();
|
|
},
|
|
|
|
renderFilterChips() {
|
|
const bar = document.getElementById('fwlive-chips');
|
|
if (!bar) return;
|
|
|
|
chips.renderFilterChips(
|
|
bar,
|
|
{
|
|
filters: Object.assign({}, this.readFilters()),
|
|
chipFields: this.FILTER_CHIP_FIELDS
|
|
},
|
|
{
|
|
onInvert: (field, ev) => this.invertFilter(field, ev),
|
|
onClear: (field, ev) => this.clearFilter(field, ev),
|
|
onClearAll: (ev) => this.clearAllFilters(ev)
|
|
}
|
|
);
|
|
},
|
|
|
|
readMessageLayout() {
|
|
return storedValue('fwlive-msg-layout', '') === 'oneline' ? 'oneline' : 'wrap';
|
|
},
|
|
|
|
saveMessageLayout() {
|
|
storeValue('fwlive-msg-layout', this.messageLayout);
|
|
},
|
|
|
|
updateMessageLayoutUi() {
|
|
const scroll = document.getElementById('fwlive-scroll');
|
|
const wrapBtn = document.getElementById('fwlive-msg-wrap');
|
|
const onelineBtn = document.getElementById('fwlive-msg-oneline');
|
|
const oneline = this.messageLayout === 'oneline';
|
|
if (scroll) {
|
|
scroll.classList.toggle('fwlive-msg-oneline', oneline);
|
|
scroll.classList.toggle('fwlive-msg-wrap', !oneline);
|
|
}
|
|
if (wrapBtn) wrapBtn.setAttribute('aria-pressed', oneline ? 'false' : 'true');
|
|
if (onelineBtn) onelineBtn.setAttribute('aria-pressed', oneline ? 'true' : 'false');
|
|
},
|
|
|
|
setMessageLayout(layout) {
|
|
const next = layout === 'oneline' ? 'oneline' : 'wrap';
|
|
if (next === this.messageLayout) return;
|
|
|
|
this.messageLayout = next;
|
|
this.saveMessageLayout();
|
|
this.updateMessageLayoutUi();
|
|
if (this.paused) this.updateStatus();
|
|
else this.renderRows(true);
|
|
},
|
|
|
|
renderRows(force) {
|
|
const el = document.getElementById('fwlive-table');
|
|
if (!el || typeof el.querySelector !== 'function') return;
|
|
|
|
const body = el.querySelector('tbody');
|
|
const empty = document.getElementById('fwlive-empty');
|
|
const scroll = document.getElementById('fwlive-scroll');
|
|
this.updateHash(this.readFilters());
|
|
|
|
const rows = this.filteredRows();
|
|
const cost = force ? Math.max(1, rows.length) : this.renderBudgetCost(rows);
|
|
this.updateLoggingToolbarUi();
|
|
|
|
if (!force && cost === 0) {
|
|
this.floodSuppressed = false;
|
|
this.updateFloodBanner();
|
|
this.updateStatus(rows);
|
|
return;
|
|
}
|
|
|
|
if (!force && !this.consumeRenderBudget(cost)) {
|
|
this.updateFloodBanner();
|
|
this.updateStatus(rows);
|
|
return;
|
|
}
|
|
|
|
this.updateFloodBanner();
|
|
|
|
const prevScroll = scroll ? scroll.scrollTop : 0;
|
|
|
|
if (empty) empty.style.display = rows.length ? 'none' : 'block';
|
|
this.updateStatus(rows);
|
|
this.renderFilterChips();
|
|
|
|
table.renderRows(
|
|
body,
|
|
{
|
|
rows: rows.slice(),
|
|
columns: this.activeColumns().slice(),
|
|
viewMode: this.viewMode,
|
|
messageLayout: this.messageLayout,
|
|
expandedRowId: this.expandedRowId,
|
|
rowTint: this.rowTintEnabled(),
|
|
showHostnames: !!this.showHostnames,
|
|
hostnameCache: this.hostnameCache,
|
|
firewallBackend: this.firewallBackend
|
|
},
|
|
{
|
|
onRowClick: (rowId, ev) => this.onRowClick(rowId, ev),
|
|
onFilterClick: (field, value, ev) => this.filterClick(field, value, ev),
|
|
actionRowTintClass: (action) => this.actionRowTintClass(action)
|
|
}
|
|
);
|
|
|
|
if (scroll) {
|
|
if (!this.paused && this.followLive) scroll.scrollTop = 0;
|
|
else scroll.scrollTop = prevScroll;
|
|
}
|
|
|
|
this.lastRenderedRowCount = rows.length;
|
|
this.lastRenderedHeadId = rows.length ? rows[0].id : '';
|
|
|
|
if (rows.length && this.rowTintEnabled() && !this.tintProbeDone) {
|
|
const runProbe = () => {
|
|
if (!this.tintProbeDone) this.probeRowTintPaint();
|
|
};
|
|
if (typeof requestAnimationFrame === 'function')
|
|
requestAnimationFrame(() => requestAnimationFrame(runProbe));
|
|
else setTimeout(runProbe, 0);
|
|
} else if (!this.rowTintEnabled() && this.tintFallbackActive) {
|
|
this.clearTintFallback(document.querySelector('.fwlive-map'));
|
|
this.tintProbeDone = false;
|
|
}
|
|
},
|
|
|
|
onFilterInput() {
|
|
this.renderRows(true);
|
|
},
|
|
|
|
onFilterInputDebounced() {
|
|
if (this.filterInputTimer) clearTimeout(this.filterInputTimer);
|
|
this.filterInputTimer = setTimeout(
|
|
function () {
|
|
this.filterInputTimer = null;
|
|
this.onFilterInput();
|
|
}.bind(this),
|
|
100
|
|
);
|
|
},
|
|
|
|
onScrollArea(ev) {
|
|
const scroll = ev && ev.target;
|
|
if (!scroll || this.paused) return;
|
|
|
|
this.followLive = scroll.scrollTop < 8;
|
|
this.updateStatus();
|
|
},
|
|
|
|
attachHandlers() {
|
|
const scroll = document.getElementById('fwlive-scroll');
|
|
if (scroll) scroll.addEventListener('scroll', this.onScrollArea.bind(this));
|
|
|
|
const ids = ['q', 'action', 'interface', 'src', 'dst', 'sport', 'dport'];
|
|
for (let i = 0; i < ids.length; i++) {
|
|
const el = document.getElementById('fwlive-' + ids[i]);
|
|
if (!el) continue;
|
|
/* Text inputs: debounce rebuilds. Selects: apply immediately. */
|
|
if (el.tagName === 'SELECT')
|
|
el.addEventListener('change', this.onFilterInput.bind(this));
|
|
else el.addEventListener('input', this.onFilterInputDebounced.bind(this));
|
|
}
|
|
|
|
const protoSel = document.getElementById('fwlive-proto');
|
|
const protoCustom = document.getElementById('fwlive-proto-custom');
|
|
if (protoSel) {
|
|
protoSel.addEventListener(
|
|
'change',
|
|
function () {
|
|
if (protoCustom) protoCustom.value = '';
|
|
this.onFilterInput();
|
|
}.bind(this)
|
|
);
|
|
}
|
|
if (protoCustom) {
|
|
protoCustom.addEventListener(
|
|
'input',
|
|
function () {
|
|
if (protoCustom.value.trim() && protoSel) protoSel.value = '';
|
|
this.onFilterInputDebounced();
|
|
}.bind(this)
|
|
);
|
|
}
|
|
|
|
const pauseBtn = document.getElementById('fwlive-pause');
|
|
if (pauseBtn) pauseBtn.addEventListener('click', this.onPauseClick.bind(this));
|
|
|
|
const limitSel = document.getElementById('fwlive-limit');
|
|
if (limitSel) limitSel.addEventListener('change', this.onRowLimitChange.bind(this));
|
|
|
|
const hostCb = document.getElementById('fwlive-show-hostnames');
|
|
if (hostCb) hostCb.addEventListener('change', this.onShowHostnamesChange.bind(this));
|
|
|
|
const tintCb = document.getElementById('fwlive-row-tint-toggle');
|
|
if (tintCb) tintCb.addEventListener('change', this.onRowTintEnabledChange.bind(this));
|
|
|
|
const tintSel = document.getElementById('fwlive-row-tint');
|
|
if (tintSel) tintSel.addEventListener('change', this.onRowTintPaletteChange.bind(this));
|
|
},
|
|
|
|
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.scheduleRenderRows(!!this.pendingForceRender);
|
|
|
|
try {
|
|
await this.resolveHostnamesForEntries(this.filteredRows());
|
|
} 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 (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);
|
|
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);
|
|
} catch (e) {
|
|
/* poll gone */
|
|
}
|
|
this.pollFn = null;
|
|
}
|
|
if (this.filterInputTimer) {
|
|
clearTimeout(this.filterInputTimer);
|
|
this.filterInputTimer = null;
|
|
}
|
|
}.bind(this)
|
|
);
|
|
}
|
|
}
|
|
return Promise.all([this.loadRulesMap(), this.loadLoggingStatus()]).then(() =>
|
|
this.fetchEntries()
|
|
);
|
|
},
|
|
|
|
render() {
|
|
return E(
|
|
'div',
|
|
{ 'class': 'cbi-map fwlive-map', 'data-view': 'simple', 'data-row-tint': 'classic' },
|
|
[
|
|
E('style', {}, [css.styleText]),
|
|
E('div', { 'id': 'fwlive-title-row', 'class': 'fwlive-title-row' }, [
|
|
E('h2', {}, [
|
|
_('Firewall Live View'),
|
|
E('span', { 'id': 'fwlive-backend', 'class': 'fwlive-backend' }, [''])
|
|
]),
|
|
E('div', { 'class': 'fwlive-title-status' }, [
|
|
E(
|
|
'span',
|
|
{
|
|
'id': 'fwlive-watch-dot',
|
|
'class': 'fwlive-dot fwlive-dot-on',
|
|
'aria-hidden': 'true'
|
|
},
|
|
['']
|
|
),
|
|
E('span', { 'id': 'fwlive-watch-label', 'class': 'fwlive-watch-label' }, [
|
|
_('Watching')
|
|
]),
|
|
E('span', { 'id': 'fwlive-status', 'class': 'fwlive-status' }, ['']),
|
|
E(
|
|
'span',
|
|
{
|
|
'id': 'fwlive-tint-warn',
|
|
'class': 'fwlive-tint-warn',
|
|
'title': _(
|
|
'Row tint used a local color fallback because the active LuCI theme did not apply pass/deny backgrounds.'
|
|
)
|
|
},
|
|
[_('Theme tint fallback')]
|
|
)
|
|
])
|
|
]),
|
|
E('div', { 'id': 'fwlive-watch-strip', 'class': 'fwlive-watch-strip' }, [
|
|
E('div', { 'class': 'fwlive-watch-group' }, [
|
|
E(
|
|
'button',
|
|
{
|
|
'id': 'fwlive-pause',
|
|
'class': 'cbi-button fwlive-btn-ghost',
|
|
'type': 'button'
|
|
},
|
|
[_('Pause')]
|
|
),
|
|
E('span', { 'id': 'fwlive-logging-bar', 'class': 'fwlive-logging-bar' }, [])
|
|
]),
|
|
E('div', { 'class': 'fwlive-watch-group' }, [
|
|
E('span', { 'class': 'fwlive-watch-group-label' }, [_('View')]),
|
|
E(
|
|
'div',
|
|
{
|
|
'class': 'fwlive-watch-seg',
|
|
'role': 'group',
|
|
'aria-label': _('View')
|
|
},
|
|
[
|
|
E(
|
|
'button',
|
|
{
|
|
'id': 'fwlive-view-simple',
|
|
'class': 'cbi-button fwlive-seg-btn',
|
|
'type': 'button',
|
|
'aria-pressed': 'true',
|
|
'click': () => this.setViewMode('simple')
|
|
},
|
|
[_('Simple')]
|
|
),
|
|
E(
|
|
'button',
|
|
{
|
|
'id': 'fwlive-view-detail',
|
|
'class': 'cbi-button fwlive-seg-btn',
|
|
'type': 'button',
|
|
'aria-pressed': 'false',
|
|
'click': () => this.setViewMode('detailed')
|
|
},
|
|
[_('Detail')]
|
|
)
|
|
]
|
|
)
|
|
]),
|
|
E(
|
|
'div',
|
|
{
|
|
'id': 'fwlive-msg-group',
|
|
'class': 'fwlive-watch-group'
|
|
},
|
|
[
|
|
E('span', { 'class': 'fwlive-watch-group-label' }, [_('Message')]),
|
|
E(
|
|
'div',
|
|
{
|
|
'id': 'fwlive-msg-seg',
|
|
'class': 'fwlive-watch-seg',
|
|
'role': 'group',
|
|
'aria-label': _('Message')
|
|
},
|
|
[
|
|
E(
|
|
'button',
|
|
{
|
|
'id': 'fwlive-msg-wrap',
|
|
'class': 'cbi-button fwlive-seg-btn',
|
|
'type': 'button',
|
|
'aria-pressed': 'true',
|
|
'click': () => this.setMessageLayout('wrap')
|
|
},
|
|
[_('Wrap')]
|
|
),
|
|
E(
|
|
'button',
|
|
{
|
|
'id': 'fwlive-msg-oneline',
|
|
'class': 'cbi-button fwlive-seg-btn',
|
|
'type': 'button',
|
|
'aria-pressed': 'false',
|
|
'click': () => this.setMessageLayout('oneline')
|
|
},
|
|
[_('One line')]
|
|
)
|
|
]
|
|
)
|
|
]
|
|
)
|
|
]),
|
|
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' }, [
|
|
E('label', { 'class': 'fwlive-display-ctl', 'for': 'fwlive-limit' }, [
|
|
_('Limit'),
|
|
E(
|
|
'select',
|
|
{
|
|
'id': 'fwlive-limit',
|
|
'class': 'cbi-input-select'
|
|
},
|
|
this.limitSelectOptions()
|
|
)
|
|
]),
|
|
E('label', { 'class': 'fwlive-display-ctl' }, [
|
|
E('input', {
|
|
'id': 'fwlive-row-tint-toggle',
|
|
'type': 'checkbox',
|
|
'title': _('Show pass/deny row background colors')
|
|
}),
|
|
_('Row tint')
|
|
]),
|
|
E(
|
|
'label',
|
|
{
|
|
'id': 'fwlive-row-tint-palette-wrap',
|
|
'class': 'fwlive-display-ctl',
|
|
'for': 'fwlive-row-tint'
|
|
},
|
|
[
|
|
_('Palette'),
|
|
E(
|
|
'select',
|
|
{
|
|
'id': 'fwlive-row-tint',
|
|
'class': 'cbi-input-select',
|
|
'title': _(
|
|
'Classic uses green/red; Accessible uses teal/orange'
|
|
)
|
|
},
|
|
this.rowTintPaletteOptions()
|
|
)
|
|
]
|
|
),
|
|
E('label', { 'class': 'fwlive-display-ctl' }, [
|
|
E('input', {
|
|
'id': 'fwlive-show-hostnames',
|
|
'type': 'checkbox'
|
|
}),
|
|
_('Show hostnames')
|
|
])
|
|
])
|
|
]),
|
|
E(
|
|
'div',
|
|
{ 'id': 'fwlive-filter-panel', 'class': 'fwlive-filter-panel fwlive-find-row' },
|
|
[
|
|
E('div', { 'class': 'fwlive-grid fwlive-grid-core' }, [
|
|
E('input', {
|
|
'id': 'fwlive-q',
|
|
'class': 'cbi-input-text',
|
|
'placeholder': _('Quick search')
|
|
}),
|
|
E('select', { 'id': 'fwlive-action', 'class': 'cbi-input-select' }, [
|
|
E('option', { 'value': '' }, [_('Any action')]),
|
|
E('option', { 'value': 'pass' }, ['pass']),
|
|
E('option', { 'value': 'block' }, ['block']),
|
|
E('option', { 'value': 'drop' }, ['drop']),
|
|
E('option', { 'value': 'reject' }, ['reject']),
|
|
E('option', { 'value': 'unknown' }, ['unknown']),
|
|
E('option', { 'value': '!pass' }, [_('not pass')]),
|
|
E('option', { 'value': '!drop' }, [_('not drop')]),
|
|
E('option', { 'value': '!block' }, [_('not block')]),
|
|
E('option', { 'value': '!reject' }, [_('not reject')]),
|
|
E('option', { 'value': '!unknown' }, [_('not unknown')])
|
|
]),
|
|
E('div', { 'class': 'fwlive-proto-pair' }, [
|
|
E(
|
|
'select',
|
|
{
|
|
'id': 'fwlive-proto',
|
|
'class': 'cbi-input-select',
|
|
'title': _('Protocol — common values')
|
|
},
|
|
this.protoSelectOptions()
|
|
),
|
|
E('input', {
|
|
'id': 'fwlive-proto-custom',
|
|
'class': 'cbi-input-text',
|
|
'placeholder': _('or type…'),
|
|
'title': _(
|
|
'Custom protocol (prefix ! to exclude). Overrides the menu when set.'
|
|
),
|
|
'autocomplete': 'off'
|
|
})
|
|
])
|
|
]),
|
|
E(
|
|
'details',
|
|
{ 'id': 'fwlive-more-filters', 'class': 'fwlive-more-filters' },
|
|
[
|
|
E('summary', {}, [_('More filters')]),
|
|
E('div', { 'class': 'fwlive-grid fwlive-grid-extra' }, [
|
|
E('input', {
|
|
'id': 'fwlive-interface',
|
|
'class': 'cbi-input-text',
|
|
'placeholder': _('Interface (prefix ! to exclude)')
|
|
}),
|
|
E('input', {
|
|
'id': 'fwlive-src',
|
|
'class': 'cbi-input-text',
|
|
'placeholder': _('Source IP contains (! to exclude)')
|
|
}),
|
|
E('input', {
|
|
'id': 'fwlive-sport',
|
|
'class': 'cbi-input-text',
|
|
'placeholder': _('Source port (! to exclude)')
|
|
}),
|
|
E('input', {
|
|
'id': 'fwlive-dst',
|
|
'class': 'cbi-input-text',
|
|
'placeholder': _('Destination IP contains (! to exclude)')
|
|
}),
|
|
E('input', {
|
|
'id': 'fwlive-dport',
|
|
'class': 'cbi-input-text',
|
|
'placeholder': _('Destination port (! to exclude)')
|
|
})
|
|
])
|
|
]
|
|
)
|
|
]
|
|
),
|
|
E('div', { 'id': 'fwlive-chips', 'class': 'fwlive-chips' }, []),
|
|
E('p', { 'class': 'fwlive-hint-line' }, [
|
|
_(
|
|
'Click a cell to filter · ≠ on a chip to exclude · Ctrl+click a rule for firewall settings · in Simple view, click a row for the full message'
|
|
)
|
|
]),
|
|
E(
|
|
'div',
|
|
{
|
|
'id': 'fwlive-empty',
|
|
'class': 'fwlive-empty',
|
|
'style': 'display:none'
|
|
},
|
|
[]
|
|
),
|
|
E('div', { 'id': 'fwlive-scroll', 'class': 'fwlive-scroll fwlive-msg-wrap' }, [
|
|
E('table', { 'id': 'fwlive-table', 'class': 'table cbi-section-table' }, [
|
|
E('thead', {}, E('tr', {}, [])),
|
|
E('tbody', {}, [])
|
|
])
|
|
]),
|
|
E('div', { 'class': 'fwlive-help-row' }, [
|
|
E('details', { 'id': 'fwlive-help', 'class': 'fwlive-help' }, [
|
|
E('summary', {}, [_('Help')]),
|
|
E('ul', {}, [
|
|
E('li', {}, [
|
|
_(
|
|
'The table updates automatically when your firewall logs traffic. Use Pause if it moves too fast.'
|
|
)
|
|
]),
|
|
E('li', {}, [
|
|
_(
|
|
'Enable logging turns on WAN zone drop/reject logging only (same as Network → Firewall). It does not add rules or log normal LAN browsing.'
|
|
)
|
|
]),
|
|
E('li', {}, [
|
|
_(
|
|
'Display options on the bar set Limit, row tint, palette, and hostnames.'
|
|
)
|
|
]),
|
|
E('li', {}, [
|
|
_(
|
|
'The rate shown for WAN logging is the firewall zone log_limit. OpenWrt defaults to 10/minute when no explicit limit is configured; fwlive does not impose this cap.'
|
|
)
|
|
]),
|
|
E('li', { 'id': 'fwlive-manual-test' }, []),
|
|
E('li', {}, [
|
|
_(
|
|
'Click a row (Time or other non-link cells) to see the full log line (Simple view).'
|
|
)
|
|
]),
|
|
E('li', {}, [
|
|
_(
|
|
'Click an IP, action, or protocol to filter; use the Protocol menu (or ≠ on a chip) to exclude.'
|
|
)
|
|
]),
|
|
E('li', {}, [
|
|
_(
|
|
'Row tint shows pass/deny row backgrounds when checked. Choose Classic (green/red, default) or Accessible (teal/orange). Action text stays colored either way.'
|
|
)
|
|
]),
|
|
E('li', {}, [
|
|
_('Use Detail for all columns (flags, length, raw message).')
|
|
]),
|
|
E('li', {}, [
|
|
_(
|
|
'If Row tint looks missing, the active LuCI theme may omit success/error or info/warn CSS variables; fwlive falls back to local colors (air-gapped, no data leaves the device).'
|
|
)
|
|
])
|
|
])
|
|
]),
|
|
E(
|
|
'span',
|
|
{
|
|
'id': 'fwlive-build',
|
|
'class': 'fwlive-build',
|
|
'title': 'luci-app-fwlive'
|
|
},
|
|
['v' + constants.APP_VERSION]
|
|
)
|
|
])
|
|
]
|
|
);
|
|
},
|
|
|
|
addFooter() {
|
|
this.viewMode = this.readViewMode();
|
|
this.messageLayout = this.readMessageLayout();
|
|
this.showHostnames = this.readShowHostnames();
|
|
this.rowTint = this.readRowTint();
|
|
this.rowTintPalette = this.rowTintEnabled() ? this.rowTint : 'classic';
|
|
this.hostnameCache = new Map();
|
|
this.hostnameFailed = new Map();
|
|
this.resolveGeneration = 0;
|
|
this.lastPollError = false;
|
|
this.applyRowLimit(this.readRowLimit());
|
|
this.applyHash();
|
|
this.attachHandlers();
|
|
this.applyRowTintMode();
|
|
this.updateRowTintUi();
|
|
this.updateMessageLayoutUi();
|
|
this.updateStreamControlsUi();
|
|
this.updateDetailToggleUi();
|
|
this.renderThead();
|
|
this.updateLoggingToolbarUi();
|
|
this.updateEmptyStateUi();
|
|
this.updateBackendUi();
|
|
this.updateTintWarnUi();
|
|
this.renderRows(true);
|
|
const testLi = document.getElementById('fwlive-manual-test');
|
|
if (testLi)
|
|
logging.renderManualTestNodes(testLi, { firewallBackend: this.firewallBackend }, {});
|
|
if (this.showHostnames) this.resolveHostnamesForEntries(this.filteredRows());
|
|
}
|
|
});
|