mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-10 18:34:18 +08:00
1731 lines
49 KiB
JavaScript
1731 lines
49 KiB
JavaScript
'use strict';
|
|
/*
|
|
* 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' ],
|
|
expect: { log: [] }
|
|
});
|
|
|
|
const callFwliveRules = rpc.declare({
|
|
object: 'fwlive',
|
|
method: 'rules'
|
|
});
|
|
|
|
const callFwliveResolve = rpc.declare({
|
|
object: 'fwlive',
|
|
method: 'resolve',
|
|
params: [ 'addresses' ],
|
|
expect: { names: {} }
|
|
});
|
|
|
|
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: []
|
|
} }
|
|
});
|
|
|
|
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,
|
|
filterInputTimer: null,
|
|
messageLayout: 'wrap',
|
|
renderBucket: constants.RENDER_CAP_PER_SEC,
|
|
renderBucketMs: 0,
|
|
floodSuppressed: false,
|
|
pendingForceRender: false,
|
|
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,
|
|
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';
|
|
} catch (e) {
|
|
this.rulesMap = {};
|
|
this.firewallBackend = 'nft';
|
|
}
|
|
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)
|
|
label.textContent = this.backendDisplayLabel();
|
|
|
|
this.updateEmptyStateUi();
|
|
},
|
|
|
|
async loadLoggingStatus() {
|
|
try {
|
|
this.loggingStatus = await callFwliveLoggingStatus();
|
|
} catch (e) {
|
|
this.loggingStatus = null;
|
|
}
|
|
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();
|
|
|
|
let raw;
|
|
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);
|
|
raw = await callFwlivePoll({
|
|
addresses: [ String(fetchLines) ]
|
|
});
|
|
} catch (e) {
|
|
this.lastPollError = true;
|
|
return;
|
|
}
|
|
if (!Array.isArray(raw)) {
|
|
this.lastPollError = true;
|
|
return;
|
|
}
|
|
|
|
this.lastPollError = false;
|
|
|
|
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: this.resumeMerge,
|
|
rowLimit: this.rowLimit,
|
|
fetchLinesMax: constants.FETCH_LINES_MAX
|
|
});
|
|
},
|
|
|
|
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.paused && !this.followLive)
|
|
bits.push(_('scroll frozen — scroll to top to follow live'));
|
|
return bits.length ? ' — ' + bits.join(', ') : '';
|
|
},
|
|
|
|
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 (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;
|
|
return;
|
|
}
|
|
|
|
status.className = this.paused
|
|
? 'fwlive-status fwlive-status-paused'
|
|
: 'fwlive-status';
|
|
status.textContent = this.compactCountText(matchCount);
|
|
},
|
|
|
|
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;
|
|
this.fetchEntries()
|
|
.then(() => this.renderRows(true))
|
|
.finally(function() {
|
|
this.resumeMerge = false;
|
|
}.bind(this));
|
|
}
|
|
},
|
|
|
|
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();
|
|
this.fetchEntries()
|
|
.then(() => {
|
|
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();
|
|
|
|
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;
|
|
|
|
const names = (res && res.names) || {};
|
|
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);
|
|
}
|
|
}
|
|
|
|
if (updated)
|
|
this.renderRows(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');
|
|
if (scroll) {
|
|
/* add/remove — classList.toggle(name, force) is unsupported on some 21.02-era browsers */
|
|
if (this.messageLayout === 'oneline') {
|
|
scroll.classList.add('fwlive-msg-oneline');
|
|
scroll.classList.remove('fwlive-msg-wrap');
|
|
} else {
|
|
scroll.classList.add('fwlive-msg-wrap');
|
|
scroll.classList.remove('fwlive-msg-oneline');
|
|
}
|
|
}
|
|
const oneline = this.messageLayout === '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)
|
|
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.pollDataInFlight)
|
|
return;
|
|
|
|
this.pollDataInFlight = true;
|
|
try {
|
|
try {
|
|
await this.fetchEntries();
|
|
} catch (e) {
|
|
this.lastPollError = true;
|
|
}
|
|
|
|
if (this.paused)
|
|
this.updateStatus();
|
|
else
|
|
this.renderRows(!!this.pendingForceRender);
|
|
|
|
try {
|
|
await this.resolveHostnamesForEntries(this.filteredRows());
|
|
} catch (e) {
|
|
/* resolve unavailable — show IPs */
|
|
}
|
|
} finally {
|
|
this.pollDataInFlight = false;
|
|
}
|
|
},
|
|
|
|
load() {
|
|
if (!this.pollFn) {
|
|
this.pollFn = this.pollData.bind(this);
|
|
poll.add(this.pollFn, 1);
|
|
/* Best-effort teardown when leaving the page (LuCI SPA may full-reload). */
|
|
if (typeof window !== 'undefined' && window.addEventListener) {
|
|
window.addEventListener('pagehide', function() {
|
|
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-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());
|
|
}
|
|
});
|