diff --git a/agentflow/Makefile b/agentflow/Makefile index b1081d99..ab1de5be 100644 --- a/agentflow/Makefile +++ b/agentflow/Makefile @@ -10,14 +10,15 @@ AGENTFLOW_ARCH_x86_64:=amd64 AGENTFLOW_ARCH_aarch64:=arm64 AGENTFLOW_ARCH:=$(AGENTFLOW_ARCH_$(ARCH)) -AGENTFLOW_HASH_x86_64:=b02c1211f3c7f288b0b25ad187ba1e74989bdde744477875a553dc9f7f7501c8 -AGENTFLOW_HASH_aarch64:=938b5feca2fc942eca13b0e8c9df9ea6e7d45c0b0adba0b61f6a3da5bc2ad8d8 +AGENTFLOW_HASH_x86_64:=996ccb6580043a187e83a3558c6bb2f0ff503bafbe46b7c995cff7f77629c088 +AGENTFLOW_HASH_aarch64:=6d733ebc49b095bf8670a1b655a13bb8ad98c90ced19701b67d5cdd4fe9a3db4 PKG_NAME:=agentflow PKG_VERSION:=0.3.0 -PKG_RELEASE:=3 +PKG_RELEASE:=4 -AGENTFLOW_DOWNLOAD:=agentflow-linux-$(AGENTFLOW_ARCH) +AGENTFLOW_URL_FILE:=agentflow-linux-$(AGENTFLOW_ARCH) +AGENTFLOW_DOWNLOAD:=$(AGENTFLOW_URL_FILE)-$(AGENTFLOW_HASH_$(ARCH)) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-$(PKG_VERSION) PKG_BUILD_PARALLEL:=1 @@ -28,7 +29,8 @@ RSTRIP:=: include $(INCLUDE_DIR)/package.mk define Download/agentflow - URL:=https://fw0.koolcenter.com/binary/geili/agentflow/build/ + URL:=https://fw.koolcenter.com/binary/geili/agentflow/build/ + URL_FILE:=$(AGENTFLOW_URL_FILE) FILE:=$(AGENTFLOW_DOWNLOAD) HASH:=skip endef diff --git a/luci-app-fwlive/Makefile b/luci-app-fwlive/Makefile new file mode 100644 index 00000000..fffe2209 --- /dev/null +++ b/luci-app-fwlive/Makefile @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Wire feed first (see openwrt-feed/README.md and feeds.conf.example): +# ./scripts/feeds update luci packages fwlive +# ./scripts/feeds install luci-base luci-app-fwlive + +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=Firewall Live View +LUCI_DESCRIPTION:=Live firewall log table (nftables/fw4 and iptables LOG) with filters +LUCI_DEPENDS:=+luci-base +logd +jsonfilter +LUCI_PKGARCH:=all + +PKG_VERSION:=0.1.37 +PKG_RELEASE:=1 + +# Reproducible build: honor SOURCE_DATE_EPOCH from the build environment (set by docker-sdk.sh / CI). +ifdef SOURCE_DATE_EPOCH +export SOURCE_DATE_EPOCH +endif + +PKG_LICENSE:=Apache-2.0 +PKG_MAINTAINER:=Lucas Albers + +include $(TOPDIR)/feeds/luci/luci.mk + +define Package/luci-app-fwlive/prerm +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] && exit 0 +[ "$$1" = "remove" ] || exit 0 +. /usr/libexec/fwlive-logging.sh +# Best-effort restore: always exit 0 so a transient lock/pending-staging +# failure cannot strand the package half-removed. Baseline is kept on +# failure (restore_wan_log_baseline) for admin recovery; helpers are still +# present while this prerm runs. +restore_wan_log_baseline || logger -t fwlive "WAN log baseline restore failed during uninstall" +exit 0 +endef + +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-fwlive/README.md b/luci-app-fwlive/README.md new file mode 100644 index 00000000..5f8d0850 --- /dev/null +++ b/luci-app-fwlive/README.md @@ -0,0 +1,50 @@ +# luci-app-fwlive + +LuCI **Firewall Live View** — client-side JS view polling `ubus fwlive poll` (firewall-only log lines), with rule labels via `fwlive rules`, optional reverse DNS via `fwlive resolve`, and opt-in WAN zone logging via `enable_wan_logging` / `disable_wan_logging`. + +## Package layout (OpenWrt / LuCI conventions) + +| Path | Role | +|------|------| +| `Makefile` | `LUCI_TITLE`, `LUCI_DEPENDS`, includes `luci.mk` | +| `htdocs/luci-static/resources/view/status/fwlive.js` | LuCI view (`view.extend`) | +| `htdocs/luci-static/resources/fwlive/log.js` | Parser/filter module (mirror of repo `core/fwlive-log.js`) | +| `htdocs/luci-static/resources/fwlive/constants.js` | Shared view constants (`baseclass.extend` module) | +| `htdocs/luci-static/resources/fwlive/css.js` | Inline stylesheet string (`styleText` for `E('style', …)`) | +| `htdocs/luci-static/resources/fwlive/tint.js` | Row-tint paint helpers (`baseclass.extend` module) | +| `htdocs/luci-static/resources/fwlive/links.js` | Link-builder helpers (pure + filter-aware; no host) | +| `htdocs/luci-static/resources/fwlive/chips.js` | Filter-chip DOM renderer (`renderFilterChips`) | +| `htdocs/luci-static/resources/fwlive/logging.js` | Logging toolbar and empty-state DOM renderers | +| `htdocs/luci-static/resources/fwlive/table.js` | Table thead/rows DOM renderer (`renderThead`, `renderRows`) | +| `htdocs/luci-static/resources/fwlive/buffer.js` | Ring-buffer apply/merge helpers (pause ingest + resume merge) | +| `htdocs/luci-static/resources/fwlive/hostname.js` | Hostname cache LRU + failure TTL helpers | +| `root/usr/share/luci/menu.d/*.json` | Menu entry (`admin/status/fwlive`) | +| `root/usr/share/rpcd/acl.d/*.json` | ubus ACL (read + write for logging enable/disable) | +| `root/usr/libexec/rpcd/fwlive` | rpcd plugin (`rules`, `poll`, `resolve`, `logging_status`, `enable_wan_logging`, `disable_wan_logging`) | +| `root/usr/libexec/fwlive-logging.sh` | WAN zone logging helpers | +| `/etc/fwlive/wan-log-baseline` | Written on first **Enable logging**; restored on uninstall (`prerm`) | +| `root/usr/libexec/fwlive-log-filter.sh` | Server-side firewall-only filter (`isFirewallEvent` parity) | +| `root/usr/libexec/fwlive-is-firewall-event.sh` | Shared filter logic (sourced by filter + tests) | +| `po/templates/luci-app-fwlive.pot` | i18n template (English msgid scaffolding) | + +No `luasrc/` — modern JS-only app. + +## Dependencies + +- `luci-base`, `logd`, `jsonfilter` (declared in `LUCI_DEPENDS`; `rpcd` via `luci-base`) +- Optional reverse DNS uses BusyBox `nslookup` (stock image; not a package depend) +- No hard `firewall4` dependency +- Menu depends on ACL only (no `fs` AND of `nft`+`iptables` — that hid the entry on stock fw3 and fw4) +- Runtime backend detection selects **fw4/nft** (22.03+) or **iptables LOG** (21.02 fw3); best-effort iptables when nft absent + +## Maintenance + +The development home is [lucas-albers-lz4/fwlive](https://github.com/lucas-albers-lz4/fwlive). +The copy in `openwrt/luci` is a snapshot. +For which tree wins, when we re-cut, generated files, and why we do not use `PKG_SOURCE`, see +[Maintenance model](../../docs/developer/upstream-openwrt.md#maintenance-model). + +## Documentation + +- **Users:** [`../../docs/user/installation.md`](../../docs/user/installation.md) +- **Developers:** [`../../docs/developer/README.md`](../../docs/developer/README.md) diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/buffer.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/buffer.js new file mode 100644 index 00000000..e7826867 --- /dev/null +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/buffer.js @@ -0,0 +1,76 @@ +'use strict'; +'require baseclass'; + +/** + * Ring-buffer helpers for luci-app-fwlive poll ingest. + * LuCI modules must return baseclass.extend(...) — plain objects fail Class.isSubclass. + * + * While paused, ingest grows up to fetchLinesMax. Live mode caps at rowLimit. + * On resume, merge (do not replace) so pause-accumulated rows survive the first + * live poll — see issue #43 pause→resume data loss. + */ + +function ingestCap(paused, rowLimit, fetchLinesMax) { + return paused ? fetchLinesMax : rowLimit; +} + +function mergeById(entries, normalized, cap) { + if (!normalized || !normalized.length) { + if (!entries || !entries.length) + return []; + return entries.slice(-cap); + } + + const byId = {}; + let i; + if (entries) { + for (i = 0; i < entries.length; i++) + byId[entries[i].id] = entries[i]; + } + for (i = 0; i < normalized.length; i++) + byId[normalized[i].id] = normalized[i]; + + const merged = Object.keys(byId).map(function(id) { return byId[id]; }); + merged.sort(function(a, b) { + const ta = a.timestamp || 0; + const tb = b.timestamp || 0; + if (ta !== tb) + return ta - tb; + return (a.log_id || 0) - (b.log_id || 0); + }); + return merged.slice(-cap); +} + +/** + * Apply a poll batch onto the current buffer. + * + * @param {object[]} entries - current buffer (oldest-first) + * @param {object[]} normalized - newly polled rows (oldest-first) + * @param {{ paused: boolean, resumeMerge: boolean, rowLimit: number, fetchLinesMax: number }} opts + * @returns {object[]} next buffer + */ +function applyFetchedEntries(entries, normalized, opts) { + const paused = !!(opts && opts.paused); + const resumeMerge = !!(opts && opts.resumeMerge); + const rowLimit = (opts && opts.rowLimit) || 0; + const fetchLinesMax = (opts && opts.fetchLinesMax) || rowLimit; + const cap = ingestCap(paused, rowLimit, fetchLinesMax); + const merge = paused || resumeMerge; + + let next; + if (merge) + next = mergeById(entries, normalized, cap); + else + next = (normalized || []).slice(-cap); + + if (!paused && next.length > rowLimit) + next = next.slice(-rowLimit); + + return next; +} + +return baseclass.extend({ + ingestCap: ingestCap, + mergeById: mergeById, + applyFetchedEntries: applyFetchedEntries +}); diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js new file mode 100644 index 00000000..a2c09af0 --- /dev/null +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js @@ -0,0 +1,121 @@ +'use strict'; +'require baseclass'; /* LuCI require() needs Class.isSubclass — plain return {} fails */ +'require fwlive.log as log'; + +/** + * Filter-chip DOM renderer for luci-app-fwlive. + * + * renderFilterChips(host, state, callbacks) → void + * host - container element (cleared and rebuilt; element itself is kept) + * state - shallow copy: { filters, chipFields } + * callbacks - { onInvert(field, ev), onClear(field, ev), onClearAll(ev) } + * + * Chips use the labels presentation (include: "is", exclude: "not" + light ≠). + * Modules must not mutate state. host is cleared then rebuilt (idempotent replace). + */ + +function chipValueNodes(field, val) { + const p = log.parseFilterValue(val); + if (!p.value) + return [ '' ]; + + const valueNode = p.negate + ? E('span', { 'class': 'fwlive-chip-strike' }, [ p.value ]) + : p.value; + + if (!p.negate) { + return [ + E('span', { 'class': 'fwlive-chip-polarity' }, [ _('is') ]), + ' ', + log.formatFilterChipLabel(field, val) + ]; + } + + if (field === 'q' || field === 'src' || field === 'dst') + return [ + field + ': ', + E('strong', { 'class': 'fwlive-chip-not' }, [ _('not') ]), + ' contains ', + valueNode + ]; + + return [ + field + ': ', + E('strong', { 'class': 'fwlive-chip-not' }, [ _('not') ]), + ' ', + valueNode + ]; +} + +function chipLeadingSym(negated) { + if (!negated) + return null; + return E('span', { + 'class': 'fwlive-chip-sym fwlive-chip-sym-light', + 'aria-hidden': 'true' + }, [ '≠' ]); +} + +function renderFilterChips(host, state, callbacks) { + const filters = state.filters || {}; + const chipFields = state.chipFields || []; + const chips = []; + + for (let i = 0; i < chipFields.length; i++) { + const spec = chipFields[i]; + const val = filters[spec.key]; + if (!val) + continue; + + const parsed = log.parseFilterValue(val); + const negated = parsed.negate; + const kids = []; + const lead = chipLeadingSym(negated); + if (lead) + kids.push(lead); + + kids.push(E('span', { 'class': 'fwlive-chip-label' }, chipValueNodes(spec.label, val))); + kids.push(E('span', { + 'class': 'fwlive-chip-invert-wrap', + 'data-tip': negated ? _('Include instead') : _('Exclude instead') + }, [ + E('button', { + 'type': 'button', + 'class': 'fwlive-chip-invert', + 'click': function(ev) { callbacks.onInvert(spec.key, ev); } + }, [ '≠' ]) + ])); + kids.push(E('a', { + 'href': '#', + 'class': 'fwlive-chip-remove', + 'title': _('Remove filter'), + 'click': function(ev) { callbacks.onClear(spec.key, ev); } + }, [ '×' ])); + + chips.push(E('span', { + 'class': 'fwlive-chip' + + (negated ? ' fwlive-chip-negated' : ' fwlive-chip-include') + }, kids)); + } + + host.className = 'fwlive-chips fwlive-chips-labels'; + host.innerHTML = ''; + if (!chips.length) { + host.style.display = 'none'; + return; + } + + host.style.display = 'flex'; + for (let i = 0; i < chips.length; i++) + host.appendChild(chips[i]); + + host.appendChild(E('a', { + 'href': '#', + 'class': 'fwlive-chip-clear', + 'click': function(ev) { callbacks.onClearAll(ev); } + }, [ _('Clear all') ])); +} + +return baseclass.extend({ + renderFilterChips: renderFilterChips +}); diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/constants.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/constants.js new file mode 100644 index 00000000..f972c2f8 --- /dev/null +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/constants.js @@ -0,0 +1,24 @@ +'use strict'; +'require baseclass'; + +/** + * Shared view constants for luci-app-fwlive. + * LuCI modules must return baseclass.extend(...) — plain objects fail Class.isSubclass. + */ +return baseclass.extend({ + /* Keep in sync with openwrt-feed/luci-app-fwlive/Makefile PKG_VERSION. */ + APP_VERSION: '0.1.37', + ROW_LIMIT_OPTIONS: [ 25, 50, 100, 250, 500, 1000, 2000 ], + DEFAULT_ROW_LIMIT: 100, + /* Row pass/deny tint (#40): classic green/red default; accessible teal/orange */ + ROW_TINT_OPTIONS: [ 'off', 'classic', 'accessible' ], + DEFAULT_ROW_TINT: 'classic', + FETCH_LINES_MAX: 2000, /* ubus poll / logd ring cap (~2000 lines ≈ typical ring) */ + /* DOM budget: ~250 new/updated rows painted per second on typical LuCI routers */ + RENDER_CAP_PER_SEC: 250, + VIEW_MODES: [ 'simple', 'detailed' ], + COLUMN_SETS: { + simple: [ 'action', 'time', 'iface', 'flow', 'proto', 'rule' ], + detailed: [ 'time', 'action', 'rule', 'iface_in', 'iface_out', 'dir', 'proto', 'src', 'sport', 'dst', 'dport', 'flags', 'len', 'message' ] + } +}); diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/css.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/css.js new file mode 100644 index 00000000..f565e140 --- /dev/null +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/css.js @@ -0,0 +1,13 @@ +'use strict'; +'require baseclass'; + +/** + * Inline stylesheet string for luci-app-fwlive. + * Exports CSS text for E('style', {}, [css.styleText]) injection — NOT a standalone .css asset. + * LuCI modules must return baseclass.extend(...) — plain objects fail Class.isSubclass. + * + * GENERATED — do not edit. Edit fwlive.css and run: node scripts/embed-fwlive-css.js + */ +return baseclass.extend({ + styleText: "\n.fwlive-map {\n\tmax-width: none;\n\twidth: 100%;\n\t--fwlive-pass-color: var(--success-color-high, var(--success-color, #46a546));\n\t--fwlive-deny-color: var(--error-color-high, var(--error-color, #ca3c3c));\n\t--fwlive-bg-medium: var(--background-color-medium, var(--white-color-low, #f9f9f9));\n\t--fwlive-paused-color: #b45309;\n}\n/* Wide page in Detail only — Simple keeps LuCI’s 940px column. */\n#maincontent.container:has(.fwlive-map[data-view=\"detailed\"]) {\n\tmax-width: none;\n\tpadding-left: 16px;\n\tpadding-right: 16px;\n}\n.fwlive-map[data-row-tint=\"accessible\"] {\n\t--fwlive-pass-color: #0d9488;\n\t--fwlive-deny-color: #c2410c;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row,\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tbackground: rgba(180, 83, 9, 0.08);\n\tbackground: color-mix(in srgb, var(--fwlive-paused-color) 8%, transparent);\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row {\n\tmargin: 0 -8px;\n\tpadding: 6px 8px 0;\n\tborder-radius: 3px 3px 0 0;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tmargin: 0 -8px 10px;\n\tpadding: 8px;\n\tborder-radius: 0 0 3px 3px;\n\tborder-bottom-color: transparent;\n}\n.fwlive-title-row {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: baseline;\n\tjustify-content: space-between;\n\tgap: 8px 16px;\n\tmargin: 0 0 4px;\n}\n.fwlive-title-row h2 {\n\tmargin: 0;\n\tflex: 0 1 auto;\n}\n.fwlive-title-status {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 8px;\n\tflex-wrap: wrap;\n\tmin-width: 0;\n\tmargin-left: auto;\n}\n.fwlive-watch-strip {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: stretch;\n\tjustify-content: flex-start;\n\tgap: 0;\n\tmin-height: 40px;\n\tpadding: 8px 0 10px;\n\tborder-bottom: 1px solid var(--border-color-medium);\n\tmargin-bottom: 10px;\n}\n.fwlive-watch-group {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 6px;\n\tmin-height: 28px;\n\tpadding: 0 10px;\n\tborder-left: 1px solid var(--border-color-medium);\n\tflex-wrap: wrap;\n}\n.fwlive-watch-group:last-child {\n\tpadding-right: 0;\n}\n.fwlive-watch-group-label {\n\tmargin-right: 2px;\n\tcolor: var(--text-color-medium);\n\tfont-size: 0.72em;\n\tfont-weight: 700;\n\tletter-spacing: 0.08em;\n\ttext-transform: uppercase;\n\twhite-space: nowrap;\n}\n.fwlive-watch-seg {\n\tdisplay: inline-flex;\n\talign-items: stretch;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\toverflow: hidden;\n}\n.fwlive-watch-seg .fwlive-seg-btn {\n\tmargin: 0;\n\tborder: 0;\n\tborder-radius: 0;\n\tborder-right: 1px solid var(--border-color-medium);\n\tbackground: transparent;\n\tpadding: 4px 10px;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n}\n.fwlive-watch-seg .fwlive-seg-btn:last-child {\n\tborder-right: 0;\n}\n.fwlive-watch-seg .fwlive-seg-btn[aria-pressed=\"true\"] {\n\tbackground: rgba(26, 111, 156, 0.12);\n\tbackground: color-mix(in srgb, var(--primary-color-high) 12%, transparent);\n\tcolor: var(--primary-color-high);\n\tfont-weight: 600;\n}\n.fwlive-watch-label { font-weight: 600; }\n.fwlive-watch-paused .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-map.fwlive-watch-paused .fwlive-title-status .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-dot {\n\twidth: 8px;\n\theight: 8px;\n\tborder-radius: 50%;\n\tbackground: var(--text-color-low, var(--border-color-high));\n\tdisplay: inline-block;\n\tflex-shrink: 0;\n}\n.fwlive-dot.fwlive-dot-on {\n\tbackground: var(--fwlive-pass-color);\n\tbox-shadow: 0 0 0 3px color-mix(in srgb, var(--fwlive-pass-color) 22%, transparent);\n}\n.fwlive-btn-ghost {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n}\n.fwlive-btn-quiet {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n\tcolor: var(--text-color-medium);\n\tfont-weight: 500;\n}\n.fwlive-log-merged {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tpadding: 4px 10px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-medium, var(--white-color-low, var(--background-color-high)));\n\tcolor: var(--text-color-high);\n\tfont-weight: 500;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n\tcursor: pointer;\n}\n.fwlive-log-merged:hover {\n\tbackground: var(--background-color-low, var(--background-color-medium));\n}\n.fwlive-log-merged:disabled {\n\topacity: 0.65;\n\tcursor: default;\n}\n.fwlive-log-on-dot {\n\twidth: 6px;\n\theight: 6px;\n\tborder-radius: 50%;\n\tbackground: var(--fwlive-pass-color);\n\tflex-shrink: 0;\n}\n.fwlive-log-rate {\n\tcolor: var(--text-color-medium);\n\tfont-weight: 400;\n}\n.fwlive-display-bar {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: center;\n\tgap: 8px 14px;\n\tmargin: 0 0 12px;\n\tpadding: 8px 12px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-high);\n}\n.fwlive-display-bar-label {\n\tflex: 0 0 auto;\n\tfont-weight: 600;\n\tfont-size: 0.92em;\n\tcolor: var(--text-color-high);\n\twhite-space: nowrap;\n}\n.fwlive-display-controls {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: center;\n\tgap: 8px 14px;\n\tmin-width: 0;\n\tflex: 1 1 auto;\n}\n.fwlive-display-ctl {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tmargin: 0;\n\tfont-size: 0.92em;\n\twhite-space: nowrap;\n}\n.fwlive-display-ctl input[type=\"checkbox\"] {\n\tmargin: 0;\n\tflex-shrink: 0;\n}\n.fwlive-display-ctl select {\n\tmargin-left: 0;\n}\n.fwlive-display-ctl.fwlive-hidden {\n\tdisplay: none;\n}\n/* Draw our own disclosure arrow — themes that restyle summary drop the native marker. */\n.fwlive-more-filters > summary,\n.fwlive-help > summary {\n\tcursor: pointer;\n\tlist-style: none;\n}\n.fwlive-more-filters > summary::-webkit-details-marker,\n.fwlive-help > summary::-webkit-details-marker { display: none; }\n.fwlive-more-filters > summary::before,\n.fwlive-help > summary::before {\n\tcontent: \"▸ \";\n\tcolor: var(--text-color-medium);\n}\n.fwlive-more-filters[open] > summary::before,\n.fwlive-help[open] > summary::before { content: \"▾ \"; }\n.fwlive-ctl {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tfont-size: 0.92em;\n\tflex-wrap: wrap;\n}\n.fwlive-ctl select { margin-left: 4px; }\n#fwlive-limit { width: auto; min-width: 4.5em; }\n.fwlive-grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 8px; margin-bottom: 12px; }\n.fwlive-status { margin: 0; color: var(--text-color-medium); font-size: 0.9em; }\n.fwlive-status-paused { color: var(--warn-color-high); font-weight: 600; }\n.fwlive-status-error { color: var(--warn-color-high); font-weight: 600; }\n.fwlive-hint-line {\n\tmargin: 0 0 10px;\n\tfont-size: 0.85em;\n\tcolor: var(--text-color-medium);\n}\n.fwlive-empty-title {\n\tfont-weight: 600;\n\tmargin: 0 0 6px;\n\tcolor: var(--text-color-high);\n}\n.fwlive-find-row .fwlive-grid-core { margin-bottom: 0; }\n.fwlive-proto-pair {\n\tdisplay: grid;\n\tgrid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);\n\tgap: 6px;\n\talign-items: stretch;\n\tmin-width: 0;\n}\n.fwlive-proto-pair select,\n.fwlive-proto-pair input {\n\twidth: 100%;\n\tmin-width: 0;\n}\n@media (max-width: 520px) {\n\t.fwlive-proto-pair { grid-template-columns: 1fr; }\n}\n.fwlive-scroll {\n\tmax-height: 78vh;\n\tmax-height: 78dvh;\n\toverflow: auto;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-high);\n\twidth: 100%;\n\tscrollbar-gutter: stable;\n}\n#fwlive-table { margin: 0; width: 100%; table-layout: auto; border-collapse: collapse; }\n.fwlive-map[data-view=\"simple\"] #fwlive-table { table-layout: fixed; width: 100%; }\n.fwlive-map[data-view=\"simple\"] col.fwlive-col-action { width: 5rem; }\n.fwlive-map[data-view=\"simple\"] col.fwlive-col-time { width: 5rem; }\n.fwlive-map[data-view=\"simple\"] col.fwlive-col-iface { width: 5.5rem; }\n.fwlive-map[data-view=\"simple\"] col.fwlive-col-proto { width: 4rem; }\n.fwlive-map[data-view=\"simple\"] col.fwlive-col-rule { width: 12rem; }\n.fwlive-map[data-view=\"detailed\"] .fwlive-scroll #fwlive-table {\n\twidth: max-content;\n\tmin-width: 100%;\n}\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-time { width: 11rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-action { width: 4.5rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-rule { width: 8rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-iface-in,\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-iface-out { width: 3.5rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-dir { width: 3rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-proto { width: 3.25rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-src,\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-dst { width: 9rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-sport,\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-dport { width: 3.75rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-flags { width: 5rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-len { width: 3.25rem; }\n.fwlive-scroll.fwlive-msg-oneline #fwlive-table {\n\twidth: max-content;\n\tmin-width: 100%;\n}\n.fwlive-scroll.fwlive-msg-oneline tbody td { vertical-align: middle; }\n#fwlive-table thead th {\n\tposition: sticky;\n\ttop: 0;\n\tz-index: 2;\n\tbackground: var(--background-color-low);\n\tborder-bottom: 2px solid var(--border-color-high);\n\twhite-space: nowrap;\n\tpadding: 5px 8px;\n\tfont-size: 0.9em;\n\tfont-weight: 600;\n\tvertical-align: top;\n\ttext-align: left;\n}\n#fwlive-table thead th.fwlive-port,\n#fwlive-table thead th.fwlive-len {\n\ttext-align: right;\n}\n.fwlive-map[data-view=\"simple\"] #fwlive-table thead th,\n.fwlive-map[data-view=\"simple\"] #fwlive-table tbody td {\n\tvertical-align: top;\n}\n#fwlive-table thead th.fwlive-action {\n\tfont-weight: 700;\n\ttext-transform: lowercase;\n}\n#fwlive-table tbody td {\n\tpadding: 5px 8px;\n\tborder-bottom: 1px solid var(--border-color-low);\n\tvertical-align: top;\n\tfont-size: 0.92em;\n}\n.fwlive-row-alt td {\n\tbackground: #f9f9f9;\n\tbackground: var(--fwlive-bg-medium);\n}\n.fwlive-time, .fwlive-addr, .fwlive-port, .fwlive-len {\n\tfont-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n\twhite-space: nowrap;\n}\n.fwlive-port, .fwlive-len { text-align: right; }\n.fwlive-rule, .fwlive-iface, .fwlive-proto, .fwlive-dir, .fwlive-flags { white-space: nowrap; }\n.fwlive-rule { font-size: 0.88em; }\n.fwlive-rule-link {\n\tcolor: var(--primary-color-high);\n\ttext-decoration: none;\n\tfont-weight: 500;\n}\n.fwlive-rule-link:hover { text-decoration: underline; }\n.fwlive-iface-badge {\n\tdisplay: inline-block;\n\tpadding: 1px 6px;\n\tbackground: var(--background-color-low);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tfont-size: 0.85em;\n\ttext-decoration: none;\n}\n.fwlive-iface-badge:hover {\n\tbackground: var(--background-color-medium);\n\tborder-color: var(--primary-color-medium);\n}\n.fwlive-action {\n\tfont-weight: 700;\n\ttext-transform: lowercase;\n\twhite-space: nowrap;\n}\n.fwlive-deny { color: var(--fwlive-deny-color); }\n.fwlive-pass { color: var(--fwlive-pass-color); }\n#fwlive-table td.fwlive-action.fwlive-pass,\n#fwlive-table td.fwlive-action.fwlive-pass a.fwlive-filter-link {\n\tcolor: var(--fwlive-pass-color);\n}\n#fwlive-table td.fwlive-action.fwlive-deny,\n#fwlive-table td.fwlive-action.fwlive-deny a.fwlive-filter-link {\n\tcolor: var(--fwlive-deny-color);\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-pass td {\n\tbackground: rgba(70, 165, 70, 0.12);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 12%, transparent);\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-pass.fwlive-row-alt td {\n\tbackground: rgba(70, 165, 70, 0.18);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 12%, var(--fwlive-bg-medium));\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-deny td {\n\tbackground: rgba(202, 60, 60, 0.12);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 12%, transparent);\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-deny.fwlive-row-alt td {\n\tbackground: rgba(202, 60, 60, 0.18);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 12%, var(--fwlive-bg-medium));\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-pass:hover td {\n\tbackground: rgba(70, 165, 70, 0.20);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 20%, transparent);\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-deny:hover td {\n\tbackground: rgba(202, 60, 60, 0.20);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 20%, transparent);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass td {\n\tbackground: rgba(70, 165, 70, 0.12);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass.fwlive-row-alt td {\n\tbackground: rgba(70, 165, 70, 0.18);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny td {\n\tbackground: rgba(202, 60, 60, 0.12);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny.fwlive-row-alt td {\n\tbackground: rgba(202, 60, 60, 0.18);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass:hover td {\n\tbackground: rgba(70, 165, 70, 0.20);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny:hover td {\n\tbackground: rgba(202, 60, 60, 0.20);\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-pass td {\n\tbackground: rgba(13, 148, 136, 0.12);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 12%, transparent);\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-pass.fwlive-row-alt td {\n\tbackground: rgba(13, 148, 136, 0.18);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 12%, var(--fwlive-bg-medium));\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-deny td {\n\tbackground: rgba(194, 65, 12, 0.12);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 12%, transparent);\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-deny.fwlive-row-alt td {\n\tbackground: rgba(194, 65, 12, 0.18);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 12%, var(--fwlive-bg-medium));\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-pass:hover td {\n\tbackground: rgba(13, 148, 136, 0.20);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 20%, transparent);\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-deny:hover td {\n\tbackground: rgba(194, 65, 12, 0.20);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 20%, transparent);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass td {\n\tbackground: rgba(13, 148, 136, 0.12);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass.fwlive-row-alt td {\n\tbackground: rgba(13, 148, 136, 0.18);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny td {\n\tbackground: rgba(194, 65, 12, 0.12);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny.fwlive-row-alt td {\n\tbackground: rgba(194, 65, 12, 0.18);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass:hover td {\n\tbackground: rgba(13, 148, 136, 0.20);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny:hover td {\n\tbackground: rgba(194, 65, 12, 0.20);\n}\n.fwlive-unknown { color: var(--text-color-medium); font-weight: 500; }\n.fwlive-message {\n\tfont-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n\tfont-size: 0.85em;\n\tcolor: var(--text-color-high);\n}\n.fwlive-scroll.fwlive-msg-wrap .fwlive-message-wrap {\n\tdisplay: block;\n\tmin-width: 16em;\n\tmax-width: 42em;\n\twhite-space: normal;\n\tword-break: break-word;\n\tword-wrap: break-word;\n}\n.fwlive-scroll.fwlive-msg-oneline .fwlive-message {\n\twhite-space: nowrap;\n\tmax-width: none;\n\tmin-width: 32em;\n}\n#fwlive-table th.fwlive-th-message,\n.fwlive-scroll.fwlive-msg-oneline .fwlive-message {\n\twidth: 99%;\n}\n.fwlive-empty {\n\tmargin: 12px 0;\n\tpadding: 10px;\n\tbackground: var(--background-color-medium);\n\tborder: 1px dashed var(--border-color-high);\n}\n.fwlive-empty-muted {\n\tfont-size: 0.92em;\n\tcolor: var(--text-color-medium);\n\tmargin: 0 0 8px;\n}\n.fwlive-consent {\n\tmargin: 0 0 12px;\n\tpadding: 10px 12px;\n\tbackground: var(--background-color-high);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n}\n.fwlive-consent-list {\n\tmargin: 0 0 10px;\n\tpadding-left: 1.25em;\n}\n.fwlive-consent-list li {\n\tmargin: 0 0 4px;\n}\n.fwlive-consent-check {\n\tmargin: 0 0 10px;\n\tfont-size: 0.92em;\n}\n.fwlive-consent-actions {\n\tmargin: 0;\n}\n.fwlive-logging-bar {\n\tdisplay: contents;\n}\n.fwlive-logging-status {\n\tfont-size: 0.92em;\n\tcolor: var(--text-color-medium);\n\tmargin-right: 4px;\n}\n.fwlive-logging-notice {\n\tcolor: var(--fwlive-pass-color);\n}\n.fwlive-filter-link {\n\tcolor: inherit;\n\ttext-decoration: underline;\n\ttext-decoration-style: dotted;\n\tcursor: pointer;\n}\n.fwlive-filter-link:hover { color: var(--primary-color-high); }\n.fwlive-chips {\n\tdisplay: none;\n\tflex-wrap: wrap;\n\talign-items: center;\n\tgap: 6px;\n\tmargin: 0 0 10px;\n}\n.fwlive-chip-label {\n\tline-height: 1.3;\n}\n.fwlive-chip-not {\n\tfont-weight: 700;\n}\n.fwlive-chip {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 4px;\n\tpadding: 2px 8px;\n\tbackground: var(--background-color-low);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tfont-size: 0.88em;\n}\n.fwlive-chip-negated {\n\tborder-style: dashed;\n\tbackground: var(--background-color-medium);\n}\n.fwlive-chip-include {\n}\n.fwlive-chip-polarity {\n\tfont-weight: 700;\n\tfont-size: 0.78em;\n\ttext-transform: uppercase;\n\tletter-spacing: 0.03em;\n\tcolor: var(--text-color-medium);\n}\n.fwlive-chip-strike {\n\ttext-decoration: line-through;\n\ttext-decoration-thickness: 1.5px;\n}\n.fwlive-chip-sym {\n\tfont-weight: 700;\n\tline-height: 1;\n\tmin-width: 0.9em;\n\ttext-align: center;\n}\n.fwlive-chip-sym-light {\n\tcolor: var(--text-color-medium);\n\tfont-size: 0.95em;\n}\n#fwlive-row-tint { width: auto; min-width: 9.5em; }\n/* labels (default) */\n.fwlive-chips-labels .fwlive-chip-include {\n\tborder-left: 3px solid var(--primary-color-medium, var(--primary-color-high, var(--border-color-high)));\n}\n.fwlive-chips-labels .fwlive-chip-negated .fwlive-chip-not {\n\tcolor: var(--text-color-high);\n}\n.fwlive-chip-invert-wrap {\n\tposition: relative;\n\tdisplay: inline-flex;\n}\n.fwlive-chip-invert-wrap::before {\n\tcontent: attr(data-tip);\n\tposition: absolute;\n\tbottom: calc(100% + 6px);\n\tleft: 50%;\n\ttransform: translateX(-50%);\n\twhite-space: nowrap;\n\tpadding: 4px 8px;\n\tfont-size: 0.85em;\n\tfont-weight: normal;\n\tcolor: var(--text-color-high);\n\tbackground: var(--background-color-high);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbox-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);\n\topacity: 0;\n\tvisibility: hidden;\n\tpointer-events: none;\n\tz-index: 20;\n\ttransition: opacity 0.15s ease 1.5s, visibility 0s linear 2s;\n}\n.fwlive-chip-invert-wrap:hover::before,\n.fwlive-chip-invert-wrap:focus-within::before {\n\topacity: 1;\n\tvisibility: visible;\n\ttransition: opacity 0.12s ease 0.35s, visibility 0s;\n}\n.fwlive-chip-invert {\n\tcolor: var(--text-color-medium);\n\tbackground: none;\n\tborder: none;\n\tpadding: 0;\n\tfont: inherit;\n\tfont-weight: 700;\n\tline-height: 1;\n\tcursor: pointer;\n}\n.fwlive-chip-invert:hover { color: var(--primary-color-high); }\n.fwlive-chip-remove {\n\tcolor: var(--text-color-medium);\n\ttext-decoration: none;\n\tfont-weight: 700;\n\tline-height: 1;\n}\n.fwlive-chip-remove:hover { color: var(--fwlive-deny-color); }\n.fwlive-chip-clear {\n\tfont-size: 0.88em;\n\tmargin-left: 4px;\n}\n.fwlive-flood {\n\tdisplay: none;\n\tmargin: 0 0 10px;\n\tpadding: 8px 12px;\n\tbackground: var(--background-color-low);\n\tborder: 1px solid var(--warn-color-high);\n\tborder-radius: 3px;\n\tcolor: var(--text-color-high);\n\tfont-size: 0.92em;\n}\n.fwlive-tint-warn {\n\tdisplay: none;\n\tcolor: var(--warn-color-high);\n\tfont-size: 0.78em;\n\tfont-weight: 600;\n\twhite-space: nowrap;\n\tpadding: 2px 8px;\n\tborder: 1px solid var(--warn-color-high);\n\tborder-radius: 999px;\n\tbackground: color-mix(in srgb, var(--warn-color-high) 10%, transparent);\n}\n.fwlive-map[data-view=\"simple\"] #fwlive-msg-group { display: none; }\n@media (max-width: 850px) {\n\t.fwlive-title-status {\n\t\tflex-basis: 100%;\n\t\tmargin-left: 0;\n\t}\n\t.fwlive-watch-group {\n\t\tborder-left: 0;\n\t\tpadding-left: 0;\n\t\tpadding-right: 10px;\n\t}\n\t.fwlive-display-bar {\n\t\talign-items: flex-start;\n\t}\n}\n.fwlive-help-row {\n\tdisplay: flex;\n\talign-items: baseline;\n\tjustify-content: space-between;\n\tgap: 12px;\n\tmargin: 10px 0 0;\n}\n.fwlive-help {\n\tmargin: 0;\n\tfont-size: 0.92em;\n\tcolor: var(--text-color-medium);\n\tmin-width: 0;\n\tflex: 1;\n}\n.fwlive-help ul { margin: 6px 0 0 1.2em; padding: 0; }\n.fwlive-help li { margin: 4px 0; }\n.fwlive-build {\n\tflex-shrink: 0;\n\tfont-size: 0.78em;\n\tcolor: var(--text-color-low, var(--text-color-medium));\n\tfont-variant-numeric: tabular-nums;\n\tuser-select: all;\n\twhite-space: nowrap;\n}\n.fwlive-backend {\n\tfont-size: 0.65em;\n\tfont-weight: normal;\n\tcolor: var(--text-color-medium);\n\tmargin-left: 8px;\n\tvertical-align: middle;\n}\n.fwlive-filter-panel { margin-bottom: 12px; }\n.fwlive-map[data-view=\"simple\"] .fwlive-grid-core {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(3, minmax(140px, 1fr));\n\tgap: 8px;\n}\n.fwlive-map[data-view=\"simple\"] .fwlive-more-filters { margin-top: 8px; }\n.fwlive-map[data-view=\"simple\"] .fwlive-grid-extra {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(4, minmax(140px, 1fr));\n\tgap: 8px;\n\tmargin-top: 8px;\n}\n.fwlive-map[data-view=\"detailed\"] .fwlive-more-filters > summary { display: none; }\n.fwlive-map[data-view=\"detailed\"] .fwlive-filter-panel {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(4, minmax(140px, 1fr));\n\tgap: 8px;\n}\n.fwlive-map[data-view=\"detailed\"] .fwlive-grid-core,\n.fwlive-map[data-view=\"detailed\"] .fwlive-grid-extra,\n.fwlive-map[data-view=\"detailed\"] .fwlive-more-filters { display: contents; }\n.fwlive-row-clickable { cursor: pointer; }\n.fwlive-row-expanded td { border-bottom-color: transparent; }\n.fwlive-msg-expand td {\n\tbackground: var(--background-color-low);\n\tpadding: 8px 12px 10px;\n\tborder-bottom: 1px solid var(--border-color-medium);\n}\n.fwlive-msg-expand-label {\n\tfont-size: 0.82em;\n\tcolor: var(--text-color-medium);\n\tmargin-bottom: 4px;\n\tfont-weight: 600;\n}\n.fwlive-msg-expand-body {\n\tmargin: 0;\n\twhite-space: pre-wrap;\n\tword-break: break-word;\n\tfont-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n\tfont-size: 0.85em;\n\tcolor: var(--text-color-high);\n}\n.fwlive-flow-arrow {\n\tcolor: var(--text-color-low);\n\tfont-weight: 700;\n}\n.fwlive-flow-cell { white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; }\n.fwlive-map[data-view=\"simple\"] #fwlive-table td.fwlive-action.fwlive-pass,\n.fwlive-map[data-view=\"simple\"] #fwlive-table td.fwlive-action.fwlive-pass a.fwlive-filter-link {\n\tcolor: var(--fwlive-pass-color);\n}\n.fwlive-map[data-view=\"simple\"] #fwlive-table td.fwlive-action.fwlive-deny,\n.fwlive-map[data-view=\"simple\"] #fwlive-table td.fwlive-action.fwlive-deny a.fwlive-filter-link {\n\tcolor: var(--fwlive-deny-color);\n}\n.fwlive-iface-badge {\n\tborder-radius: 10px;\n\tpadding: 2px 8px;\n}\n" +}); diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/fwlive.css b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/fwlive.css new file mode 100644 index 00000000..080e8249 --- /dev/null +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/fwlive.css @@ -0,0 +1,779 @@ +.fwlive-map { + max-width: none; + width: 100%; + --fwlive-pass-color: var(--success-color-high, var(--success-color, #46a546)); + --fwlive-deny-color: var(--error-color-high, var(--error-color, #ca3c3c)); + --fwlive-bg-medium: var(--background-color-medium, var(--white-color-low, #f9f9f9)); + --fwlive-paused-color: #b45309; +} +/* Wide page in Detail only — Simple keeps LuCI’s 940px column. */ +#maincontent.container:has(.fwlive-map[data-view="detailed"]) { + max-width: none; + padding-left: 16px; + padding-right: 16px; +} +.fwlive-map[data-row-tint="accessible"] { + --fwlive-pass-color: #0d9488; + --fwlive-deny-color: #c2410c; +} +.fwlive-map.fwlive-watch-paused .fwlive-title-row, +.fwlive-map.fwlive-watch-paused .fwlive-watch-strip { + background: rgba(180, 83, 9, 0.08); + background: color-mix(in srgb, var(--fwlive-paused-color) 8%, transparent); +} +.fwlive-map.fwlive-watch-paused .fwlive-title-row { + margin: 0 -8px; + padding: 6px 8px 0; + border-radius: 3px 3px 0 0; +} +.fwlive-map.fwlive-watch-paused .fwlive-watch-strip { + margin: 0 -8px 10px; + padding: 8px; + border-radius: 0 0 3px 3px; + border-bottom-color: transparent; +} +.fwlive-title-row { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 8px 16px; + margin: 0 0 4px; +} +.fwlive-title-row h2 { + margin: 0; + flex: 0 1 auto; +} +.fwlive-title-status { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-width: 0; + margin-left: auto; +} +.fwlive-watch-strip { + display: flex; + flex-wrap: wrap; + align-items: stretch; + justify-content: flex-start; + gap: 0; + min-height: 40px; + padding: 8px 0 10px; + border-bottom: 1px solid var(--border-color-medium); + margin-bottom: 10px; +} +.fwlive-watch-group { + display: flex; + align-items: center; + gap: 6px; + min-height: 28px; + padding: 0 10px; + border-left: 1px solid var(--border-color-medium); + flex-wrap: wrap; +} +.fwlive-watch-group:last-child { + padding-right: 0; +} +.fwlive-watch-group-label { + margin-right: 2px; + color: var(--text-color-medium); + font-size: 0.72em; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} +.fwlive-watch-seg { + display: inline-flex; + align-items: stretch; + border: 1px solid var(--border-color-medium); + border-radius: 3px; + overflow: hidden; +} +.fwlive-watch-seg .fwlive-seg-btn { + margin: 0; + border: 0; + border-radius: 0; + border-right: 1px solid var(--border-color-medium); + background: transparent; + padding: 4px 10px; + font-size: 0.88em; + line-height: 1.25; + min-height: 28px; +} +.fwlive-watch-seg .fwlive-seg-btn:last-child { + border-right: 0; +} +.fwlive-watch-seg .fwlive-seg-btn[aria-pressed="true"] { + background: rgba(26, 111, 156, 0.12); + background: color-mix(in srgb, var(--primary-color-high) 12%, transparent); + color: var(--primary-color-high); + font-weight: 600; +} +.fwlive-watch-label { font-weight: 600; } +.fwlive-watch-paused .fwlive-watch-label { color: var(--fwlive-paused-color); } +.fwlive-map.fwlive-watch-paused .fwlive-title-status .fwlive-watch-label { color: var(--fwlive-paused-color); } +.fwlive-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-color-low, var(--border-color-high)); + display: inline-block; + flex-shrink: 0; +} +.fwlive-dot.fwlive-dot-on { + background: var(--fwlive-pass-color); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--fwlive-pass-color) 22%, transparent); +} +.fwlive-btn-ghost { + background: transparent; + border-color: var(--border-color-medium); +} +.fwlive-btn-quiet { + background: transparent; + border-color: var(--border-color-medium); + color: var(--text-color-medium); + font-weight: 500; +} +.fwlive-log-merged { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid var(--border-color-medium); + border-radius: 3px; + background: var(--background-color-medium, var(--white-color-low, var(--background-color-high))); + color: var(--text-color-high); + font-weight: 500; + font-size: 0.88em; + line-height: 1.25; + min-height: 28px; + cursor: pointer; +} +.fwlive-log-merged:hover { + background: var(--background-color-low, var(--background-color-medium)); +} +.fwlive-log-merged:disabled { + opacity: 0.65; + cursor: default; +} +.fwlive-log-on-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--fwlive-pass-color); + flex-shrink: 0; +} +.fwlive-log-rate { + color: var(--text-color-medium); + font-weight: 400; +} +.fwlive-display-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 14px; + margin: 0 0 12px; + padding: 8px 12px; + border: 1px solid var(--border-color-medium); + border-radius: 3px; + background: var(--background-color-high); +} +.fwlive-display-bar-label { + flex: 0 0 auto; + font-weight: 600; + font-size: 0.92em; + color: var(--text-color-high); + white-space: nowrap; +} +.fwlive-display-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 14px; + min-width: 0; + flex: 1 1 auto; +} +.fwlive-display-ctl { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + font-size: 0.92em; + white-space: nowrap; +} +.fwlive-display-ctl input[type="checkbox"] { + margin: 0; + flex-shrink: 0; +} +.fwlive-display-ctl select { + margin-left: 0; +} +.fwlive-display-ctl.fwlive-hidden { + display: none; +} +/* Draw our own disclosure arrow — themes that restyle summary drop the native marker. */ +.fwlive-more-filters > summary, +.fwlive-help > summary { + cursor: pointer; + list-style: none; +} +.fwlive-more-filters > summary::-webkit-details-marker, +.fwlive-help > summary::-webkit-details-marker { display: none; } +.fwlive-more-filters > summary::before, +.fwlive-help > summary::before { + content: "▸ "; + color: var(--text-color-medium); +} +.fwlive-more-filters[open] > summary::before, +.fwlive-help[open] > summary::before { content: "▾ "; } +.fwlive-ctl { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.92em; + flex-wrap: wrap; +} +.fwlive-ctl select { margin-left: 4px; } +#fwlive-limit { width: auto; min-width: 4.5em; } +.fwlive-grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 8px; margin-bottom: 12px; } +.fwlive-status { margin: 0; color: var(--text-color-medium); font-size: 0.9em; } +.fwlive-status-paused { color: var(--warn-color-high); font-weight: 600; } +.fwlive-status-error { color: var(--warn-color-high); font-weight: 600; } +.fwlive-hint-line { + margin: 0 0 10px; + font-size: 0.85em; + color: var(--text-color-medium); +} +.fwlive-empty-title { + font-weight: 600; + margin: 0 0 6px; + color: var(--text-color-high); +} +.fwlive-find-row .fwlive-grid-core { margin-bottom: 0; } +.fwlive-proto-pair { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); + gap: 6px; + align-items: stretch; + min-width: 0; +} +.fwlive-proto-pair select, +.fwlive-proto-pair input { + width: 100%; + min-width: 0; +} +@media (max-width: 520px) { + .fwlive-proto-pair { grid-template-columns: 1fr; } +} +.fwlive-scroll { + max-height: 78vh; + max-height: 78dvh; + overflow: auto; + border: 1px solid var(--border-color-medium); + border-radius: 3px; + background: var(--background-color-high); + width: 100%; + scrollbar-gutter: stable; +} +#fwlive-table { margin: 0; width: 100%; table-layout: auto; border-collapse: collapse; } +.fwlive-map[data-view="simple"] #fwlive-table { table-layout: fixed; width: 100%; } +.fwlive-map[data-view="simple"] col.fwlive-col-action { width: 5rem; } +.fwlive-map[data-view="simple"] col.fwlive-col-time { width: 5rem; } +.fwlive-map[data-view="simple"] col.fwlive-col-iface { width: 5.5rem; } +.fwlive-map[data-view="simple"] col.fwlive-col-proto { width: 4rem; } +.fwlive-map[data-view="simple"] col.fwlive-col-rule { width: 12rem; } +.fwlive-map[data-view="detailed"] .fwlive-scroll #fwlive-table { + width: max-content; + min-width: 100%; +} +.fwlive-map[data-view="detailed"] col.fwlive-col-time { width: 11rem; } +.fwlive-map[data-view="detailed"] col.fwlive-col-action { width: 4.5rem; } +.fwlive-map[data-view="detailed"] col.fwlive-col-rule { width: 8rem; } +.fwlive-map[data-view="detailed"] col.fwlive-col-iface-in, +.fwlive-map[data-view="detailed"] col.fwlive-col-iface-out { width: 3.5rem; } +.fwlive-map[data-view="detailed"] col.fwlive-col-dir { width: 3rem; } +.fwlive-map[data-view="detailed"] col.fwlive-col-proto { width: 3.25rem; } +.fwlive-map[data-view="detailed"] col.fwlive-col-src, +.fwlive-map[data-view="detailed"] col.fwlive-col-dst { width: 9rem; } +.fwlive-map[data-view="detailed"] col.fwlive-col-sport, +.fwlive-map[data-view="detailed"] col.fwlive-col-dport { width: 3.75rem; } +.fwlive-map[data-view="detailed"] col.fwlive-col-flags { width: 5rem; } +.fwlive-map[data-view="detailed"] col.fwlive-col-len { width: 3.25rem; } +.fwlive-scroll.fwlive-msg-oneline #fwlive-table { + width: max-content; + min-width: 100%; +} +.fwlive-scroll.fwlive-msg-oneline tbody td { vertical-align: middle; } +#fwlive-table thead th { + position: sticky; + top: 0; + z-index: 2; + background: var(--background-color-low); + border-bottom: 2px solid var(--border-color-high); + white-space: nowrap; + padding: 5px 8px; + font-size: 0.9em; + font-weight: 600; + vertical-align: top; + text-align: left; +} +#fwlive-table thead th.fwlive-port, +#fwlive-table thead th.fwlive-len { + text-align: right; +} +.fwlive-map[data-view="simple"] #fwlive-table thead th, +.fwlive-map[data-view="simple"] #fwlive-table tbody td { + vertical-align: top; +} +#fwlive-table thead th.fwlive-action { + font-weight: 700; + text-transform: lowercase; +} +#fwlive-table tbody td { + padding: 5px 8px; + border-bottom: 1px solid var(--border-color-low); + vertical-align: top; + font-size: 0.92em; +} +.fwlive-row-alt td { + background: #f9f9f9; + background: var(--fwlive-bg-medium); +} +.fwlive-time, .fwlive-addr, .fwlive-port, .fwlive-len { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: nowrap; +} +.fwlive-port, .fwlive-len { text-align: right; } +.fwlive-rule, .fwlive-iface, .fwlive-proto, .fwlive-dir, .fwlive-flags { white-space: nowrap; } +.fwlive-rule { font-size: 0.88em; } +.fwlive-rule-link { + color: var(--primary-color-high); + text-decoration: none; + font-weight: 500; +} +.fwlive-rule-link:hover { text-decoration: underline; } +.fwlive-iface-badge { + display: inline-block; + padding: 1px 6px; + background: var(--background-color-low); + border: 1px solid var(--border-color-medium); + border-radius: 3px; + font-size: 0.85em; + text-decoration: none; +} +.fwlive-iface-badge:hover { + background: var(--background-color-medium); + border-color: var(--primary-color-medium); +} +.fwlive-action { + font-weight: 700; + text-transform: lowercase; + white-space: nowrap; +} +.fwlive-deny { color: var(--fwlive-deny-color); } +.fwlive-pass { color: var(--fwlive-pass-color); } +#fwlive-table td.fwlive-action.fwlive-pass, +#fwlive-table td.fwlive-action.fwlive-pass a.fwlive-filter-link { + color: var(--fwlive-pass-color); +} +#fwlive-table td.fwlive-action.fwlive-deny, +#fwlive-table td.fwlive-action.fwlive-deny a.fwlive-filter-link { + color: var(--fwlive-deny-color); +} +.fwlive-map[data-row-tint="classic"] #fwlive-table tbody tr.fwlive-row-pass td { + background: rgba(70, 165, 70, 0.12); + background: color-mix(in srgb, var(--fwlive-pass-color) 12%, transparent); +} +.fwlive-map[data-row-tint="classic"] #fwlive-table tbody tr.fwlive-row-pass.fwlive-row-alt td { + background: rgba(70, 165, 70, 0.18); + background: color-mix(in srgb, var(--fwlive-pass-color) 12%, var(--fwlive-bg-medium)); +} +.fwlive-map[data-row-tint="classic"] #fwlive-table tbody tr.fwlive-row-deny td { + background: rgba(202, 60, 60, 0.12); + background: color-mix(in srgb, var(--fwlive-deny-color) 12%, transparent); +} +.fwlive-map[data-row-tint="classic"] #fwlive-table tbody tr.fwlive-row-deny.fwlive-row-alt td { + background: rgba(202, 60, 60, 0.18); + background: color-mix(in srgb, var(--fwlive-deny-color) 12%, var(--fwlive-bg-medium)); +} +.fwlive-map[data-row-tint="classic"] #fwlive-table tbody tr.fwlive-row-pass:hover td { + background: rgba(70, 165, 70, 0.20); + background: color-mix(in srgb, var(--fwlive-pass-color) 20%, transparent); +} +.fwlive-map[data-row-tint="classic"] #fwlive-table tbody tr.fwlive-row-deny:hover td { + background: rgba(202, 60, 60, 0.20); + background: color-mix(in srgb, var(--fwlive-deny-color) 20%, transparent); +} +.fwlive-map[data-row-tint="classic"][data-tint-fallback="1"] #fwlive-table tbody tr.fwlive-row-pass td { + background: rgba(70, 165, 70, 0.12); +} +.fwlive-map[data-row-tint="classic"][data-tint-fallback="1"] #fwlive-table tbody tr.fwlive-row-pass.fwlive-row-alt td { + background: rgba(70, 165, 70, 0.18); +} +.fwlive-map[data-row-tint="classic"][data-tint-fallback="1"] #fwlive-table tbody tr.fwlive-row-deny td { + background: rgba(202, 60, 60, 0.12); +} +.fwlive-map[data-row-tint="classic"][data-tint-fallback="1"] #fwlive-table tbody tr.fwlive-row-deny.fwlive-row-alt td { + background: rgba(202, 60, 60, 0.18); +} +.fwlive-map[data-row-tint="classic"][data-tint-fallback="1"] #fwlive-table tbody tr.fwlive-row-pass:hover td { + background: rgba(70, 165, 70, 0.20); +} +.fwlive-map[data-row-tint="classic"][data-tint-fallback="1"] #fwlive-table tbody tr.fwlive-row-deny:hover td { + background: rgba(202, 60, 60, 0.20); +} +.fwlive-map[data-row-tint="accessible"] #fwlive-table tbody tr.fwlive-row-pass td { + background: rgba(13, 148, 136, 0.12); + background: color-mix(in srgb, var(--fwlive-pass-color) 12%, transparent); +} +.fwlive-map[data-row-tint="accessible"] #fwlive-table tbody tr.fwlive-row-pass.fwlive-row-alt td { + background: rgba(13, 148, 136, 0.18); + background: color-mix(in srgb, var(--fwlive-pass-color) 12%, var(--fwlive-bg-medium)); +} +.fwlive-map[data-row-tint="accessible"] #fwlive-table tbody tr.fwlive-row-deny td { + background: rgba(194, 65, 12, 0.12); + background: color-mix(in srgb, var(--fwlive-deny-color) 12%, transparent); +} +.fwlive-map[data-row-tint="accessible"] #fwlive-table tbody tr.fwlive-row-deny.fwlive-row-alt td { + background: rgba(194, 65, 12, 0.18); + background: color-mix(in srgb, var(--fwlive-deny-color) 12%, var(--fwlive-bg-medium)); +} +.fwlive-map[data-row-tint="accessible"] #fwlive-table tbody tr.fwlive-row-pass:hover td { + background: rgba(13, 148, 136, 0.20); + background: color-mix(in srgb, var(--fwlive-pass-color) 20%, transparent); +} +.fwlive-map[data-row-tint="accessible"] #fwlive-table tbody tr.fwlive-row-deny:hover td { + background: rgba(194, 65, 12, 0.20); + background: color-mix(in srgb, var(--fwlive-deny-color) 20%, transparent); +} +.fwlive-map[data-row-tint="accessible"][data-tint-fallback="1"] #fwlive-table tbody tr.fwlive-row-pass td { + background: rgba(13, 148, 136, 0.12); +} +.fwlive-map[data-row-tint="accessible"][data-tint-fallback="1"] #fwlive-table tbody tr.fwlive-row-pass.fwlive-row-alt td { + background: rgba(13, 148, 136, 0.18); +} +.fwlive-map[data-row-tint="accessible"][data-tint-fallback="1"] #fwlive-table tbody tr.fwlive-row-deny td { + background: rgba(194, 65, 12, 0.12); +} +.fwlive-map[data-row-tint="accessible"][data-tint-fallback="1"] #fwlive-table tbody tr.fwlive-row-deny.fwlive-row-alt td { + background: rgba(194, 65, 12, 0.18); +} +.fwlive-map[data-row-tint="accessible"][data-tint-fallback="1"] #fwlive-table tbody tr.fwlive-row-pass:hover td { + background: rgba(13, 148, 136, 0.20); +} +.fwlive-map[data-row-tint="accessible"][data-tint-fallback="1"] #fwlive-table tbody tr.fwlive-row-deny:hover td { + background: rgba(194, 65, 12, 0.20); +} +.fwlive-unknown { color: var(--text-color-medium); font-weight: 500; } +.fwlive-message { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85em; + color: var(--text-color-high); +} +.fwlive-scroll.fwlive-msg-wrap .fwlive-message-wrap { + display: block; + min-width: 16em; + max-width: 42em; + white-space: normal; + word-break: break-word; + word-wrap: break-word; +} +.fwlive-scroll.fwlive-msg-oneline .fwlive-message { + white-space: nowrap; + max-width: none; + min-width: 32em; +} +#fwlive-table th.fwlive-th-message, +.fwlive-scroll.fwlive-msg-oneline .fwlive-message { + width: 99%; +} +.fwlive-empty { + margin: 12px 0; + padding: 10px; + background: var(--background-color-medium); + border: 1px dashed var(--border-color-high); +} +.fwlive-empty-muted { + font-size: 0.92em; + color: var(--text-color-medium); + margin: 0 0 8px; +} +.fwlive-consent { + margin: 0 0 12px; + padding: 10px 12px; + background: var(--background-color-high); + border: 1px solid var(--border-color-medium); + border-radius: 3px; +} +.fwlive-consent-list { + margin: 0 0 10px; + padding-left: 1.25em; +} +.fwlive-consent-list li { + margin: 0 0 4px; +} +.fwlive-consent-check { + margin: 0 0 10px; + font-size: 0.92em; +} +.fwlive-consent-actions { + margin: 0; +} +.fwlive-logging-bar { + display: contents; +} +.fwlive-logging-status { + font-size: 0.92em; + color: var(--text-color-medium); + margin-right: 4px; +} +.fwlive-logging-notice { + color: var(--fwlive-pass-color); +} +.fwlive-filter-link { + color: inherit; + text-decoration: underline; + text-decoration-style: dotted; + cursor: pointer; +} +.fwlive-filter-link:hover { color: var(--primary-color-high); } +.fwlive-chips { + display: none; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin: 0 0 10px; +} +.fwlive-chip-label { + line-height: 1.3; +} +.fwlive-chip-not { + font-weight: 700; +} +.fwlive-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + background: var(--background-color-low); + border: 1px solid var(--border-color-medium); + border-radius: 3px; + font-size: 0.88em; +} +.fwlive-chip-negated { + border-style: dashed; + background: var(--background-color-medium); +} +.fwlive-chip-include { +} +.fwlive-chip-polarity { + font-weight: 700; + font-size: 0.78em; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text-color-medium); +} +.fwlive-chip-strike { + text-decoration: line-through; + text-decoration-thickness: 1.5px; +} +.fwlive-chip-sym { + font-weight: 700; + line-height: 1; + min-width: 0.9em; + text-align: center; +} +.fwlive-chip-sym-light { + color: var(--text-color-medium); + font-size: 0.95em; +} +#fwlive-row-tint { width: auto; min-width: 9.5em; } +/* labels (default) */ +.fwlive-chips-labels .fwlive-chip-include { + border-left: 3px solid var(--primary-color-medium, var(--primary-color-high, var(--border-color-high))); +} +.fwlive-chips-labels .fwlive-chip-negated .fwlive-chip-not { + color: var(--text-color-high); +} +.fwlive-chip-invert-wrap { + position: relative; + display: inline-flex; +} +.fwlive-chip-invert-wrap::before { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + padding: 4px 8px; + font-size: 0.85em; + font-weight: normal; + color: var(--text-color-high); + background: var(--background-color-high); + border: 1px solid var(--border-color-medium); + border-radius: 3px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12); + opacity: 0; + visibility: hidden; + pointer-events: none; + z-index: 20; + transition: opacity 0.15s ease 1.5s, visibility 0s linear 2s; +} +.fwlive-chip-invert-wrap:hover::before, +.fwlive-chip-invert-wrap:focus-within::before { + opacity: 1; + visibility: visible; + transition: opacity 0.12s ease 0.35s, visibility 0s; +} +.fwlive-chip-invert { + color: var(--text-color-medium); + background: none; + border: none; + padding: 0; + font: inherit; + font-weight: 700; + line-height: 1; + cursor: pointer; +} +.fwlive-chip-invert:hover { color: var(--primary-color-high); } +.fwlive-chip-remove { + color: var(--text-color-medium); + text-decoration: none; + font-weight: 700; + line-height: 1; +} +.fwlive-chip-remove:hover { color: var(--fwlive-deny-color); } +.fwlive-chip-clear { + font-size: 0.88em; + margin-left: 4px; +} +.fwlive-flood { + display: none; + margin: 0 0 10px; + padding: 8px 12px; + background: var(--background-color-low); + border: 1px solid var(--warn-color-high); + border-radius: 3px; + color: var(--text-color-high); + font-size: 0.92em; +} +.fwlive-tint-warn { + display: none; + color: var(--warn-color-high); + font-size: 0.78em; + font-weight: 600; + white-space: nowrap; + padding: 2px 8px; + border: 1px solid var(--warn-color-high); + border-radius: 999px; + background: color-mix(in srgb, var(--warn-color-high) 10%, transparent); +} +.fwlive-map[data-view="simple"] #fwlive-msg-group { display: none; } +@media (max-width: 850px) { + .fwlive-title-status { + flex-basis: 100%; + margin-left: 0; + } + .fwlive-watch-group { + border-left: 0; + padding-left: 0; + padding-right: 10px; + } + .fwlive-display-bar { + align-items: flex-start; + } +} +.fwlive-help-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin: 10px 0 0; +} +.fwlive-help { + margin: 0; + font-size: 0.92em; + color: var(--text-color-medium); + min-width: 0; + flex: 1; +} +.fwlive-help ul { margin: 6px 0 0 1.2em; padding: 0; } +.fwlive-help li { margin: 4px 0; } +.fwlive-build { + flex-shrink: 0; + font-size: 0.78em; + color: var(--text-color-low, var(--text-color-medium)); + font-variant-numeric: tabular-nums; + user-select: all; + white-space: nowrap; +} +.fwlive-backend { + font-size: 0.65em; + font-weight: normal; + color: var(--text-color-medium); + margin-left: 8px; + vertical-align: middle; +} +.fwlive-filter-panel { margin-bottom: 12px; } +.fwlive-map[data-view="simple"] .fwlive-grid-core { + display: grid; + grid-template-columns: repeat(3, minmax(140px, 1fr)); + gap: 8px; +} +.fwlive-map[data-view="simple"] .fwlive-more-filters { margin-top: 8px; } +.fwlive-map[data-view="simple"] .fwlive-grid-extra { + display: grid; + grid-template-columns: repeat(4, minmax(140px, 1fr)); + gap: 8px; + margin-top: 8px; +} +.fwlive-map[data-view="detailed"] .fwlive-more-filters > summary { display: none; } +.fwlive-map[data-view="detailed"] .fwlive-filter-panel { + display: grid; + grid-template-columns: repeat(4, minmax(140px, 1fr)); + gap: 8px; +} +.fwlive-map[data-view="detailed"] .fwlive-grid-core, +.fwlive-map[data-view="detailed"] .fwlive-grid-extra, +.fwlive-map[data-view="detailed"] .fwlive-more-filters { display: contents; } +.fwlive-row-clickable { cursor: pointer; } +.fwlive-row-expanded td { border-bottom-color: transparent; } +.fwlive-msg-expand td { + background: var(--background-color-low); + padding: 8px 12px 10px; + border-bottom: 1px solid var(--border-color-medium); +} +.fwlive-msg-expand-label { + font-size: 0.82em; + color: var(--text-color-medium); + margin-bottom: 4px; + font-weight: 600; +} +.fwlive-msg-expand-body { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85em; + color: var(--text-color-high); +} +.fwlive-flow-arrow { + color: var(--text-color-low); + font-weight: 700; +} +.fwlive-flow-cell { white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; } +.fwlive-map[data-view="simple"] #fwlive-table td.fwlive-action.fwlive-pass, +.fwlive-map[data-view="simple"] #fwlive-table td.fwlive-action.fwlive-pass a.fwlive-filter-link { + color: var(--fwlive-pass-color); +} +.fwlive-map[data-view="simple"] #fwlive-table td.fwlive-action.fwlive-deny, +.fwlive-map[data-view="simple"] #fwlive-table td.fwlive-action.fwlive-deny a.fwlive-filter-link { + color: var(--fwlive-deny-color); +} +.fwlive-iface-badge { + border-radius: 10px; + padding: 2px 8px; +} diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/hostname.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/hostname.js new file mode 100644 index 00000000..98d04adc --- /dev/null +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/hostname.js @@ -0,0 +1,60 @@ +'use strict'; +'require baseclass'; + +/** + * Hostname resolve cache helpers (LRU + failure TTL). + * Pure Map helpers — safe to unit-test on host without LuCI. + */ +return baseclass.extend({ + CACHE_MAX: 1000, + FAIL_TTL_MS: 60000, + FAIL_MAX: 1000, + + /* Touch-on-write LRU: re-insert moves key to newest; evict oldest when over max. */ + lruSet: function(map, key, value, max) { + const cap = max || this.CACHE_MAX; + if (map.has(key)) + map.delete(key); + map.set(key, value); + while (map.size > cap) { + const oldest = map.keys().next().value; + map.delete(oldest); + } + return map; + }, + + lruGet: function(map, key) { + if (!map.has(key)) + return undefined; + const value = map.get(key); + map.delete(key); + map.set(key, value); + return value; + }, + + failIsHot: function(failedMap, ip, nowMs, ttlMs) { + if (!failedMap || !failedMap.has(ip)) + return false; + const at = failedMap.get(ip); + const ttl = ttlMs == null ? this.FAIL_TTL_MS : ttlMs; + const now = nowMs == null ? Date.now() : nowMs; + if ((now - at) >= ttl) { + failedMap.delete(ip); + return false; + } + return true; + }, + + failMark: function(failedMap, ip, nowMs, max) { + const cap = max || this.FAIL_MAX; + const now = nowMs == null ? Date.now() : nowMs; + if (failedMap.has(ip)) + failedMap.delete(ip); + failedMap.set(ip, now); + while (failedMap.size > cap) { + const oldest = failedMap.keys().next().value; + failedMap.delete(oldest); + } + return failedMap; + } +}); diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/links.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/links.js new file mode 100644 index 00000000..2921c117 --- /dev/null +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/links.js @@ -0,0 +1,151 @@ +'use strict'; +'require baseclass'; /* LuCI require() needs Class.isSubclass — plain return {} fails */ +'require fwlive.log as log'; + +/** + * Link-builder helpers for luci-app-fwlive. + * + * Pure helpers (no filter side-effects): + * luciUrl, firewallZonesPath, firewallZonesUrl, firewallZonesLink + * + * Filter-aware helpers (require an onFilterClick callback): + * filterLink, addrFilterLink, ruleAdminPath, ruleAdminLink, ifaceLink + * + * No host element — all functions return DOM nodes or strings. + * May require fwlive.log for formatCell. + */ + +function luciUrl(path) { + if (typeof L !== 'undefined' && L.url) + return L.url(path); + + return '/cgi-bin/luci/' + path; +} + +function firewallZonesPath() { + return 'admin/network/firewall/zones'; +} + +function firewallZonesUrl() { + return luciUrl(firewallZonesPath()); +} + +function firewallZonesLink(label) { + return E('a', { + 'href': firewallZonesUrl(), + 'class': 'fwlive-filter-link' + }, [ label || _('Network → Firewall') ]); +} + +/** + * @param {string} field - filter field name + * @param {string} value - filter value + * @param {string} [label] - display label (defaults to value) + * @param {function} onFilterClick - callback(field, value, ev) + */ +function filterLink(field, value, label, onFilterClick) { + if (!value) + return log.formatCell(value); + + return E('a', { + 'href': '#', + 'class': 'fwlive-filter-link', + 'title': _('Filter by %s').format(field), + 'click': function(ev) { onFilterClick(field, value, ev); } + }, [ label || value ]); +} + +/** + * @param {string} field - filter field ('src' or 'dst') + * @param {string} ip - IP address + * @param {boolean} showHostnames + * @param {Map|null} hostnameCache + * @param {function} onFilterClick - callback(field, value, ev) + */ +function addrFilterLink(field, ip, showHostnames, hostnameCache, onFilterClick) { + if (!ip) + return log.formatCell(ip); + + const name = showHostnames && hostnameCache ? hostnameCache.get(ip) : null; + const display = name || ip; + const title = name ? ip : _('Filter by %s').format(field); + + return E('a', { + 'href': '#', + 'class': 'fwlive-filter-link', + 'title': title, + 'click': function(ev) { onFilterClick(field, ip, ev); } + }, [ display ]); +} + +/** + * @param {string} hint - rule hint token + * @param {string} firewallBackend - 'nft' or 'iptables' + */ +function ruleAdminPath(hint, firewallBackend) { + if (hint === 'fw4') + return 'admin/network/firewall/rules'; + + if (firewallBackend === 'iptables') + return 'admin/status/iptables'; + + return 'admin/status/nftables'; +} + +/** + * @param {string} hint - rule hint token + * @param {string} label - display label + * @param {string} firewallBackend - 'nft' or 'iptables' + * @param {function} onFilterClick - callback(field, value, ev) + */ +function ruleAdminLink(hint, label, firewallBackend, onFilterClick) { + if (!hint) + return log.formatCell(hint); + + const path = ruleAdminPath(hint, firewallBackend); + const url = '%s#%s'.format(luciUrl(path), encodeURIComponent(hint)); + const text = label || hint; + + return E('a', { + 'href': '#', + 'class': 'fwlive-filter-link fwlive-rule-link', + 'title': _('Filter logs by rule (hint: %s). Ctrl+click to open firewall settings.').format(hint), + 'click': function(ev) { + if (ev && (ev.ctrlKey || ev.metaKey)) { + if (ev.preventDefault) + ev.preventDefault(); + window.location = url; + return; + } + onFilterClick('q', hint, ev); + } + }, [ text ]); +} + +/** + * @param {string} value - interface name + * @param {function} onFilterClick - callback(field, value, ev) + */ +function ifaceLink(value, onFilterClick) { + if (!value) + return log.formatCell(value); + + return E('a', { + 'href': '#', + 'class': 'fwlive-filter-link fwlive-iface-badge', + 'title': _('Filter by interface'), + 'click': function(ev) { onFilterClick('interface', value, ev); } + }, [ value ]); +} + +return baseclass.extend({ + luciUrl: luciUrl, + firewallZonesPath: firewallZonesPath, + firewallZonesUrl: firewallZonesUrl, + firewallZonesLink: firewallZonesLink, + filterLink: filterLink, + addrFilterLink: addrFilterLink, + ruleAdminPath: ruleAdminPath, + ruleAdminLink: ruleAdminLink, + ifaceLink: ifaceLink +}); diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js new file mode 100644 index 00000000..7a20d73a --- /dev/null +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js @@ -0,0 +1,490 @@ +'use strict'; +'require baseclass'; + +/** + * Shared classify logic mirrors core/fwlive-log.js CLASSIFY_SPEC — keep in sync + * (gen-luci-wrapper.js gates full-spec drift; ./scripts/gen-all.sh verifies). + * LuCI-only helpers live in the @fwlive-codegen:luci-preserve region. + */ +return baseclass.extend({ + CLASSIFY_SPEC: { + glueKeys: ['IN', 'OUT', 'SRC', 'DST', 'PROTO', 'SPT', 'DPT', 'LEN', 'MAC', 'TYPE', 'CODE', 'TTL', 'TOS', 'PREC', 'DF'], + nonFirewallPrefixes: ['dnsmasq', 'procd', 'ubusd', 'netifd', 'odhcpd', 'logd', 'dropbear', 'uhttpd', 'hostapd', 'wpad'], + firewallHints: ['fw4', 'nft', 'iptables', 'kernel', 'firewall'], + actionWords: ['ACCEPT', 'ALLOW', 'PASS', 'DROP', 'REJECT', 'DENY', 'BLOCK'], + rules: [ + { or: [ + { and: [ { kv: ['SRC'] }, { kv: ['DST'] } ] }, + { and: [ { kvAny: ['IN', 'OUT'] }, { kvAny: ['SRC', 'DST', 'PROTO', 'SPT', 'DPT'] } ] }, + { and: [ { action: 'known' }, { kvAny: ['IN', 'OUT', 'PROTO', 'SRC', 'DST'] } ] } + ]}, + { and: [ { hint: true }, { action: 'known' } ] }, + { and: [ { hint: true }, { kvAny: ['IN', 'OUT', 'SRC', 'DST', 'PROTO'] } ] } + ] + }, + + TCP_FLAG_TAIL: /\b(SYN|ACK|FIN|RST|PSH|URG)(?:\s+(?:SYN|ACK|FIN|RST|PSH|URG))*\s*$/i, + NETFILTER_KV_GLUE: /([^\s])(?=(IN|OUT|SRC|DST|PROTO|SPT|DPT|LEN|MAC|TYPE|CODE|TTL|TOS|PREC|DF)=)/g, + + wordPattern: function(words) { + const alt = words.join('|'); + return new RegExp('(^|[^A-Za-z0-9_])(' + alt + ')([^A-Za-z0-9_]|$)', 'i'); + }, + + kvHas: function(msg, key) { + return new RegExp('(^|[^A-Za-z0-9_])' + key + '=').test(msg); + }, + + NON_FIREWALL_PREFIX: /^(dnsmasq|procd|ubusd|netifd|odhcpd|logd|dropbear|uhttpd|hostapd|wpad)([^A-Za-z0-9_]|$)/i, + FIREWALL_HINT: /(^|[^A-Za-z0-9_])(fw4|nft|iptables|kernel|firewall)([^A-Za-z0-9_]|$)/i, + ACTION_RE: /(^|[^A-Za-z0-9_])(ACCEPT|ALLOW|PASS|DROP|REJECT|DENY|BLOCK)([^A-Za-z0-9_]|$)/i, + DENY_ACTION: /(^|[^A-Za-z0-9_])(DROP|REJECT|DENY|BLOCK)([^A-Za-z0-9_]|$)/i, + + normalizeNetfilterMessage: function(message) { + return (message || '').replace(this.NETFILTER_KV_GLUE, '$1 '); + }, + + parseKeyValueLog: function(message) { + const out = {}; + const re = /\b([A-Z]+)=([^\s]+)/g; + const normalized = this.normalizeNetfilterMessage(message); + let match; + + while ((match = re.exec(normalized)) !== null) + out[match[1]] = match[2]; + + return out; + }, + + detectAction: function(message) { + const m = message.match(this.ACTION_RE); + return m ? m[2].toUpperCase() : 'UNKNOWN'; + }, + + evaluateClassifySpec: function(message, actionRaw) { + const msg = this.normalizeNetfilterMessage(message || ''); + const action = actionRaw === undefined ? this.detectAction(msg) : actionRaw; + const self = this; + const has = function(key) { return self.kvHas(msg, key); }; + const hasAny = function(keys) { + for (let i = 0; i < keys.length; i++) { + if (has(keys[i])) + return true; + } + return false; + }; + + const pred = { + kv: function(c) { + for (let i = 0; i < c.kv.length; i++) { + if (!has(c.kv[i])) + return false; + } + return true; + }, + kvAny: function(c) { return hasAny(c.kvAny); }, + action: function(c) { return (c.action === 'known') ? action !== 'UNKNOWN' : true; }, + hint: function() { return self.FIREWALL_HINT.test(msg); } + }; + + const evalNode = function(node) { + if (node.and) { + for (let i = 0; i < node.and.length; i++) { + if (!evalNode(node.and[i])) + return false; + } + return true; + } + if (node.or) { + for (let i = 0; i < node.or.length; i++) { + if (evalNode(node.or[i])) + return true; + } + return false; + } + const keys = Object.keys(node); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + if (pred[k]) + return pred[k](node); + } + return false; + }; + + for (let i = 0; i < this.CLASSIFY_SPEC.rules.length; i++) { + if (evalNode(this.CLASSIFY_SPEC.rules[i])) + return true; + } + return false; + }, + + normalizeAction: function(raw) { + const a = (raw || '').toUpperCase(); + const words = this.CLASSIFY_SPEC.actionWords; + const pass = words.slice(0, 3); /* ACCEPT|ALLOW|PASS */ + const denyClass = words.slice(3); /* DROP|REJECT|DENY|BLOCK — positional */ + if (pass.indexOf(a) >= 0) + return 'pass'; + if (a === words[3]) /* DROP */ + return 'drop'; + if (a === words[4]) /* REJECT */ + return 'reject'; + if (denyClass.indexOf(a) >= 0) /* DENY|BLOCK */ + return 'block'; + return 'unknown'; + }, + + parseRuleHint: function(message) { + let msg = this.normalizeNetfilterMessage(message || '').trim(); + msg = msg.replace(/^\[\s*[\d.]+\]\s*/, ''); + + if (/^fw4:\s*/i.test(msg)) + return 'fw4'; + + const beforeKv = msg.match(/^([A-Za-z0-9_.-]+)(?::|\s+)(?=IN=|OUT=|SRC=|DST=|PROTO=)/); + if (beforeKv) + return beforeKv[1]; + + const colon = msg.match(/^([A-Za-z0-9_.-]+):/); + if (colon) { + const tag = colon[1].toLowerCase(); + if (tag !== 'kernel' && tag !== 'iptables') + return colon[1]; + } + + return ''; + }, + + formatRuleLabel: function(hint) { + if (!hint) + return ''; + + if (hint === 'fw4') + return 'Firewall4'; + + return hint.replace(/-/g, ' '); + }, + + inferActionRaw: function(message, kv, actionRaw) { + if (actionRaw && actionRaw !== 'UNKNOWN') + return actionRaw; + + const msg = this.normalizeNetfilterMessage(message || ''); + const withoutKv = msg.replace(/\b[A-Z]+=[^\s]*/g, ' '); + if (this.DENY_ACTION.test(withoutKv)) + return 'UNKNOWN'; + + if (/^kernel:/i.test(msg.trim())) + return 'UNKNOWN'; + + const hasTuple = !!(kv.IN || kv.OUT) && !!(kv.SRC || kv.DST || kv.PROTO); + if (hasTuple) + return 'PASS'; + + return 'UNKNOWN'; + }, + + parseFlags: function(message, kv) { + if (kv.TCPFLAGS) + return kv.TCPFLAGS; + if (kv.FLAGS) + return kv.FLAGS; + + const m = message.match(this.TCP_FLAG_TAIL); + if (!m) + return ''; + + return m[0].trim().toUpperCase().replace(/\s+/g, ','); + }, + + parseLength: function(kv) { + const len = kv.LEN || kv.LENGTH || ''; + if (!len) + return null; + + const n = parseInt(len, 10); + return isFinite(n) ? n : null; + }, + + timestampUnix: function(entry) { + if (!entry || entry.time == null || entry.time === '') + return null; + + if (typeof entry.time === 'string' && /^\d{4}-\d{2}-\d{2}[T ]/.test(entry.time)) { + const ms = new Date(entry.time).getTime(); + if (isFinite(ms)) + return Math.floor(ms / 1000); + } + + const n = Number(entry.time); + if (!isFinite(n)) + return null; + + return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n); + }, + + formatTimestampDisplay: function(entry) { + const unix = this.timestampUnix(entry); + if (unix == null) + return ''; + + return new Date(unix * 1000).toISOString(); + }, + + /* @fwlive-codegen:luci-preserve-begin */ + formatTimestampLocal: function(unix) { + if (unix == null || !isFinite(unix)) + return ''; + + const d = new Date(unix * 1000); + const pad = function(n) { return (n < 10 ? '0' : '') + n; }; + + return '%d-%s-%s %s:%s:%s'.format( + d.getFullYear(), + pad(d.getMonth() + 1), + pad(d.getDate()), + pad(d.getHours()), + pad(d.getMinutes()), + pad(d.getSeconds()) + ); + }, + + formatTimestampCompact: function(unix) { + if (unix == null || !isFinite(unix)) + return ''; + + const d = new Date(unix * 1000); + const pad = function(n) { return (n < 10 ? '0' : '') + n; }; + + return '%s:%s:%s'.format(pad(d.getHours()), pad(d.getMinutes()), pad(d.getSeconds())); + }, + + formatFlowDisplay: function(row) { + const src = row && row.src ? String(row.src) : ''; + const dst = row && row.dst ? String(row.dst) : ''; + const sport = row && row.sport ? String(row.sport) : ''; + const dport = row && row.dport ? String(row.dport) : ''; + let left = src; + let right = dst; + + if (sport) + left = left ? (left + ':' + sport) : (':' + sport); + if (dport) + right = right ? (right + ':' + dport) : (':' + dport); + + if (!left && !right) + return '—'; + if (!right) + return left; + if (!left) + return '→ ' + right; + + return left + ' → ' + right; + }, + + formatCell: function(value) { + if (value == null || value === '') + return ''; + + return String(value); + }, + + formatActionLabel: function(action) { + const a = (action || '').toLowerCase(); + if (!a || a === 'unknown') + return '—'; + return a; + }, + + formatMessageDisplay: function(message, layout) { + let m = this.normalizeNetfilterMessage(message || ''); + m = m.replace(/^\[\s*[\d.]+\]\s*/, ''); + m = m.replace(/\bMAC=[^\s]+/g, ''); + m = m.replace(/\s+/g, ' ').trim(); + + if (layout === 'oneline') + return m; + + if (m.length > 240) + return m.substring(0, 237) + '…'; + + return m; + }, + /* @fwlive-codegen:luci-preserve-end */ + + isFirewallEvent: function(entry) { + const msg = this.normalizeNetfilterMessage((entry && entry.msg) || ''); + if (!msg.trim()) + return false; + + if (this.NON_FIREWALL_PREFIX.test(msg)) + return false; + + return this.evaluateClassifySpec(msg); + }, + + makeEntryId: function(entry, tsUnix, action, src, dst, sport, dport, proto, ifaceIn, ifaceOut) { + if (entry && entry.id != null && entry.id !== '') + return 'log:' + entry.id; + + return [tsUnix, action, src, dst, sport, dport, proto, ifaceIn, ifaceOut, entry.msg || ''].join('|'); + }, + + normalizeEntry: function(entry) { + const kv = this.parseKeyValueLog(entry.msg || ''); + const tsUnix = this.timestampUnix(entry); + const tsDisplay = this.formatTimestampDisplay(entry); + const proto = (kv.PROTO || '').toUpperCase(); + const actionRaw = this.inferActionRaw(entry.msg || '', kv, this.detectAction(entry.msg || '')); + const action = this.normalizeAction(actionRaw); + const src = kv.SRC || ''; + const dst = kv.DST || ''; + const sport = kv.SPT || ''; + const dport = kv.DPT || ''; + const ifaceIn = kv.IN || ''; + const ifaceOut = kv.OUT || ''; + const iface = ifaceIn || ifaceOut || ''; + const dir = ifaceIn && ifaceOut ? 'forward' : (ifaceIn ? 'in' : (ifaceOut ? 'out' : 'unknown')); + const flags = this.parseFlags(entry.msg || '', kv); + const length = this.parseLength(kv); + const ruleHint = this.parseRuleHint(entry.msg || ''); + const ruleLabel = this.formatRuleLabel(ruleHint); + + return { + id: this.makeEntryId(entry, tsUnix, action, src, dst, sport, dport, proto, ifaceIn, ifaceOut), + log_id: entry && entry.id != null ? Number(entry.id) : null, + timestamp: tsUnix, + timestamp_display: tsDisplay, + rule_hint: ruleHint, + rule_label: ruleLabel, + action: action, + action_raw: actionRaw, + interface: iface, + interface_in: ifaceIn, + interface_out: ifaceOut, + direction: dir, + proto: proto, + src: src, + sport: sport, + dst: dst, + dport: dport, + flags: flags, + length: length, + message: entry.msg || '' + }; + }, + + parseFilterValue: function(val) { + const s = (val || '').trim(); + if (!s) + return { negate: false, value: '' }; + + if (s.charAt(0) === '!') + return { negate: true, value: s.slice(1).trim() }; + + return { negate: false, value: s }; + }, + + toggleFilterNegation: function(val) { + const p = this.parseFilterValue(val); + if (!p.value) + return val; + + return p.negate ? p.value : '!' + p.value; + }, + + formatFilterChipLabel: function(field, val) { + const p = this.parseFilterValue(val); + if (!p.value) + return ''; + + if (p.negate) { + if (field === 'q' || field === 'src' || field === 'dst') + return '%s: not contains %s'.format(field, p.value); + + return '%s: not %s'.format(field, p.value); + } + + return '%s: %s'.format(field, val); + }, + + matchesTextField: function(haystack, spec) { + const p = this.parseFilterValue(spec); + if (!p.value) + return true; + + const hit = (haystack || '').indexOf(p.value) !== -1; + return p.negate ? !hit : hit; + }, + + matchesExactField: function(haystack, spec) { + const p = this.parseFilterValue(spec); + if (!p.value) + return true; + + const want = p.value.toUpperCase(); + const got = (haystack || '').toUpperCase(); + const hit = got === want; + return p.negate ? !hit : hit; + }, + + matchesFilter: function(row, filters) { + if (filters.q) { + const p = this.parseFilterValue(filters.q); + if (p.value) { + const keys = Object.keys(row); + const parts = []; + for (let i = 0; i < keys.length; i++) + parts.push(row[keys[i]]); + const blob = parts.join(' ').toLowerCase(); + const hit = blob.indexOf(p.value.toLowerCase()) !== -1; + if (p.negate ? hit : !hit) + return false; + } + } + + if (filters.action) { + const p = this.parseFilterValue(filters.action); + if (p.value) { + const want = p.value.toLowerCase(); + const hit = row.action === want + || (row.action_raw || '').toUpperCase() === p.value.toUpperCase(); + if (p.negate ? hit : !hit) + return false; + } + } + + if (filters.interface) { + const p = this.parseFilterValue(filters.interface); + if (p.value) { + const iface = p.value; + const hit = row.interface === iface + || row.interface_in === iface + || row.interface_out === iface; + if (p.negate ? hit : !hit) + return false; + } + } + + if (filters.proto && !this.matchesExactField(row.proto, filters.proto)) + return false; + if (filters.src && !this.matchesTextField(row.src, filters.src)) + return false; + if (filters.dst && !this.matchesTextField(row.dst, filters.dst)) + return false; + if (filters.sport && !this.matchesExactField(row.sport, filters.sport)) + return false; + if (filters.dport && !this.matchesExactField(row.dport, filters.dport)) + return false; + return true; + }, + + actionRowClass: function(action) { + const a = (action || '').toLowerCase(); + if (a === 'drop' || a === 'reject' || a === 'block') + return 'fwlive-action fwlive-deny'; + if (a === 'pass') + return 'fwlive-action fwlive-pass'; + return 'fwlive-action fwlive-unknown'; + } +}); diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js new file mode 100644 index 00000000..577ddeec --- /dev/null +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js @@ -0,0 +1,270 @@ +'use strict'; +'require baseclass'; +'require fwlive.links as links'; + +/** + * Logging toolbar and empty-state DOM renderers for luci-app-fwlive. + * + * renderToolbar(host, state, callbacks) → void + * host - #fwlive-logging-bar strip slot (cleared and rebuilt; element kept) + * state - { loggingStatus, loggingBusy, entriesLength, loggingNotice } + * callbacks - { onEnable(), onDisable() } + * + * G Hybrid chrome: when WAN logging is on, one merged control carries status + + * rate (click disables). When off, filled Enable CTA. Blockers stay status text. + * + * renderManualTestNodes(host, state, callbacks) → void + * host -