'use strict'; /* SPDX-License-Identifier: Apache-2.0 */ /* Copyright 2025-2026 Lucas Albers */ /* * 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()); } });