From 0deac8fcbbc747ffc7d614cda457200f79940519 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 16 Sep 2026 03:46:54 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9B=85=20Sync=202026-09-16=2003:46:54?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- luci-app-fwlive/Makefile | 2 +- .../luci-static/resources/fwlive/css.js | 2 +- .../luci-static/resources/fwlive/fwlive.css | 19 ++ .../luci-static/resources/fwlive/table.js | 172 ++++++++++--- .../resources/view/status/fwlive.js | 230 ++++++++++++++++-- luci-app-fwlive/po/de/luci-app-fwlive.po | 30 +++ luci-app-fwlive/po/ru/luci-app-fwlive.po | 30 +++ .../po/templates/luci-app-fwlive.pot | 41 ++++ luci-app-fwlive/po/zh_Hans/luci-app-fwlive.po | 30 +++ .../root/usr/libexec/fwlive-adaptive-cap.sh | 111 ++++++--- .../usr/libexec/fwlive-is-firewall-event.awk | 101 +++++++- .../usr/libexec/fwlive-is-firewall-event.sh | 12 +- .../root/usr/libexec/fwlive-log-filter.sh | 41 +++- .../root/usr/libexec/fwlive-logging.sh | 54 +++- luci-app-fwlive/root/usr/libexec/rpcd/fwlive | 82 ++++--- luci-app-passwall2/Makefile | 2 +- luci-app-passwall2/po/fa/passwall2.po | 53 ++-- luci-app-passwall2/po/ru/passwall2.po | 52 ++-- luci-app-passwall2/po/zh-cn/passwall2.po | 53 ++-- luci-app-passwall2/po/zh-tw/passwall2.po | 53 ++-- .../root/usr/share/passwall2/nftables.sh | 42 ++-- .../usr/share/passwall2/socks_auto_switch.sh | 150 ++++++------ .../root/usr/share/passwall2/test.sh | 4 +- luci-theme-footstrap/Makefile | 2 +- .../htdocs/luci-static/resources/fs-router.js | 65 +++-- .../styles/theme/30-tables.css | 35 ++- .../styles/theme/60-inputs.css | 155 +++++++----- .../styles/theme/95-a11y-media.css | 17 +- rtl8189es/Makefile | 4 +- 29 files changed, 1215 insertions(+), 429 deletions(-) diff --git a/luci-app-fwlive/Makefile b/luci-app-fwlive/Makefile index cabe9ce80..465fe94c0 100644 --- a/luci-app-fwlive/Makefile +++ b/luci-app-fwlive/Makefile @@ -12,7 +12,7 @@ LUCI_DEPENDS:=+luci-base +logd +jsonfilter LUCI_PKGARCH:=all PKG_VERSION:=0.1.41 -PKG_RELEASE:=16 +PKG_RELEASE:=17 # Reproducible build: honor SOURCE_DATE_EPOCH from the build environment (set by docker-sdk.sh / CI). ifdef SOURCE_DATE_EPOCH diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/css.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/css.js index 7bfcaffee..650e52b8e 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/css.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/css.js @@ -11,5 +11,5 @@ * 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-adaptive {\n\tdisplay: none;\n\tmargin: 0 0 10px;\n\tpadding: 8px 12px;\n\tbackground: var(--background-color-low);\n\tborder: 1px solid var(--border-color-medium);\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-backend.fwlive-backend-warn {\n\tcolor: var(--warn-color-high);\n\tfont-weight: 600;\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" + 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-adaptive {\n\tdisplay: none;\n\tmargin: 0 0 10px;\n\tpadding: 8px 12px;\n\tbackground: var(--background-color-low);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tcolor: var(--text-color-high);\n\tfont-size: 0.92em;\n}\n.fwlive-summary {\n\tmargin: 0 0 10px;\n\tpadding: 8px 12px;\n\tbackground: var(--background-color-low);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tcolor: var(--text-color-high);\n}\n.fwlive-summary-head {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n\tgap: 10px;\n}\n.fwlive-summary-body {\n\tmargin: 8px 0 0;\n\tfont: inherit;\n\twhite-space: pre-wrap;\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-backend.fwlive-backend-warn {\n\tcolor: var(--warn-color-high);\n\tfont-weight: 600;\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 index aa1274a39..59b19aab0 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/fwlive.css +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/fwlive.css @@ -670,6 +670,25 @@ color: var(--text-color-high); font-size: 0.92em; } +.fwlive-summary { + margin: 0 0 10px; + padding: 8px 12px; + background: var(--background-color-low); + border: 1px solid var(--border-color-medium); + border-radius: 3px; + color: var(--text-color-high); +} +.fwlive-summary-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.fwlive-summary-body { + margin: 8px 0 0; + font: inherit; + white-space: pre-wrap; +} .fwlive-tint-warn { display: none; color: var(--warn-color-high); diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js index 83e25a091..3a182d3a2 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js @@ -14,7 +14,7 @@ * callbacks - {} (unused; present for API consistency) * * renderRows(host, state, callbacks) → void - * host - element (cleared and rebuilt; element itself is kept) + * host - element (kept; normal polls reuse keyed rows) * state - shallow copy: { rows, columns, viewMode, messageLayout, * expandedRowId, rowTint, showHostnames, * hostnameCache, firewallBackend } @@ -24,8 +24,9 @@ * Internals (not a second public contract): columnLabel, columnCellClass, * flowCell, buildColumnCell. * - * Modules must not mutate state. host contents are cleared then rebuilt - * (idempotent replace). Does not touch #fwlive-scroll or #fwlive-empty. + * Modules must not mutate state. Forced renders clear and rebuild the host; + * normal polls reuse unchanged keyed rows and only build changed rows. Does + * not touch #fwlive-scroll or #fwlive-empty. */ function columnLabel(col) { @@ -255,49 +256,146 @@ function renderThead(host, state, _callbacks) { } } +function rowRenderKey(row, state, columns) { + return JSON.stringify([ + row.id, + row.timestamp, + row.action, + row.rule_hint, + row.rule_label, + row.interface_in, + row.interface_out, + row.direction, + row.proto, + row.src, + row.sport, + row.dst, + row.dport, + row.flags, + row.length, + row.message, + columns, + state.viewMode, + state.messageLayout, + state.expandedRowId === row.id, + !!state.rowTint, + !!state.showHostnames, + state.firewallBackend + ]); +} + +function rowClass(index, row, state, callbacks) { + return [ + index % 2 ? 'fwlive-row-alt' : '', + state.viewMode === 'simple' ? 'fwlive-row-clickable' : '', + state.expandedRowId === row.id ? 'fwlive-row-expanded' : '', + state.rowTint ? callbacks.actionRowTintClass(row.action) : '' + ] + .filter(Boolean) + .join(' '); +} + +function buildRow(row, index, state, columns, callbacks) { + const cells = []; + for (let c = 0; c < columns.length; c++) + cells.push(buildColumnCell(columns[c], row, state, callbacks)); + + const tr = E( + 'tr', + { + 'class': rowClass(index, row, state, callbacks), + 'click': state.viewMode === 'simple' ? (ev) => callbacks.onRowClick(row.id, ev) : null + }, + cells + ); + tr._fwliveRowId = String(row.id); + tr._fwliveRowKey = rowRenderKey(row, state, columns); + return tr; +} + +function buildExpansionRow(row, state, columns) { + const expansion = E('tr', { 'class': 'fwlive-msg-expand' }, [ + E('td', { 'colspan': String(columns.length) }, [ + E('div', { 'class': 'fwlive-msg-expand-label' }, [_('Message')]), + E('pre', { 'class': 'fwlive-msg-expand-body' }, [ + log.formatMessageDisplay(row.message, 'wrap') || '—' + ]) + ]) + ]); + expansion._fwliveExpansionFor = String(row.id); + expansion._fwliveExpansionKey = rowRenderKey(row, state, columns); + return expansion; +} + +function renderAllRows(host, rows, state, columns, callbacks) { + host.innerHTML = ''; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + host.appendChild(buildRow(row, i, state, columns, callbacks)); + if (state.viewMode === 'simple' && state.expandedRowId === row.id) + host.appendChild(buildExpansionRow(row, state, columns)); + } +} + +function canReuseRows(host) { + return ( + host && + host.childNodes && + typeof host.childNodes.length === 'number' && + typeof host.insertBefore === 'function' && + typeof host.removeChild === 'function' + ); +} + function renderRows(host, state, callbacks) { const rows = state.rows || []; const columns = state.columns || []; - host.innerHTML = ''; + if (state.forceRender || !canReuseRows(host)) { + renderAllRows(host, rows, state, columns, callbacks); + return; + } + const existingRows = new Map(); + const existingExpansions = new Map(); + const oldChildren = Array.prototype.slice.call(host.childNodes); + for (let i = 0; i < oldChildren.length; i++) { + const child = oldChildren[i]; + if (child._fwliveRowId != null) existingRows.set(child._fwliveRowId, child); + else if (child._fwliveExpansionFor != null) + existingExpansions.set(child._fwliveExpansionFor, child); + } + + const desired = []; for (let i = 0; i < rows.length; i++) { - const r = rows[i]; - const rowClass = [ - i % 2 ? 'fwlive-row-alt' : '', - state.viewMode === 'simple' ? 'fwlive-row-clickable' : '', - state.expandedRowId === r.id ? 'fwlive-row-expanded' : '', - state.rowTint ? callbacks.actionRowTintClass(r.action) : '' - ] - .filter(Boolean) - .join(' '); - const cells = []; - for (let c = 0; c < columns.length; c++) - cells.push(buildColumnCell(columns[c], r, state, callbacks)); + const row = rows[i]; + const id = String(row.id); + const key = rowRenderKey(row, state, columns); + let tr = existingRows.get(id); + if (!tr || tr._fwliveRowKey !== key) tr = buildRow(row, i, state, columns, callbacks); + else tr.setAttribute('class', rowClass(i, row, state, callbacks)); + tr._fwliveRowId = id; + tr._fwliveRowKey = key; + desired.push(tr); - const tr = E( - 'tr', - { - 'class': rowClass, - 'click': state.viewMode === 'simple' ? (ev) => callbacks.onRowClick(r.id, ev) : null - }, - cells - ); - host.appendChild(tr); - - if (state.viewMode === 'simple' && state.expandedRowId === r.id) { - host.appendChild( - E('tr', { 'class': 'fwlive-msg-expand' }, [ - E('td', { 'colspan': String(columns.length) }, [ - E('div', { 'class': 'fwlive-msg-expand-label' }, [_('Message')]), - E('pre', { 'class': 'fwlive-msg-expand-body' }, [ - log.formatMessageDisplay(r.message, 'wrap') || '—' - ]) - ]) - ]) - ); + if (state.viewMode === 'simple' && state.expandedRowId === row.id) { + let expansion = existingExpansions.get(id); + if (!expansion || expansion._fwliveExpansionKey !== key) + expansion = buildExpansionRow(row, state, columns); + desired.push(expansion); } } + + for (let i = 0; i < desired.length; i++) { + const current = host.childNodes[i] || null; + if (current !== desired[i]) host.insertBefore(desired[i], current); + } + + const used = new Set(desired); + const currentChildren = Array.prototype.slice.call(host.childNodes); + for (let i = 0; i < currentChildren.length; i++) { + if (!used.has(currentChildren[i])) host.removeChild(currentChildren[i]); + } } return baseclass.extend({ diff --git a/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js b/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js index 05e204241..286c648f2 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js @@ -102,6 +102,12 @@ return view.extend({ resumeMerge: false, pollFn: null, pollDataInFlight: false, + /* Every refresh trigger goes through this coordinator. A request made + * while one is active is coalesced into one follow-up, never overlapped. */ + pollRequestPromise: null, + pollRequestQueued: false, + pollRequestWaiters: [], + pollRequestQueuedWaiters: [], /* Layer 2 — visibility / RTT cadence / shed surfacing. */ pollEpoch: 0, pollCadenceSec: 1, @@ -111,6 +117,10 @@ return view.extend({ serverTruncated: 0, serverShed: null, degradedSampling: false, + /* Layer 2 summary fallback — rows remain available behind an explicit toggle. */ + summaryMode: false, + summaryRowsShown: false, + summaryData: null, resolveLoadShed: false, visibilityBound: false, renderRaf: 0, @@ -769,6 +779,14 @@ return view.extend({ this.notePollRtt(rtt, errored); this.updateAdaptiveBanner(); + if (this.clientBackoffEnabled() && rtt > constants.POLL_RTT_SLOW_MS) { + if (!this.summaryMode) this.enterSummaryMode(reply.summary); + else { + this.summaryData = + reply.summary && typeof reply.summary === 'object' ? reply.summary : null; + this.renderSummary(); + } + } const batch = this.normalizePollBatch(raw); this.lastPollNewEvents = batch.pollNew; @@ -884,6 +902,7 @@ return view.extend({ notePollRtt(ms, errored) { if (!this.clientBackoffEnabled()) { + if (this.summaryMode) this.leaveSummaryMode(); this.degradedSampling = false; /* Drop any partial streak so a slow sample from before the * adaptive:0 window cannot trip degraded on re-enable. */ @@ -905,6 +924,7 @@ return view.extend({ const cadence = this.cadenceForKind(kind); this.degradedSampling = cadence === constants.POLL_CADENCE_SLOW_S; if (cadence !== this.pollCadenceSec) this.setPollCadence(cadence); + if (kind === 'fast' && this.summaryMode) this.leaveSummaryMode(); }, stopPollingForHidden() { @@ -919,11 +939,12 @@ return view.extend({ 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(); + /* An old hidden-epoch request may still be on the wire. Queue the + * catch-up behind it; stale-reply checks discard its application, while + * the coordinator prevents a second server request from overlapping. */ + this.requestPoll(); }, onVisibilityChange() { @@ -992,6 +1013,93 @@ return view.extend({ } }, + summaryListText(label, values) { + const lines = [label + ':']; + if (!Array.isArray(values) || !values.length) { + lines.push(_('none')); + return lines; + } + for (let i = 0; i < values.length; i++) { + const item = values[i]; + if (!item || typeof item.value !== 'string') continue; + const count = Number.isFinite(Number(item.count)) ? Number(item.count) : 0; + lines.push('%s — %d'.format(item.value, count)); + } + if (lines.length === 1) lines.push(_('none')); + return lines; + }, + + renderSummary() { + const card = document.getElementById('fwlive-summary'); + const body = document.getElementById('fwlive-summary-body'); + if (!card || !body) return; + const summary = this.summaryData; + const lines = [_('Showing a compact summary of the top of the shown sample.')]; + if (!summary || typeof summary !== 'object') { + lines.push(_('Summary data is unavailable; show rows to inspect the sample.')); + } else if (summary.truncated) { + lines.push(_('Summary was bounded before all categories could be included.')); + } else { + this.summaryListText(_('Top talkers'), summary.top_talkers).forEach((line) => + lines.push(line) + ); + this.summaryListText(_('Top drops'), summary.top_drops).forEach((line) => + lines.push(line) + ); + this.summaryListText(_('Top rules'), summary.top_rules).forEach((line) => + lines.push(line) + ); + } + body.textContent = lines.join('\n'); + this.updateSummaryUi(); + }, + + updateSummaryUi() { + const card = document.getElementById('fwlive-summary'); + const scroll = document.getElementById('fwlive-scroll'); + const empty = document.getElementById('fwlive-empty'); + const toggle = document.getElementById('fwlive-summary-rows'); + if (card) { + if (!card.style) card.style = { display: '' }; + card.style.display = this.summaryMode ? 'block' : 'none'; + } + if (scroll) { + if (!scroll.style) scroll.style = { display: '' }; + scroll.style.display = this.summaryMode && !this.summaryRowsShown ? 'none' : ''; + } + if (empty) { + if (!empty.style) empty.style = { display: '' }; + empty.style.display = this.summaryMode && !this.summaryRowsShown ? 'none' : ''; + } + if (toggle) { + toggle.textContent = this.summaryRowsShown ? _('Hide rows') : _('Show rows'); + toggle.setAttribute('aria-pressed', this.summaryRowsShown ? 'true' : 'false'); + } + }, + + enterSummaryMode(summary) { + this.summaryMode = true; + this.summaryRowsShown = false; + this.summaryData = summary && typeof summary === 'object' ? summary : null; + this.renderSummary(); + }, + + leaveSummaryMode() { + if (!this.summaryMode) return; + this.summaryMode = false; + this.summaryRowsShown = false; + this.summaryData = null; + this.updateSummaryUi(); + this.renderRows(true); + }, + + onSummaryRowsToggle() { + if (!this.summaryMode) return; + this.summaryRowsShown = !this.summaryRowsShown; + this.updateSummaryUi(); + if (this.summaryRowsShown) this.renderRows(true); + }, + scheduleRenderRows(force) { const doForce = !!force; const epoch = this.pollEpoch; @@ -1193,7 +1301,7 @@ return view.extend({ if (!wasPaused && this.paused) { this.pauseBufferLoading = true; this.updateStatus(); - this.fetchEntries() + this.requestPoll() .catch(function () {}) .finally( function () { @@ -1209,7 +1317,7 @@ return view.extend({ /* Merge pause buffer with the first live poll — do not replace. */ this.resumeMerge = true; const epoch = this.pollEpoch; - this.fetchEntries() + this.requestPoll() .then(() => { /* A hide/show bump abandons this epoch; the catch-up poll paints. */ if (epoch === this.pollEpoch) this.renderRows(true); @@ -1232,7 +1340,7 @@ return view.extend({ if (!this.paused) this.renderRows(true); else this.updateStatus(); const epoch = this.pollEpoch; - this.fetchEntries() + this.requestPoll() .then(() => { /* A hide/show bump abandons this epoch; the catch-up poll paints. */ if (epoch !== this.pollEpoch) return; @@ -1533,6 +1641,7 @@ return view.extend({ { rows: rows.slice(), columns: this.activeColumns().slice(), + forceRender: !!force, viewMode: this.viewMode, messageLayout: this.messageLayout, expandedRowId: this.expandedRowId, @@ -1641,14 +1750,82 @@ return view.extend({ const tintSel = document.getElementById('fwlive-row-tint'); if (tintSel) tintSel.addEventListener('change', this.onRowTintPaletteChange.bind(this)); + + const summaryRows = document.getElementById('fwlive-summary-rows'); + if (summaryRows) summaryRows.addEventListener('click', this.onSummaryRowsToggle.bind(this)); }, async pollData() { - if (this.isTabHidden()) return; - if (this.pollDataInFlight) return; + return this.requestPoll(); + }, + requestPoll() { + if (this.isTabHidden()) return Promise.resolve(); + + const waiter = {}; + const promise = new Promise(function (resolve) { + waiter.resolve = resolve; + }); + + if (this.pollRequestPromise) { + /* Keep one pending refresh intent. The latest view state is read when + * that follow-up begins, so Pause/Resume/Limit changes coalesce safely. */ + this.pollRequestQueued = true; + this.pollRequestQueuedWaiters.push(waiter); + return promise; + } + + if (this.pollRequestQueued) { + this.pollRequestQueuedWaiters.push(waiter); + this.startPollRequest(this.pollRequestQueuedWaiters); + this.pollRequestQueuedWaiters = []; + this.pollRequestQueued = false; + return promise; + } + + this.startPollRequest([waiter]); + return promise; + }, + + startPollRequest(waiters) { const epoch = this.pollEpoch; this.pollDataInFlight = true; + const run = this.runPollRequest(epoch); + this.pollRequestPromise = run; + this.pollRequestWaiters = waiters; + run.then( + function (value) { + this.finishPollRequest(run, value); + }.bind(this), + function () { + /* runPollRequest normally absorbs local failures so the poll loop + * remains alive; settle waiters even if a future change rejects. */ + this.finishPollRequest(run); + }.bind(this) + ); + }, + + finishPollRequest(run, value) { + if (this.pollRequestPromise !== run) return; + const waiters = this.pollRequestWaiters; + this.pollRequestWaiters = []; + this.pollRequestPromise = null; + this.pollDataInFlight = false; + for (let i = 0; i < waiters.length; i++) { + waiters[i].resolve(value); + } + + /* If visibility changed while the request was active, retain the + * queued intent until the visible catch-up can start it. */ + if (this.pollRequestQueued && !this.isTabHidden()) { + const queued = this.pollRequestQueuedWaiters; + this.pollRequestQueuedWaiters = []; + this.pollRequestQueued = false; + this.startPollRequest(queued); + } + }, + + async runPollRequest(epoch) { try { try { await this.fetchEntries(); @@ -1661,7 +1838,11 @@ return view.extend({ if (epoch !== this.pollEpoch) return; + /* Pause freezes row rendering but polling remains active for health and + * cadence state; summary mode can therefore appear while rows are paused + * and stays behind the explicit Show rows control. */ if (this.paused) this.updateStatus(); + else if (this.summaryMode) this.renderSummary(); else this.scheduleRenderRows(!!this.pendingForceRender); try { @@ -1669,11 +1850,10 @@ return view.extend({ } 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. Resume clears - * explicitly before starting the catch-up poll. */ - if (epoch === this.pollEpoch) this.pollDataInFlight = false; + } catch (e) { + /* Keep the coordinator promise settling so a queued refresh cannot + * be stranded by an unexpected local rendering failure. */ + this.lastPollError = true; } }, @@ -1706,7 +1886,7 @@ return view.extend({ } } return Promise.all([this.loadRulesMap(), this.loadLoggingStatus()]).then(() => - this.fetchEntries() + this.requestPoll() ); }, @@ -1842,6 +2022,28 @@ return view.extend({ ]), E('div', { 'id': 'fwlive-flood', 'class': 'fwlive-flood' }, ['']), E('div', { 'id': 'fwlive-adaptive', 'class': 'fwlive-adaptive' }, ['']), + E( + 'div', + { 'id': 'fwlive-summary', 'class': 'fwlive-summary', 'style': 'display:none' }, + [ + E('div', { 'class': 'fwlive-summary-head' }, [ + E('strong', {}, [_('Summary mode')]), + E( + 'button', + { + 'id': 'fwlive-summary-rows', + 'class': 'cbi-button fwlive-btn-ghost', + 'type': 'button', + 'aria-pressed': 'false' + }, + [_('Show rows')] + ) + ]), + E('pre', { 'id': 'fwlive-summary-body', 'class': 'fwlive-summary-body' }, [ + '' + ]) + ] + ), 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' }, [ diff --git a/luci-app-fwlive/po/de/luci-app-fwlive.po b/luci-app-fwlive/po/de/luci-app-fwlive.po index a95722ae0..1efcf9384 100644 --- a/luci-app-fwlive/po/de/luci-app-fwlive.po +++ b/luci-app-fwlive/po/de/luci-app-fwlive.po @@ -744,3 +744,33 @@ msgstr "mit iptables" #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:89 msgid "· %s" msgstr "· %s" + +msgid "Hide rows" +msgstr "Zeilen ausblenden" + +msgid "none" +msgstr "keine" + +msgid "Show rows" +msgstr "Zeilen anzeigen" + +msgid "Showing a compact summary of the top of the shown sample." +msgstr "Eine kompakte Zusammenfassung des Anfangs der angezeigten Stichprobe." + +msgid "Summary data is unavailable; show rows to inspect the sample." +msgstr "Zusammenfassungsdaten nicht verfügbar; Zeilen anzeigen, um die Stichprobe zu prüfen." + +msgid "Summary mode" +msgstr "Zusammenfassungsmodus" + +msgid "Summary was bounded before all categories could be included." +msgstr "Die Zusammenfassung wurde begrenzt, bevor alle Kategorien aufgenommen werden konnten." + +msgid "Top drops" +msgstr "Häufigste Drops" + +msgid "Top rules" +msgstr "Häufigste Regeln" + +msgid "Top talkers" +msgstr "Häufigste Absender" diff --git a/luci-app-fwlive/po/ru/luci-app-fwlive.po b/luci-app-fwlive/po/ru/luci-app-fwlive.po index 1b44869e0..d6b96eabe 100644 --- a/luci-app-fwlive/po/ru/luci-app-fwlive.po +++ b/luci-app-fwlive/po/ru/luci-app-fwlive.po @@ -749,3 +749,33 @@ msgstr "используется iptables" #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:89 msgid "· %s" msgstr "· %s" + +msgid "Hide rows" +msgstr "Скрыть строки" + +msgid "none" +msgstr "нет" + +msgid "Show rows" +msgstr "Показать строки" + +msgid "Showing a compact summary of the top of the shown sample." +msgstr "Показана краткая сводка верхней части отображаемой выборки." + +msgid "Summary data is unavailable; show rows to inspect the sample." +msgstr "Данные сводки недоступны; покажите строки для проверки выборки." + +msgid "Summary mode" +msgstr "Режим сводки" + +msgid "Summary was bounded before all categories could be included." +msgstr "Сводка ограничена до включения всех категорий." + +msgid "Top drops" +msgstr "Частые сбросы" + +msgid "Top rules" +msgstr "Частые правила" + +msgid "Top talkers" +msgstr "Частые источники" diff --git a/luci-app-fwlive/po/templates/luci-app-fwlive.pot b/luci-app-fwlive/po/templates/luci-app-fwlive.pot index 487a412de..87d6b3d76 100644 --- a/luci-app-fwlive/po/templates/luci-app-fwlive.pot +++ b/luci-app-fwlive/po/templates/luci-app-fwlive.pot @@ -678,3 +678,44 @@ msgstr "" #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:89 msgid "· %s" msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1063 +msgid "Hide rows" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1012 +msgid "none" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1039 +msgid "Top drops" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1042 +msgid "Top rules" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1036 +msgid "Top talkers" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1030 +msgid "Showing a compact summary of the top of the shown sample." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1032 +msgid "Summary data is unavailable; show rows to inspect the sample." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1950 +msgid "Summary mode" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1034 +msgid "Summary was bounded before all categories could be included." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1063 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1959 +msgid "Show rows" +msgstr "" diff --git a/luci-app-fwlive/po/zh_Hans/luci-app-fwlive.po b/luci-app-fwlive/po/zh_Hans/luci-app-fwlive.po index 3e013519f..5d9b23e13 100644 --- a/luci-app-fwlive/po/zh_Hans/luci-app-fwlive.po +++ b/luci-app-fwlive/po/zh_Hans/luci-app-fwlive.po @@ -713,3 +713,33 @@ msgstr "使用 iptables" #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:89 msgid "· %s" msgstr "· %s" + +msgid "Hide rows" +msgstr "隐藏行" + +msgid "none" +msgstr "无" + +msgid "Show rows" +msgstr "显示行" + +msgid "Showing a compact summary of the top of the shown sample." +msgstr "显示当前样本顶部的简要摘要。" + +msgid "Summary data is unavailable; show rows to inspect the sample." +msgstr "摘要数据不可用;显示行以检查样本。" + +msgid "Summary mode" +msgstr "摘要模式" + +msgid "Summary was bounded before all categories could be included." +msgstr "摘要已受限,无法包含所有类别。" + +msgid "Top drops" +msgstr "主要丢弃" + +msgid "Top rules" +msgstr "主要规则" + +msgid "Top talkers" +msgstr "主要通信方" diff --git a/luci-app-fwlive/root/usr/libexec/fwlive-adaptive-cap.sh b/luci-app-fwlive/root/usr/libexec/fwlive-adaptive-cap.sh index 5a2a48fa5..5451e21d9 100755 --- a/luci-app-fwlive/root/usr/libexec/fwlive-adaptive-cap.sh +++ b/luci-app-fwlive/root/usr/libexec/fwlive-adaptive-cap.sh @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright 2025-2026 Lucas Albers # -# Layer 1 adaptive poll cap. Sourced by rpcd/fwlive. +# Layer 1 adaptive poll cap (#306). Sourced by rpcd/fwlive. # Always on unless test/triage override (no UCI / no product config): # FWLIVE_ADAPTIVE=0|false|off|no # or sentinel ${FWLIVE_ADAPTIVE_OFF_FILE:-/fwlive-adaptive-off} @@ -18,10 +18,10 @@ # flock, lock busy, or corrupt state. Lock-busy ⇒ unlocked last-writer-wins is # acceptable (state stays one valid JSON line; ordering is not guaranteed). # Failed ubus log.read must NOT call record() — a ~0 ms failure is not "cold" -# health and must not clear an existing hot/shed cap. +# health and must not clear an existing hot/shed cap (#329 Hermes Q1). # Outside the measured duration interval: plan (pre), record/merge (post). -# messages_received is 0 in Layer 1 — do not ash-scan the filter JSON after -# end_cs; accurate count belongs in the filter or a later layer. +# messages_received is supplied by the filter-side jsonfilter enumeration; +# failed reads still use 0 without scanning the response in ash. FWLIVE_ADAPTIVE_STATE_FILE="${FWLIVE_ADAPTIVE_STATE_FILE:-/var/run/fwlive-state.json}" # Optional overrides; when unset, lock/off paths are siblings of the current state. @@ -37,6 +37,7 @@ FWLIVE_ADAPTIVE_HOT_FLOOR=250 FWLIVE_ADAPTIVE_WARM_MIN=50 FWLIVE_ADAPTIVE_COOLDOWN_WARM_CS=300 FWLIVE_ADAPTIVE_COOLDOWN_HOT_CS=600 +FWLIVE_ADAPTIVE_COOLDOWN_PROBE_CS=300 fwlive_adaptive_lock_path() { if [ -n "${FWLIVE_ADAPTIVE_LOCK_FILE:-}" ]; then @@ -273,7 +274,8 @@ fwlive_adaptive_bucket_for_ms() { fi } -# Args: requested_limit duration_ms prev_limit prev_bucket prev_warm prev_completed_cs +# Args: requested_limit duration_ms prev_limit prev_bucket prev_warm +# prev_completed_cs planning # Prints next limit. fwlive_adaptive_compute_limit() { _req=$1 @@ -282,6 +284,7 @@ fwlive_adaptive_compute_limit() { _prev_b=$4 _prev_w=$5 _prev_c=$6 + _planning=${7:-0} _max="${POLL_LINES_MAX:-2000}" _now=$(fwlive_adaptive_clock_cs) _bucket=$(fwlive_adaptive_bucket_for_ms "$_ms") @@ -292,9 +295,10 @@ fwlive_adaptive_compute_limit() { hot) _cd=$FWLIVE_ADAPTIVE_COOLDOWN_HOT_CS ;; warm) _cd=$FWLIVE_ADAPTIVE_COOLDOWN_WARM_CS ;; esac + _elapsed=$((_now - _prev_c)) if [ "$_cd" -gt 0 ] && [ "$_prev_c" -gt 0 ] && \ - [ "$((_now - _prev_c))" -lt "$_cd" ]; then + [ "$_elapsed" -lt "$_cd" ]; then if [ "$_ms" -le "$FWLIVE_ADAPTIVE_HOT_EXIT_MS" ] && \ [ "$_bucket" != hot ]; then : @@ -308,9 +312,50 @@ fwlive_adaptive_compute_limit() { fi fi + # Once a hot/warm cooldown expires, deliberately test a larger request. + # Planning and recording use the same pure helper: planning raises the next + # request, while recording raises the retained limit only when that probe + # completed below the hot-exit threshold. A still-hot probe therefore falls + # through to the hot floor below. + if [ "$_cd" -gt 0 ] && [ "$_prev_c" -gt 0 ] && \ + [ "$_elapsed" -ge "$_cd" ]; then + if [ "$_planning" = 1 ]; then + _limit=$_prev_l + [ "$_limit" -lt 1 ] && _limit=$_max + _limit=$((_limit * 2)) + [ "$_limit" -gt "$_max" ] && _limit=$_max + [ "$_limit" -gt "$_req" ] && _limit=$_req + [ "$_limit" -lt 1 ] && _limit=1 + printf '%s\n' "$_limit" + return 0 + fi + if [ "$_ms" -le "$FWLIVE_ADAPTIVE_HOT_EXIT_MS" ] && \ + [ "$_bucket" != hot ]; then + _limit=$_prev_l + [ "$_limit" -lt 1 ] && _limit=$_max + _limit=$((_limit * 2)) + [ "$_limit" -gt "$_max" ] && _limit=$_max + [ "$_limit" -gt "$_req" ] && _limit=$_req + [ "$_limit" -lt 1 ] && _limit=1 + printf '%s\n' "$_limit" + return 0 + fi + fi + case "$_bucket" in cold) _limit=$_max ;; - cool) _limit=$FWLIVE_ADAPTIVE_COOL_CAP ;; + cool) + # A healthy cooldown probe may retain its raised limit for the + # short probe window. Ordinary cool samples still use the fixed + # 250-line cap. + if [ "$_prev_l" -gt "$FWLIVE_ADAPTIVE_COOL_CAP" ] && \ + [ "$_prev_c" -gt 0 ] && \ + [ "$((_now - _prev_c))" -lt "$FWLIVE_ADAPTIVE_COOLDOWN_PROBE_CS" ]; then + _limit=$_prev_l + else + _limit=$FWLIVE_ADAPTIVE_COOL_CAP + fi + ;; warm) if [ "$_prev_w" = 1 ] && [ "$_prev_b" = warm ]; then _limit=$_prev_l @@ -350,7 +395,7 @@ fwlive_adaptive_plan() { _prev_w=$4 _s=$5 _prev_c=$6 - _l=$(fwlive_adaptive_compute_limit "$_req" "$_d" "$_prev_l" "$_b" "$_prev_w" "$_prev_c") + _l=$(fwlive_adaptive_compute_limit "$_req" "$_d" "$_prev_l" "$_b" "$_prev_w" "$_prev_c" 1) _nb=$(fwlive_adaptive_bucket_for_ms "$_d") _shed=0 [ "$_nb" = hot ] && _shed=1 @@ -395,7 +440,9 @@ fwlive_adaptive_is_hot() { return 1 } -# Merge adaptive siblings into a JSON object ending with }. +# Merge adaptive siblings into a JSON object ending with }. A successful +# filter already carries the exact messages_received count; do not append a +# duplicate key in that case. # Args: json_body shed_flag limit truncated messages_received fwlive_adaptive_merge_reply() { _body=$1 @@ -410,34 +457,32 @@ fwlive_adaptive_merge_reply() { *) printf '%s' "$_body"; return 0 ;; esac _base=${_body%\}} + _has_msgs=0 + case "$_body" in + *',"messages_received":'*) _has_msgs=1 ;; + esac if [ "$_adapt" = 0 ]; then - printf '%s,"adaptive":0,"messages_received":%s}' "$_base" "$_msgs" + if [ "$_has_msgs" = 1 ]; then + printf '%s,"adaptive":0}' "$_base" + else + printf '%s,"adaptive":0,"messages_received":%s}' "$_base" "$_msgs" + fi return 0 fi if [ "$_shed" = 1 ]; then - printf '%s,"adaptive":1,"messages_received":%s,"truncated":%s,"shed":{"level":"hot","limit":%s}}' \ - "$_base" "$_msgs" "$_trunc" "$_limit" + if [ "$_has_msgs" = 1 ]; then + printf '%s,"adaptive":1,"truncated":%s,"shed":{"level":"hot","limit":%s}}' \ + "$_base" "$_trunc" "$_limit" + else + printf '%s,"adaptive":1,"messages_received":%s,"truncated":%s,"shed":{"level":"hot","limit":%s}}' \ + "$_base" "$_msgs" "$_trunc" "$_limit" + fi else - printf '%s,"adaptive":1,"messages_received":%s,"truncated":%s}' \ - "$_base" "$_msgs" "$_trunc" + if [ "$_has_msgs" = 1 ]; then + printf '%s,"adaptive":1,"truncated":%s}' "$_base" "$_trunc" + else + printf '%s,"adaptive":1,"messages_received":%s,"truncated":%s}' \ + "$_base" "$_msgs" "$_trunc" + fi fi } - -# Count log[*] via "msg" keys — helper for tests / future filter-side count. -# NOT called on the poll hot path: ash-scanning a 2000-entry -# reply after end_cs is unmeasured overhead that cannot shed itself. -fwlive_adaptive_count_log() { - _json=$1 - _n=0 - _rest=$_json - while :; do - case "$_rest" in - *'"msg"'*) - _n=$((_n + 1)) - _rest=${_rest#*\"msg\"} - ;; - *) break ;; - esac - done - printf '%s\n' "$_n" -} diff --git a/luci-app-fwlive/root/usr/libexec/fwlive-is-firewall-event.awk b/luci-app-fwlive/root/usr/libexec/fwlive-is-firewall-event.awk index 367dcc56e..a7db204f8 100755 --- a/luci-app-fwlive/root/usr/libexec/fwlive-is-firewall-event.awk +++ b/luci-app-fwlive/root/usr/libexec/fwlive-is-firewall-event.awk @@ -106,11 +106,101 @@ function is_fw(s, action) { if (has_hint(s) && (has_kv(s, "IN") || has_kv(s, "OUT") || has_kv(s, "SRC") || has_kv(s, "DST") || has_kv(s, "PROTO"))) return 1 return 0 } -BEGIN { if (MODE != "json") ORS = "" } +function utf8_prefix(s, max_bytes, out, i, c, width) { + out = "" + i = 1 + while (i <= length(s)) { + c = substr(s, i, 1) + width = 1 + if (c ~ ("^[\302-\337]$")) width = 2 + else if (c ~ ("^[\340-\357]$")) width = 3 + else if (c ~ ("^[\360-\367]$")) width = 4 + if (i + width - 1 > max_bytes) break + out = out substr(s, i, width) + i += width + } + return out +} +function summary_kv(s, key, part) { + s = normalize(s) + if (!match(s, "(^|[[:space:]])" key "=[^[:space:]]+")) return "" + part = substr(s, RSTART, RLENGTH) + sub(/^[^=]*=/, "", part) + return part +} +function summary_rule(s, first) { + s = trim(normalize(s)) + sub(/^\[[[:space:]]*[0-9.]+\][[:space:]]*/, "", s) + if (tolower(s) ~ /^fw4:[[:space:]]*/) return "fw4" + first = s + sub(/[[:space:]:].*/, "", first) + if (first == "" || first ~ /^(IN|OUT|SRC|DST|PROTO|SPT|DPT|LEN|MAC|TYPE|CODE|TTL|TOS|PREC|DF)=/) return "" + if (tolower(first) == "kernel" || tolower(first) == "iptables") return "" + return utf8_prefix(first, 64) +} +function summary_add_count(counts, key) { + if (key != "") counts[key]++ +} +function json_escape(s, out, i, c) { + out = "" + for (i = 1; i <= length(s); i++) { + c = substr(s, i, 1) + if (c == "\\") out = out "\\\\" + else if (c == "\"") out = out "\\\"" + else if (c == "\n") out = out "\\n" + else if (c == "\r") out = out "\\r" + else if (c == "\t") out = out "\\t" + else if (c < " ") out = out " " + else out = out c + } + return out +} +function summary_top(counts, limit, out, i, k, best, best_n) { + for (k in summary_picked) delete summary_picked[k] + out = "[" + for (i = 0; i < limit; i++) { + best = ""; best_n = 0 + for (k in counts) { + if (!(k in summary_picked) && (counts[k] > best_n || (counts[k] == best_n && (best == "" || k < best)))) { + best = k; best_n = counts[k] + } + } + if (best == "") break + summary_picked[best] = 1 + if (i) out = out "," + out = out "{\"value\":\"" json_escape(best) "\",\"count\":" best_n "}" + } + return out "]" +} +function summary_add(s, action, src, dst, rule) { + summary_count++ + action = tolower(detect_action(normalize(s))) + if (action == "drop" || action == "reject" || action == "deny" || action == "block") summary_add_count(summary_drop_counts, action) + src = summary_kv(s, "SRC") + if (src == "") src = summary_kv(s, "DST") + if (src != "") summary_add_count(summary_talker_counts, utf8_prefix(src, 64)) + rule = summary_rule(s) + if (rule != "") summary_add_count(summary_rule_counts, rule) +} +function summary_json(out) { + out = "{\"scope\":\"top of shown sample\",\"top_talkers\":" summary_top(summary_talker_counts, 3) + out = out ",\"top_drops\":" summary_top(summary_drop_counts, 3) + out = out ",\"top_rules\":" summary_top(summary_rule_counts, 3) "}" + # LC_ALL=C makes this a byte budget; utf8_prefix keeps field cuts valid. + if (length(out) > 256) return "{\"scope\":\"top of shown sample\",\"truncated\":true}" + return out +} +BEGIN { + if (MODE == "json_reply") { + ORS = "" + printf "{\"log\":[" + } else if (MODE != "json") ORS = "" +} { - if (MODE == "json") { + if (MODE == "json" || MODE == "json_reply") { msg = json_get_msg($0) if (is_fw(msg)) { + if (MODE == "json_reply" && SUMMARY != "0") summary_add(msg) if (out_n++) printf "," printf "%s", $0 } @@ -119,6 +209,11 @@ BEGIN { if (MODE != "json") ORS = "" } buf = (NR == 1) ? $0 : buf "\n" $0 } END { - if (MODE != "json") + if (MODE == "json_reply") { + printf "],\"messages_received\":%d", NR + if (SUMMARY != "0" && summary_count > 0) printf ",\"summary\":%s", summary_json() + printf "}" + } + else if (MODE != "json") print is_fw(buf) ? 1 : 0 } diff --git a/luci-app-fwlive/root/usr/libexec/fwlive-is-firewall-event.sh b/luci-app-fwlive/root/usr/libexec/fwlive-is-firewall-event.sh index f19f672a6..54a2f1bf6 100755 --- a/luci-app-fwlive/root/usr/libexec/fwlive-is-firewall-event.sh +++ b/luci-app-fwlive/root/usr/libexec/fwlive-is-firewall-event.sh @@ -5,14 +5,18 @@ # source: core/fwlive-log.js CLASSIFY_SPEC # Shared isFirewallEvent parity logic (shell). Sourced by fwlive-log-filter.sh and tests. # Sourced library: do not add set -euo here (callers own strict mode, #291 C3). -# One awk process classifies a batch (MODE=json) or one message (default). +# One awk process classifies a batch (MODE=json/json_reply) or one message (default). # The caller sets FILTER_DIR when this file is sourced. Resolve the asset # once, not once per classification call. CLASSIFY_AWK="${FILTER_DIR:-${0%/*}}/fwlive-is-firewall-event.awk" _fwlive_run_classify() { - awk -v MODE="${1:-msg}" -f "$CLASSIFY_AWK" + if [ "${1:-msg}" = json_reply ]; then + LC_ALL=C awk -v MODE="${1:-msg}" -v SUMMARY="${FWLIVE_SUMMARY:-1}" -f "$CLASSIFY_AWK" + else + awk -v MODE="${1:-msg}" -v SUMMARY="${FWLIVE_SUMMARY:-1}" -f "$CLASSIFY_AWK" + fi } is_firewall_event_msg() { @@ -23,3 +27,7 @@ is_firewall_event_msg() { _fwlive_filter_json_entries() { _fwlive_run_classify json } + +_fwlive_filter_json_reply() { + _fwlive_run_classify json_reply +} diff --git a/luci-app-fwlive/root/usr/libexec/fwlive-log-filter.sh b/luci-app-fwlive/root/usr/libexec/fwlive-log-filter.sh index 35562bd60..d506103ee 100755 --- a/luci-app-fwlive/root/usr/libexec/fwlive-log-filter.sh +++ b/luci-app-fwlive/root/usr/libexec/fwlive-log-filter.sh @@ -6,14 +6,13 @@ # Log messages are treated as data (jsonfilter + awk stdin); never interpolated # into shell command strings. Usage: ubus call log read '...' | fwlive-log-filter.sh # -# Perf: one jsonfilter for @.log[*] plus one awk classify. Process -# count is constant per poll, not O(entries). +# Perf (#219): one jsonfilter for @.log[*] plus one awk classify. The same +# awk pass counts enumerated entries for messages_received; process count is +# constant per poll, not O(entries). # # Entry point (pipeline). The classifier sibling is sourced and must not set -# strict mode itself. +# strict mode itself (#291 C3). set -eu -# shellcheck disable=SC3040 -(set -o pipefail) 2>/dev/null && set -o pipefail if ! command -v jsonfilter >/dev/null 2>&1; then command -v logger >/dev/null 2>&1 && logger -t fwlive "jsonfilter not found; cannot filter firewall logs" @@ -26,17 +25,35 @@ FILTER_DIR="$(cd "$(dirname "$0")" && pwd)" . "$FILTER_DIR/fwlive-is-firewall-event.sh" # The classifier is a package asset, not generated at request time. Fail -# closed if an incomplete install or a damaged package removed it. +# closed if an incomplete install or a damaged package removed it (#321). if [ ! -r "$CLASSIFY_AWK" ]; then printf '%s' '{"log":[],"error":"classifier_missing"}' exit 1 fi -printf '%s' '{"log":[' # Prefer stdin over -s: Linux MAX_ARG_STRLEN is 128KiB; a raised logd ring # (or paused FETCH_LINES_MAX poll) can exceed that and make jsonfilter fail -# while this script still printed {"log":[]} and exited 0. -# jsonfilter miss / empty @.log is not fatal; must still close JSON. -# set -e + pipefail cannot apply to this pipeline. -jsonfilter -e '@.log[*]' 2>/dev/null | _fwlive_filter_json_entries || true -printf '%s' ']}' +# while this script still returns a valid empty result (#234). +# A valid input with no log entries is not fatal; the classifier closes JSON +# and reports messages_received:0 (#220). A jsonfilter failure is different: +# it must remain an error so rpcd does not record a fast healthy sample. Keep +# jsonfilter output in a secure temporary file before classification so a +# partial pipeline cannot produce malformed JSON on failure. +# This script runs from rpcd as root and reopens the path for classification. +# Match _fwlive_mktemp: only use the verified, sticky system /tmp; honoring +# TMPDIR would permit a non-sticky attacker-controlled directory here. +# shellcheck disable=SC3065 # OpenWrt BusyBox test supports -k; match rpcd helper. +if [ ! -d /tmp ] || [ -L /tmp ] || ! [ -k /tmp ]; then + printf '%s' '{"log":[],"error":"filter_tempfile_failed"}' + exit 1 +fi +_filter_tmp=$(mktemp /tmp/fwlive-filter.XXXXXX) || { + printf '%s' '{"log":[],"error":"filter_tempfile_failed"}' + exit 1 +} +trap 'rm -f "$_filter_tmp"' 0 1 2 3 15 +if ! jsonfilter -e '@.log[*]' >"$_filter_tmp" 2>/dev/null; then + printf '%s' '{"log":[],"error":"filter_failed"}' + exit 1 +fi +_fwlive_filter_json_reply <"$_filter_tmp" diff --git a/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh b/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh index 3cab14eb8..5dddb5cf3 100755 --- a/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh +++ b/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh @@ -27,6 +27,54 @@ NF_LOG_IPV6='/proc/sys/net/netfilter/nf_log/10' WAN_LOG_LOCK_FILE="${FWLIVE_WAN_LOG_LOCK_FILE:-/etc/fwlive/logging.lock}" WAN_LOG_BASELINE_FILE="${FWLIVE_WAN_LOG_BASELINE_FILE:-/etc/fwlive/wan-log-baseline}" +weak_device_detected() { + # Path overrides are test hooks; production defaults stay in procfs. + _meminfo=${FWLIVE_MEMINFO_PATH:-/proc/meminfo} + _cpuinfo=${FWLIVE_CPUINFO_PATH:-/proc/cpuinfo} + _mem_total= + if [ -r "$_meminfo" ]; then + while IFS= read -r _line; do + case "$_line" in + MemTotal:*) + _mem_total=${_line#MemTotal:} + while :; do + case "$_mem_total" in + [[:space:]]*) _mem_total=${_mem_total#?} ;; + *) break ;; + esac + done + _mem_total=${_mem_total%%[[:space:]]*} + break + ;; + esac + done <"$_meminfo" || : + fi + + _cores=0 + _cpu_valid=0 + if [ -r "$_cpuinfo" ]; then + while IFS= read -r _line; do + case "$_line" in + processor[[:space:]]*:*) + _cores=$((_cores + 1)) + _cpu_valid=1 + ;; + esac + done <"$_cpuinfo" || : + fi + + case "$_mem_total" in + ''|*[!0-9]*) printf 'false\n'; return 0 ;; + esac + [ "$_cpu_valid" = 1 ] || { printf 'false\n'; return 0; } + # MemTotal is reported in KiB; 256 MiB is 262144 KiB. + if [ "$_mem_total" -lt 262144 ] || [ "$_cores" -le 1 ]; then + printf 'true\n' + else + printf 'false\n' + fi +} + # RFC 8259 string escape. Lives here so prerm can source this file # standalone. rpcd sources us and must not redefine this. json_escape() { @@ -470,12 +518,14 @@ build_logging_status_json() { if [ -n "$zone" ] && [ "$wan_log" = true ] && [ "$nf4" = true ] && [ "$nf6" = true ]; then ready=true fi + weak_device=false + [ "$(weak_device_detected)" = true ] && weak_device=true zone_json=$(json_null_or_string "$zone") limit_json=$(json_null_or_string "$limit_val") - printf '{"wan_zone":%s,"wan_log":%s,"wan_log_limit":%s,"nf_log_ipv4":%s,"nf_log_ipv6":%s,"ready":%s,"blockers":%s,"warnings":%s}' \ - "$zone_json" "$wan_log" "$limit_json" "$nf4" "$nf6" "$ready" "$blockers" "$warnings" + printf '{"wan_zone":%s,"wan_log":%s,"wan_log_limit":%s,"nf_log_ipv4":%s,"nf_log_ipv6":%s,"ready":%s,"weak_device":%s,"blockers":%s,"warnings":%s}' \ + "$zone_json" "$wan_log" "$limit_json" "$nf4" "$nf6" "$ready" "$weak_device" "$blockers" "$warnings" } reload_firewall() { diff --git a/luci-app-fwlive/root/usr/libexec/rpcd/fwlive b/luci-app-fwlive/root/usr/libexec/rpcd/fwlive index 118023715..8b7d4049a 100755 --- a/luci-app-fwlive/root/usr/libexec/rpcd/fwlive +++ b/luci-app-fwlive/root/usr/libexec/rpcd/fwlive @@ -23,7 +23,7 @@ ADAPTIVE_SH="$LIBEXEC_DIR/fwlive-adaptive-cap.sh" RESOLVE_MAX=32 # Per-lookup nslookup timeout (integer seconds — BusyBox `timeout` truncates # fractional values to 0 on OpenWrt 23.05/1.36, so 0.5 would silently -# degrade to no timeout.. The wall-clock budget +# degrade to no timeout; luna fold 2026-08-10). The wall-clock budget # below is the real whole-call bound; this per-lookup cap only prevents a # single hung resolver from dominating the budget. LAN/local-resolver # lookups are single-digit ms, so 1s is ample. Passed as argv to @@ -42,7 +42,7 @@ NFT_TIMEOUT=5 # CLI-only iptables fixture path for __rulesmap_iptables (no arbitrary paths). RULESMAP_IPTABLES_FILE='/tmp/rulesmap' -# json_escape lives in fwlive-logging.sh so prerm can source that file alone. +# json_escape lives in fwlive-logging.sh so prerm can source that file alone (#222). # shellcheck disable=SC1090,SC1091 # LOGGING_SH resolves to sibling ../fwlive-logging.sh . "$LOGGING_SH" # shellcheck disable=SC1090,SC1091 @@ -52,10 +52,10 @@ slug_key() { echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' _' '--' } -# Cap rules JSON size so a huge ruleset cannot unbound an rpcd reply. +# Cap rules JSON size so a huge ruleset cannot unbound an rpcd reply (#229). RULES_MAP_MAX_KEYS=512 RULES_MAP_MAX_BYTES=65536 -# Same wall-clock bound as nft for iptables-save / ip6tables-save. +# Same wall-clock bound as nft for iptables-save / ip6tables-save (#229). # Overridable for host tests (e.g. FWLIVE_IPTABLES_TIMEOUT=1). IPTABLES_TIMEOUT="${FWLIVE_IPTABLES_TIMEOUT:-5}" @@ -70,7 +70,7 @@ map_add() { # ("foo" vs "foobar") and value false positives ("see foo"). # Quoting "$esc_key" makes the match literal (no glob escaping needed). # Callers apply UCI, then labeled (!fw4:) prefixes, then unlabeled - # cosmetics so first-wins prefers authoritative names. + # cosmetics so first-wins prefers authoritative names (#230). case "$OUT" in *"\"$esc_key\":"*) return 0 ;; esac @@ -92,7 +92,7 @@ map_add() { uci_rule_names() { # uci missing / empty firewall config is "no names", not fatal. - # pipefail + set -e cannot apply to this pipeline. + # pipefail + set -e cannot apply to this pipeline (#291 C3). uci -q show firewall 2>/dev/null | sed -n "s/^firewall\.@rule\[[0-9]*\]\.name='\(.*\)'$/\1/p" || true } @@ -142,7 +142,7 @@ run_with_timeout() { if command -v timeout >/dev/null 2>&1; then timeout "$secs" "$@" 2>/dev/null else - # Fail closed: never run unbounded when timeout is absent. + # Fail closed (#229): never run unbounded when timeout is absent. return 127 fi } @@ -170,7 +170,7 @@ map_uci_rule_names() { # Line-wise (here-doc, not an unquoted for-loop) so a name with # spaces stays one token. Names that fail is_uci_style_name are # skipped: parseRuleHint() cannot match them, and fragments of - # "My Rule" must not shadow a real rule named Rule. + # "My Rule" must not shadow a real rule named Rule (#226). # The while-read stays in the current shell (here-doc, not a # pipeline) so map_add mutations reach OUT. while IFS= read -r name || [ -n "$name" ]; do @@ -187,7 +187,7 @@ EOF } # pass=labeled|unlabeled|all — labeled before unlabeled so !fw4: beats -# an earlier cosmetic for the same prefix under first-wins. +# an earlier cosmetic for the same prefix under first-wins (#230). map_from_nft_stream() { pass="${1:-all}" while IFS= read -r line; do @@ -288,7 +288,7 @@ build_rules_map() { backend=$(detect_firewall_backend) # UCI first, then labeled stream prefixes, then unlabeled cosmetics - # First-wins keeps UCI over nft and !fw4: over cosmetic. + # (#230). First-wins keeps UCI over nft and !fw4: over cosmetic. map_uci_rule_names case "$backend" in @@ -435,32 +435,54 @@ fetch_firewall_logs() { shed_flag=${2:-0} # Adaptive plan prints: lines shed bucket start_cs=$(fwlive_adaptive_clock_cs) - # Drop dead oneshot flag — only consumed on logd's stream branch. + # Drop dead oneshot flag — only consumed on logd's stream branch (#306). if ! raw=$(ubus call log read "{\"lines\":$lines,\"stream\":false}" 2>/dev/null); then # Do not record ~0 ms as cold: failure is not healthy fast processing - # and must not clear an existing hot/shed cap. + # and must not clear an existing hot/shed cap (#329 Hermes Q1). out='{"log":[],"error":"log_read_failed"}' fwlive_adaptive_merge_reply "$out" "$shed_flag" "$lines" 0 0 return 0 fi - # Do not swallow a filter failure into a silent empty table. + # Summary aggregation is part of the adaptive contract. Keep it out of + # adaptive=0 replies, while the classifier still remains the single pass + # over decoded log entries (#306). + if fwlive_adaptive_enabled; then + FWLIVE_SUMMARY=1 + else + FWLIVE_SUMMARY=0 + fi + export FWLIVE_SUMMARY + + # Do not swallow a filter failure into a silent empty table (#220). # If the filter exits non-zero but still printed JSON, keep it. # Empty success is not valid poll JSON — the shipped filter always # prints {"log":[...]}, but a stub/regression must not emit a blank body. + _filter_ok=1 if ! out=$(printf '%s' "$raw" | "$FILTER_SH"); then + _filter_ok=0 if [ -z "$out" ]; then out='{"log":[],"error":"filter_failed"}' fi fi - [ -n "$out" ] || out='{"log":[],"error":"filter_empty"}' + if [ -z "$out" ]; then + _filter_ok=0 + out='{"log":[],"error":"filter_empty"}' + fi + # A classifier can return a structured error body while exiting zero (for + # example classifier_missing). Preserve the error for the caller, but do + # not let it overwrite a prior hot state as a healthy fast sample. + case "$out" in + *'"error":'*) _filter_ok=0 ;; + esac end_cs=$(fwlive_adaptive_clock_cs) - # Uptime wrap / skew: end < start — do not record as cold. - if [ "$end_cs" -ge "$start_cs" ]; then + # Uptime wrap / skew: end < start — do not record as cold (Grok #329 P2). + if [ "$_filter_ok" = 1 ] && [ "$end_cs" -ge "$start_cs" ]; then _dur=$(((end_cs - start_cs) * 10)) fwlive_adaptive_record "$_dur" "$lines" fi - # Layer 1: messages_received stays 0 — no post-filter ash count. + # The filter carries messages_received from its jsonfilter enumeration; + # failed/read-invalid paths keep the merge fallback at 0. _trunc=0 [ "$shed_flag" = 1 ] && _trunc=1 [ "$lines" -lt "$requested" ] && _trunc=1 @@ -502,7 +524,7 @@ resolve_hostname() { [ -n "$ip" ] || return 1 is_resolvable_address "$ip" || return 1 command -v nslookup >/dev/null 2>&1 || return 1 - # timeout/nslookup miss is "no PTR", not a plugin abort. + # timeout/nslookup miss is "no PTR", not a plugin abort (#291 C3). out=$(run_with_timeout "$RESOLVE_TIMEOUT" nslookup "$ip") || return 1 [ -n "$out" ] || return 1 name=$(parse_nslookup_name "$out") || return 1 @@ -510,7 +532,7 @@ resolve_hostname() { printf '%s' "$name" } -# Strict IPv4/IPv6 validation before nslookup: the old char-class +# Strict IPv4/IPv6 validation before nslookup (issue #190): the old char-class # check admitted hostname-shaped tokens of hex chars + dots/colons # (e.g. "dead.beef.cafe.baad"), letting the resolver fall back to upstream DNS # queries chosen by an authenticated session. Validate the real address @@ -523,7 +545,7 @@ is_resolvable_address() { # Reject anything outside the address alphabet BEFORE awk, so embedded # newlines/whitespace (which awk would treat as record separators) can # never let a valid first record mask trailing garbage that still reaches - # nslookup: nslookup only ever sees a single + # nslookup (#190, CodeRabbit/luna fold): nslookup only ever sees a single # token. Note the check intentionally allows only hex/colon/dot. case "$addr" in *[!0-9a-fA-F:.]*) return 1 ;; @@ -596,7 +618,7 @@ is_resolvable_address() { } resolve_addresses() { - # Missing resolver must not look like "no PTR". Check before + # Missing resolver must not look like "no PTR" (#218). Check before # starting the budget clock or reading stdin. if ! command -v nslookup >/dev/null 2>&1; then command -v logger >/dev/null 2>&1 && logger -t fwlive "nslookup not found; resolve disabled" @@ -604,14 +626,14 @@ resolve_addresses() { return 0 fi - # Load shed: previous poll was hot. + # Load shed: previous poll was hot (#306 Layer 1). if fwlive_adaptive_is_hot; then printf '{"names":{},"disabled":"load"}' return 0 fi - # Wall-clock budget clock starts at the TRUE function entry: the - # budget must bound the whole call — including + # Wall-clock budget clock starts at the TRUE function entry (luna + # folds 2026-08-10): the budget must bound the whole call — including # read_rpc_input (stalled stdin must not escape the budget) and json # parsing. `date +%s` can jump under NTP sync — accepted (worst case # the budget over- or under-runs by the jump; worker starvation is @@ -657,12 +679,12 @@ resolve_addresses() { # never START a lookup past the budget. now=$(date +%s) [ $((now - start)) -ge "$RESOLVE_BUDGET" ] && break - # Missing/empty element: set -u cannot read bare $ip. + # Missing/empty element: set -u cannot read bare $ip (#291 C3). if ! is_resolvable_address "${ip:-}"; then continue fi # NXDOMAIN / timeout: resolve_hostname returns 1. set -e - # cannot apply to this assignment. + # cannot apply to this assignment (#291 C3). name=$(resolve_hostname "$ip") || name= if [ -n "$name" ]; then map_add "$ip" "$name" @@ -729,7 +751,7 @@ run_selftest() { return 1 fi - # hostname-shaped tokens of hex chars + dots/colons passed the + # Issue #190: hostname-shaped tokens of hex chars + dots/colons passed the # old char-class check and made the resolver fall back to upstream DNS. Each case # below must REJECT (and would be ACCEPTED by the reverted char-class code). if is_resolvable_address 'ab.cd.ef.01'; then @@ -757,7 +779,7 @@ run_selftest() { echo 'is_resolvable_address: expected accept IPv6-mapped ::ffff:192.0.2.1' >&2 return 1 fi - # Multi-line input must be rejected wholesale: awk + # Multi-line input must be rejected wholesale (CodeRabbit/luna fold): awk # reads newline-separated records, so a valid first record must not mask # trailing garbage that would still reach nslookup. if is_resolvable_address "$(printf '192.0.2.1\nnot-an-address')"; then @@ -849,7 +871,7 @@ Address: 127.0.0.1:53") run_logging_selftest || return 1 - # Strict-mode smoke: status JSON uses soft-fail helpers + # Strict-mode smoke (#291 C3): status JSON uses soft-fail helpers # (uci miss, empty WAN zone). Must not abort under dash/ash set -e. _status=$(build_logging_status_json) || { echo "build_logging_status_json: aborted under set -e" >&2 @@ -950,7 +972,7 @@ case "${1:-}" in build_rules_map ;; poll) - # rpcd may omit argv $3 and pass JSON on stdin. + # rpcd may omit argv $3 and pass JSON on stdin (#291 C3). poll_logs "${3:-}" ;; resolve) diff --git a/luci-app-passwall2/Makefile b/luci-app-passwall2/Makefile index 3bbd51a31..e6ec3912d 100644 --- a/luci-app-passwall2/Makefile +++ b/luci-app-passwall2/Makefile @@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-passwall2 PKG_VERSION:=26.9.12 -PKG_RELEASE:=123 +PKG_RELEASE:=124 PKG_PO_VERSION:=$(PKG_VERSION) PKG_CONFIG_DEPENDS:= \ diff --git a/luci-app-passwall2/po/fa/passwall2.po b/luci-app-passwall2/po/fa/passwall2.po index 43f0f9203..4bf4fda4d 100644 --- a/luci-app-passwall2/po/fa/passwall2.po +++ b/luci-app-passwall2/po/fa/passwall2.po @@ -2174,23 +2174,8 @@ msgstr "حذف قوانین %s کامل شد." msgid "%s firewall rules load complete!" msgstr "بارگذاری قوانین فایروال %s کامل شد!" -msgid "Socks switch detection: Unknown error." -msgstr "تشخیص سوئیچ Socks: خطای ناشناخته." - -msgid "Socks switch detection: Unable to connect to the network. Please check if the network is working properly!" -msgstr "تشخیص سوئیچ Socks: عدم توانایی در اتصال به شبکه. لطفاً بررسی کنید که شبکه به درستی کار می‌کند!" - -msgid "Socks switch detection: Primary node 【%s: [%s]】 is normal. Switch to the primary node!" -msgstr "تشخیص سوئیچ Socks: گره اولیه 【%s: [%s]】 عادی است. سوئیچ به گره اولیه!" - -msgid "Socks switch detection: %s node switch complete!" -msgstr "تشخیص سوئیچ Socks: سوئیچ گره %s کامل شد!" - -msgid "Socks switch detection: %s 【%s:[%s]】 normal." -msgstr "تشخیص سوئیچ Socks: %s 【%s:[%s]】 عادی است." - -msgid "switch to %s test detect!" -msgstr "سوئیچ به تست تشخیص %s!" +msgid "main node" +msgstr "گره اصلی" msgid "backup node" msgstr "گره پشتیبان" @@ -2198,17 +2183,35 @@ msgstr "گره پشتیبان" msgid "next backup node" msgstr "گره پشتیبان بعدی" -msgid "main node" -msgstr "گره اصلی" +msgid "Socks switch detection: Port [%s] try %s [%s:%s]." +msgstr "تشخیص تغییر وضعیت سوئیچ: پورت [%s]، تلاش %s [%s:%s]." -msgid "Socks switch detection: Unknown error." -msgstr "تشخیص سوئیچ Socks: خطای ناشناخته." +msgid "Socks switch detection: Port [%s] [%s:[%s]] normal, switch to this node!" +msgstr "تشخیص تغییر وضعیت سوکت‌ها: پورت [%s] [%s:[%s]] در وضعیت عادی است، تغییر وضعیت به این گره!" -msgid "Socks switch detection: %s 【%s:[%s]】 abnormal, %s" -msgstr "تشخیص سوئیچ Socks: %s 【%s:[%s]】 غیرعادی، %s" +msgid "Socks switch detection: Port [%s] node switch complete!" +msgstr "تشخیص تغییر وضعیت سوکت‌ها: تغییر وضعیت گره در پورت [%s] تکمیل شد!" -msgid "Socks switch detection: %s 【%s:[%s]】 normal, switch to this node!" -msgstr "تشخیص سوئیچ Socks: %s 【%s:[%s]】 عادی، سوئیچ به این گره!" +msgid "Socks switch detection: Port [%s] [%s:[%s]] abnormal." +msgstr "تشخیص وضعیت سوئیچ: پورت [%s] [%s:[%s]] غیرعادی است." + +msgid "Socks switch detection: Port [%s] all nodes are unavailable!" +msgstr "تشخیص تغییر وضعیت سوئیچ: در پورت [%s]، تمام گره‌ها در دسترس نیستند!" + +msgid "Socks switch detection: Port [%s] Unknown error." +msgstr "تشخیص وضعیت سوئیچ جوراب: پورت [%s] خطای ناشناخته." + +msgid "Socks switch detection: Port [%s] Unable to connect to the network. Please check if the network is working properly!" +msgstr "تشخیص تغییر وضعیت سوکت: پورت [%s]؛ امکان اتصال به شبکه وجود ندارد. لطفاً بررسی کنید که آیا شبکه به‌درستی کار می‌کند یا خیر." + +msgid "Socks switch detection: Port [%s] Primary node [%s: [%s]] is normal. Switch to the primary node!" +msgstr "تشخیص تغییر وضعیت سوکت: وضعیت پورت [%s] و گره اصلی [%s: [%s]] عادی است. تغییر وضعیت به گره اصلی!" + +msgid "Socks switch detection: Port [%s] [%s:[%s]] normal." +msgstr "تشخیص وضعیت سوئیچ: پورت [%s] [%s:[%s]] در وضعیت عادی است." + +msgid "Socks switch detection: Port [%s] Number of backup nodes: %s" +msgstr "تشخیص سوئیچ سوئیچ شبکه: پورت [%s] تعداد گره‌های پشتیبان: %s" msgid "Restart dnsmasq service." msgstr "راه‌اندازی مجدد سرویس dnsmasq." diff --git a/luci-app-passwall2/po/ru/passwall2.po b/luci-app-passwall2/po/ru/passwall2.po index bea5f1c9a..f9503710e 100644 --- a/luci-app-passwall2/po/ru/passwall2.po +++ b/luci-app-passwall2/po/ru/passwall2.po @@ -2175,23 +2175,8 @@ msgstr "Удаление правил %s завершено." msgid "%s firewall rules load complete!" msgstr "Загрузка правил фаервола %s завершена!" -msgid "Socks switch detection: Unknown error." -msgstr "Проверка переключения SOCKS: неизвестная ошибка." - -msgid "Socks switch detection: Unable to connect to the network. Please check if the network is working properly!" -msgstr "Проверка переключения SOCKS: не удалось подключиться к сети. Пожалуйста, проверьте работоспособность сети!" - -msgid "Socks switch detection: Primary node 【%s: [%s]】 is normal. Switch to the primary node!" -msgstr "Проверка переключения SOCKS: основной сервер 【%s: [%s]】 работает нормально. Переключение на основной сервер!" - -msgid "Socks switch detection: %s node switch complete!" -msgstr "Проверка переключения SOCKS: переключение сервера %s завершено!" - -msgid "Socks switch detection: %s 【%s:[%s]】 normal." -msgstr "Проверка переключения SOCKS: %s 【%s:[%s]】 работает нормально." - -msgid "switch to %s test detect!" -msgstr "Переключение на %s для проверки!" +msgid "main node" +msgstr "основной сервер" msgid "backup node" msgstr "резервный сервер" @@ -2199,14 +2184,35 @@ msgstr "резервный сервер" msgid "next backup node" msgstr "следующий резервный сервер" -msgid "main node" -msgstr "основной сервер" +msgid "Socks switch detection: Port [%s] try %s [%s:%s]." +msgstr "Обнаружение переключения кабеля: порт [%s], попытка %s [%s:%s]." -msgid "Socks switch detection: %s 【%s:[%s]】 abnormal, %s" -msgstr "Проверка переключения SOCKS: %s 【%s:[%s]】 не работает, %s" +msgid "Socks switch detection: Port [%s] [%s:[%s]] normal, switch to this node!" +msgstr "Обнаружение переключения Socks: порт [%s] [%s:[%s]] в норме, переключение на этот узел!" -msgid "Socks switch detection: %s 【%s:[%s]】 normal, switch to this node!" -msgstr "Проверка переключения SOCKS: %s 【%s:[%s]】 работает нормально, переключение на этот сервер!" +msgid "Socks switch detection: Port [%s] node switch complete!" +msgstr "Обнаружение переключения соединений: переключение узла на порту [%s] завершено!" + +msgid "Socks switch detection: Port [%s] [%s:[%s]] abnormal." +msgstr "Обнаружено переключение Socks: порт [%s] [%s:[%s]] работает некорректно." + +msgid "Socks switch detection: Port [%s] all nodes are unavailable!" +msgstr "Обнаружено переключение коммутатора: порт [%s], все узлы недоступны!" + +msgid "Socks switch detection: Port [%s] Unknown error." +msgstr "Обнаружение переключения разъема: порт [%s], неизвестная ошибка." + +msgid "Socks switch detection: Port [%s] Unable to connect to the network. Please check if the network is working properly!" +msgstr "Обнаружено переключение Socks: порт [%s] — не удалось подключиться к сети. Пожалуйста, проверьте исправность сети!" + +msgid "Socks switch detection: Port [%s] Primary node [%s: [%s]] is normal. Switch to the primary node!" +msgstr "Обнаружено переключение: порт [%s], основной узел [%s: [%s]] — состояние нормальное. Переключение на основной узел!" + +msgid "Socks switch detection: Port [%s] [%s:[%s]] normal." +msgstr "Обнаружение переключения Socks: порт [%s] [%s:[%s]] — норма." + +msgid "Socks switch detection: Port [%s] Number of backup nodes: %s" +msgstr "Обнаружение переключения коммутатора: порт [%s], количество резервных узлов: %s" msgid "Restart dnsmasq service." msgstr "Перезапуск службы dnsmasq." diff --git a/luci-app-passwall2/po/zh-cn/passwall2.po b/luci-app-passwall2/po/zh-cn/passwall2.po index c45f283a3..6cbf32dd3 100644 --- a/luci-app-passwall2/po/zh-cn/passwall2.po +++ b/luci-app-passwall2/po/zh-cn/passwall2.po @@ -2160,23 +2160,8 @@ msgstr "删除 %s 规则完成。" msgid "%s firewall rules load complete!" msgstr "%s 防火墙规则加载完成!" -msgid "Socks switch detection: Unknown error." -msgstr "Socks切换检测:未知错误。" - -msgid "Socks switch detection: Unable to connect to the network. Please check if the network is working properly!" -msgstr "Socks切换检测:无法连接到网络,请检查网络是否正常!" - -msgid "Socks switch detection: Primary node 【%s: [%s]】 is normal. Switch to the primary node!" -msgstr "Socks切换检测:%s 主节点【%s:[%s]】正常,切换到主节点!" - -msgid "Socks switch detection: %s node switch complete!" -msgstr "Socks切换检测:%s 节点切换完毕!" - -msgid "Socks switch detection: %s 【%s:[%s]】 normal." -msgstr "Socks切换检测:%s 【%s:[%s]】 正常。" - -msgid "switch to %s test detect!" -msgstr "切换到 %s 检测!" +msgid "main node" +msgstr "主节点" msgid "backup node" msgstr "备用节点" @@ -2184,17 +2169,35 @@ msgstr "备用节点" msgid "next backup node" msgstr "下一个备用节点" -msgid "main node" -msgstr "主节点" +msgid "Socks switch detection: Port [%s] try %s [%s:%s]." +msgstr "Socks切换检测:端口 [%s] 尝试 %s 【%s:%s】。" -msgid "Socks switch detection: Unknown error." -msgstr "Socks切换检测:未知错误。" +msgid "Socks switch detection: Port [%s] [%s:[%s]] normal, switch to this node!" +msgstr "Socks切换检测:端口 [%s] 【%s:[%s]】 正常,切换到此节点!" -msgid "Socks switch detection: %s 【%s:[%s]】 abnormal, %s" -msgstr "Socks切换检测:%s 【%s:[%s]】 异常,%s" +msgid "Socks switch detection: Port [%s] node switch complete!" +msgstr "Socks切换检测:端口 [%s] 节点切换完毕!" -msgid "Socks switch detection: %s 【%s:[%s]】 normal, switch to this node!" -msgstr "Socks切换检测:%s 【%s:[%s]】 正常,切换到此节点!" +msgid "Socks switch detection: Port [%s] [%s:[%s]] abnormal." +msgstr "Socks切换检测:端口 [%s] 异常。" + +msgid "Socks switch detection: Port [%s] all nodes are unavailable!" +msgstr "Socks切换检测:端口 [%s] 所有节点均不可用!" + +msgid "Socks switch detection: Port [%s] Unknown error." +msgstr "Socks切换检测:端口 [%s] 未知错误。" + +msgid "Socks switch detection: Port [%s] Unable to connect to the network. Please check if the network is working properly!" +msgstr "Socks切换检测:端口 [%s] 无法连接到网络,请检查网络是否正常!" + +msgid "Socks switch detection: Port [%s] Primary node [%s: [%s]] is normal. Switch to the primary node!" +msgstr "Socks切换检测:端口 [%s] 主节点【%s:[%s]】正常,切换到主节点!" + +msgid "Socks switch detection: Port [%s] [%s:[%s]] normal." +msgstr "Socks切换检测:端口 [%s] 【%s:[%s]】 正常。" + +msgid "Socks switch detection: Port [%s] Number of backup nodes: %s" +msgstr "Socks切换检测:端口 [%s] 后备节点数量:%s" msgid "Restart dnsmasq service." msgstr "重启 dnsmasq 服务。" diff --git a/luci-app-passwall2/po/zh-tw/passwall2.po b/luci-app-passwall2/po/zh-tw/passwall2.po index 0a168b379..de315efcd 100644 --- a/luci-app-passwall2/po/zh-tw/passwall2.po +++ b/luci-app-passwall2/po/zh-tw/passwall2.po @@ -2166,23 +2166,8 @@ msgstr "刪除 %s 規則完成。" msgid "%s firewall rules load complete!" msgstr "%s 防火墙規則加载完成!" -msgid "Socks switch detection: Unknown error." -msgstr "Socks切換檢測:未知錯誤。" - -msgid "Socks switch detection: Unable to connect to the network. Please check if the network is working properly!" -msgstr "Socks切換檢測:無法連接到網絡,請檢查網絡是否正常!" - -msgid "Socks switch detection: Primary node 【%s: [%s]】 is normal. Switch to the primary node!" -msgstr "Socks切換檢測:%s 主節點【%s:[%s]】正常,切換到主節點!" - -msgid "Socks switch detection: %s node switch complete!" -msgstr "Socks切換檢測:%s 節點切換完毕!" - -msgid "Socks switch detection: %s 【%s:[%s]】 normal." -msgstr "Socks切換檢測:%s 【%s:[%s]】 正常。" - -msgid "switch to %s test detect!" -msgstr "切換到 %s 檢測!" +msgid "main node" +msgstr "主節點" msgid "backup node" msgstr "備用節點" @@ -2190,17 +2175,35 @@ msgstr "備用節點" msgid "next backup node" msgstr "下一個備用節點" -msgid "main node" -msgstr "主節點" +msgid "Socks switch detection: Port [%s] try %s [%s:%s]." +msgstr "Socks切換檢測:連接埠 [%s] 嘗試 %s 【%s:%s】。" -msgid "Socks switch detection: Unknown error." -msgstr "Socks切換檢測:未知錯誤。" +msgid "Socks switch detection: Port [%s] [%s:[%s]] normal, switch to this node!" +msgstr "Socks切換偵測:連接埠 [%s] 【%s:[%s]】 正常,切換到此節點!" -msgid "Socks switch detection: %s 【%s:[%s]】 abnormal, %s" -msgstr "Socks切換檢測:%s 【%s:[%s]】 异常,%s" +msgid "Socks switch detection: Port [%s] node switch complete!" +msgstr "Socks切換偵測:連接埠 [%s] 節點切換完畢!" -msgid "Socks switch detection: %s 【%s:[%s]】 normal, switch to this node!" -msgstr "Socks切換檢測:%s 【%s:[%s]】 正常,切換到此節點!" +msgid "Socks switch detection: Port [%s] [%s:[%s]] abnormal." +msgstr "Socks切換偵測:連接埠 [%s] 異常。" + +msgid "Socks switch detection: Port [%s] all nodes are unavailable!" +msgstr "Socks切換偵測:連接埠 [%s] 所有節點均不可用!" + +msgid "Socks switch detection: Port [%s] Unknown error." +msgstr "Socks切換偵測:連接埠 [%s] 未知錯誤。" + +msgid "Socks switch detection: Port [%s] Unable to connect to the network. Please check if the network is working properly!" +msgstr "Socks切換偵測:連接埠 [%s] 無法連接到網絡,請檢查網路是否正常!" + +msgid "Socks switch detection: Port [%s] Primary node [%s: [%s]] is normal. Switch to the primary node!" +msgstr "Socks切換偵測:連接埠 [%s] 主節點【%s:[%s]】正常,切換到主節點!" + +msgid "Socks switch detection: Port [%s] [%s:[%s]] normal." +msgstr "Socks切換檢測:連接埠 [%s] 【%s:[%s]】 正常。" + +msgid "Socks switch detection: Port [%s] Number of backup nodes: %s" +msgstr "Socks切換偵測:連接埠 [%s] 後備節點數量:%s" msgid "Restart dnsmasq service." msgstr "重啟 dnsmasq 服務。" diff --git a/luci-app-passwall2/root/usr/share/passwall2/nftables.sh b/luci-app-passwall2/root/usr/share/passwall2/nftables.sh index 72e4f8a4c..e282d98d5 100755 --- a/luci-app-passwall2/root/usr/share/passwall2/nftables.sh +++ b/luci-app-passwall2/root/usr/share/passwall2/nftables.sh @@ -331,15 +331,10 @@ load_acl() { [ -n "$dns_redirect_port" ] && { #nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE ip protocol udp udp dport 53 counter accept" #nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE ip protocol tcp tcp dport 53 counter accept" - if [ "$PROXY_IPV6" == "1" ]; then - #nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE_V6 meta l4proto udp udp dport 53 counter accept" - #nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE_V6 meta l4proto tcp tcp dport 53 counter accept" - nft "add rule $NFTABLE_NAME nat_output oif lo meta l4proto udp udp dport 53 counter redirect to :$dns_redirect_port comment \"PSW2_DNS\"" - nft "add rule $NFTABLE_NAME nat_output oif lo meta l4proto tcp tcp dport 53 counter redirect to :$dns_redirect_port comment \"PSW2_DNS\"" - else - nft "add rule $NFTABLE_NAME nat_output oif lo ip protocol udp udp dport 53 counter redirect to :$dns_redirect_port comment \"PSW2_DNS\"" - nft "add rule $NFTABLE_NAME nat_output oif lo ip protocol tcp tcp dport 53 counter redirect to :$dns_redirect_port comment \"PSW2_DNS\"" - fi + #nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE_V6 meta l4proto udp udp dport 53 counter accept" + #nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE_V6 meta l4proto tcp tcp dport 53 counter accept" + nft "add rule $NFTABLE_NAME nat_output oif lo meta l4proto udp udp dport 53 counter redirect to :$dns_redirect_port comment \"PSW2_DNS\"" + nft "add rule $NFTABLE_NAME nat_output oif lo meta l4proto tcp tcp dport 53 counter redirect to :$dns_redirect_port comment \"PSW2_DNS\"" log 2 "${msg}$(i18n "DNS will redirected to the dedicated DNS server [%s]." "${dns_redirect_port}")" } fi @@ -504,26 +499,16 @@ load_acl() { } if ([ -z "$no_tcp_proxy" ] || [ -z "$no_udp_proxy" ]) && [ -n "$dns_redirect_port" ]; then - if [ "$PROXY_IPV6" == "1" ]; then - nft "add rule $NFTABLE_NAME PSW2_MANGLE_V6 meta l4proto udp ${_ipt_source} udp dport 53 counter accept" - nft "add rule $NFTABLE_NAME PSW2_MANGLE_V6 meta l4proto tcp ${_ipt_source} tcp dport 53 counter accept" - nft "add rule $NFTABLE_NAME PSW2_DNS meta l4proto udp ${_ipt_source} udp dport 53 counter redirect to :$dns_redirect_port comment \"$remarks\"" - nft "add rule $NFTABLE_NAME PSW2_DNS meta l4proto tcp ${_ipt_source} tcp dport 53 counter redirect to :$dns_redirect_port comment \"$remarks\"" - else - nft "add rule $NFTABLE_NAME PSW2_MANGLE ip protocol udp ${_ipt_source} udp dport 53 counter accept" - nft "add rule $NFTABLE_NAME PSW2_MANGLE ip protocol tcp ${_ipt_source} tcp dport 53 counter accept" - nft "add rule $NFTABLE_NAME PSW2_DNS ip protocol udp ${_ipt_source} udp dport 53 counter redirect to :$dns_redirect_port comment \"$remarks\"" - nft "add rule $NFTABLE_NAME PSW2_DNS ip protocol tcp ${_ipt_source} tcp dport 53 counter redirect to :$dns_redirect_port comment \"$remarks\"" - fi + nft "add rule $NFTABLE_NAME PSW2_MANGLE ip protocol udp ${_ipt_source} udp dport 53 counter accept" + nft "add rule $NFTABLE_NAME PSW2_MANGLE ip protocol tcp ${_ipt_source} tcp dport 53 counter accept" + nft "add rule $NFTABLE_NAME PSW2_MANGLE_V6 meta l4proto udp ${_ipt_source} udp dport 53 counter accept" 2>/dev/null + nft "add rule $NFTABLE_NAME PSW2_MANGLE_V6 meta l4proto tcp ${_ipt_source} tcp dport 53 counter accept" 2>/dev/null + nft "add rule $NFTABLE_NAME PSW2_DNS meta l4proto udp ${_ipt_source} udp dport 53 counter redirect to :$dns_redirect_port comment \"$remarks\"" + nft "add rule $NFTABLE_NAME PSW2_DNS meta l4proto tcp ${_ipt_source} tcp dport 53 counter redirect to :$dns_redirect_port comment \"$remarks\"" log 2 "${msg}$(i18n "DNS will redirected to the dedicated DNS server [%s]." "${dns_redirect_port}")" else - if [ "$PROXY_IPV6" == "1" ]; then - nft "add rule $NFTABLE_NAME PSW2_DNS meta l4proto udp ${_ipt_source} udp dport 53 counter return comment \"$remarks\"" - nft "add rule $NFTABLE_NAME PSW2_DNS meta l4proto tcp ${_ipt_source} tcp dport 53 counter return comment \"$remarks\"" - else - nft "add rule $NFTABLE_NAME PSW2_DNS ip protocol udp ${_ipt_source} udp dport 53 counter return comment \"$remarks\"" - nft "add rule $NFTABLE_NAME PSW2_DNS ip protocol tcp ${_ipt_source} tcp dport 53 counter return comment \"$remarks\"" - fi + nft "add rule $NFTABLE_NAME PSW2_DNS meta l4proto udp ${_ipt_source} udp dport 53 counter return comment \"$remarks\"" 2>/dev/null + nft "add rule $NFTABLE_NAME PSW2_DNS meta l4proto tcp ${_ipt_source} tcp dport 53 counter return comment \"$remarks\"" 2>/dev/null fi [ -z "$no_tcp_proxy" ] && [ -n "$redir_port" ] && { @@ -828,6 +813,7 @@ add_firewall_rule() { # jump chains # Only TCP, UDP Invalid. nft "add rule $NFTABLE_NAME mangle_prerouting meta nfproto ipv4 meta l4proto tcp socket transparent 1 mark set ${FWMARK} counter accept comment PSW2_SOCKET" + nft "add rule $NFTABLE_NAME mangle_prerouting ip daddr != @$NFTSET_DIRECT ip protocol udp counter jump PSW2_MANGLE" [ -n "${is_tproxy}" ] && nft "add rule $NFTABLE_NAME mangle_prerouting ip daddr != @$NFTSET_DIRECT ip protocol tcp counter jump PSW2_MANGLE" @@ -899,9 +885,9 @@ add_firewall_rule() { [ "$PROXY_IPV6" == "1" ] && { # Only TCP, UDP Invalid. nft "add rule $NFTABLE_NAME mangle_prerouting meta nfproto ipv6 meta l4proto tcp socket transparent 1 mark set ${FWMARK} counter accept comment PSW2_SOCKET" + nft "add rule $NFTABLE_NAME mangle_prerouting ip6 daddr != @$NFTSET_DIRECT6 meta nfproto {ipv6} counter jump PSW2_MANGLE_V6" nft "add rule $NFTABLE_NAME mangle_output ip6 daddr != @$NFTSET_DIRECT6 meta nfproto {ipv6} counter jump PSW2_OUTPUT_MANGLE_V6 comment \"PSW2_OUTPUT_MANGLE\"" - nft "add rule $NFTABLE_NAME PSW2_MANGLE_V6 ip6 daddr @$NFTSET_WAN6 counter return comment \"WAN6_IP_RETURN\"" ip -6 rule add fwmark ${FWMARK} table 999 priority 999 diff --git a/luci-app-passwall2/root/usr/share/passwall2/socks_auto_switch.sh b/luci-app-passwall2/root/usr/share/passwall2/socks_auto_switch.sh index 1d4ee54af..634c276a0 100755 --- a/luci-app-passwall2/root/usr/share/passwall2/socks_auto_switch.sh +++ b/luci-app-passwall2/root/usr/share/passwall2/socks_auto_switch.sh @@ -12,13 +12,11 @@ check_process() { } test_url() { - local url=$1 - local try=1 - [ -n "$2" ] && try=$2 - local timeout=2 - [ -n "$3" ] && timeout=$3 - local extra_params=$4 - local repeat=$5 + local url="$1" + local try="${2:-1}" + local timeout="${3:-2}" + local extra_params="$4" + local repeat="$5" if [ -z "$curl_retry_all_errors" ]; then if /usr/bin/curl --help all | grep -q "\-\-retry-all-errors"; then @@ -29,23 +27,15 @@ test_url() { local max_time=$((timeout * (try + 1) + try + 3)) curl_test() { - /usr/bin/curl -I -o /dev/null -skL ${extra_params} --max-time ${max_time} --connect-timeout ${timeout} --retry ${try} --retry-delay 1 -w "%{http_code}" "$url" + /usr/bin/curl -skIL -o /dev/null ${extra_params} --max-time ${max_time} --connect-timeout ${timeout} --retry ${try} --retry-delay 1 -w "%{http_code}" "$url" } local status=$(curl_test) - case "$status" in - 204) - status=200 - ;; - esac + [ "$status" = "204" ] && status=200 if [ "$status" = "200" ] && [ "$repeat" = "1" ]; then sleep 3s status=$(curl_test) - case "$status" in - 204) - status=200 - ;; - esac + [ "$status" = "204" ] && status=200 fi echo $status } @@ -75,7 +65,7 @@ test_node() { local _type=$(echo $(config_n_get ${node_id} type) | tr 'A-Z' 'a-z') [ -n "${_type}" ] && { check_process - local _tmp_port=$(get_new_port 48800) + local _tmp_port=$(get_new_port 48800 tcp,udp) NO_REC_PROCESS=1 $APP_FILE run_socks flag="test_node_${node_id}" node=${node_id} bind=127.0.0.1 socks_port=${_tmp_port} config_file=test_node_${node_id}.json sleep 2s local curlx="socks5h://127.0.0.1:${_tmp_port}" @@ -92,6 +82,61 @@ test_node() { return 1 } +try_switch_backup() { + local b_nodes="$1" + local now_node="$2" + local total tried + local new_node msg + local first_node found node + + # Only one backup node, alternate with the primary node. + if [ "$backup_node_num" -eq 1 ]; then + b_nodes="$b_nodes $main_node" + fi + + total=$(printf "%s\n" "$b_nodes" | wc -w) + tried=0 + + while [ "$tried" -lt "$total" ]; do + new_node="" + first_node="" + found="" + for node in $b_nodes; do + [ -z "$first_node" ] && first_node="$node" # Record first node. + [ "$found" = "1" ] && { new_node="$node"; break; } # Find the current node and then get the next one. + [ "$node" = "$now_node" ] && found=1 # flag the found current node. + done + # If the current node is not found, or if it is the last node, select the first node. + [ -z "$new_node" ] && new_node="$first_node" + + local node_role node_type node_remarks + if [ "$new_node" = "$main_node" ]; then + node_role="$(i18n "main node")" + else + node_role="$(i18n "next backup node")" + fi + node_type=$(config_n_get $new_node type) + node_remarks=$(config_n_get $new_node remarks) + log_i18n 0 "Socks switch detection: Port [%s] try %s [%s:%s]." "${socks_port}" "${node_role}" "${node_type}" "${node_remarks}" + + if test_node ${new_node}; then + check_process + log_i18n 0 "Socks switch detection: Port [%s] [%s:[%s]] normal, switch to this node!" "${socks_port}" "${node_type}" "${node_remarks}" + NO_REC_PROCESS=1 $APP_FILE socks_node_switch flag=${id} new_node=${new_node} + [ $? -eq 0 ] && { + log_i18n 0 "Socks switch detection: Port [%s] node switch complete!" "${socks_port}" + } + return 0 + fi + log_i18n 0 "Socks switch detection: Port [%s] [%s:[%s]] abnormal." "${socks_port}" "${node_type}" "${node_remarks}" + now_node="$new_node" + tried=$((tried + 1)) + done + + log_i18n 0 "Socks switch detection: Port [%s] all nodes are unavailable!" "${socks_port}" + return 1 +} + test_auto_switch() { flag=$((flag + 1)) local b_nodes=$1 @@ -100,18 +145,16 @@ test_auto_switch() { if [ -n "$(get_cache_var "${id}")" ]; then now_node=$(get_cache_var "${id}") else - #log_i18n 0 "Socks switch detection: Unknown error." + #log_i18n 0 "Socks switch detection: Port [%s] Unknown error." "${socks_port}" return 1 fi } - - [ $flag -le 1 ] && { - main_node=$now_node - } + + [ $flag -le 1 ] && main_node=$now_node local status=$(test_proxy) if [ "$status" = "2" ]; then - log_i18n 0 "Socks switch detection: Unable to connect to the network. Please check if the network is working properly!" + log_i18n 0 "Socks switch detection: Port [%s] Unable to connect to the network. Please check if the network is working properly!" "${socks_port}" return 2 fi @@ -121,58 +164,23 @@ test_auto_switch() { [ $? -eq 0 ] && { check_process # The main node is working properly; switch to the main node. - log_i18n 0 "Socks switch detection: Primary node 【%s: [%s]】 is normal. Switch to the primary node!" "${id}" "$(config_n_get $main_node type)" "$(config_n_get $main_node remarks)" - $APP_FILE socks_node_switch flag=${id} new_node=${main_node} + log_i18n 0 "Socks switch detection: Port [%s] Primary node [%s: [%s]] is normal. Switch to the primary node!" "${socks_port}" "$(config_n_get $main_node type)" "$(config_n_get $main_node remarks)" + NO_REC_PROCESS=1 $APP_FILE socks_node_switch flag=${id} new_node=${main_node} [ $? -eq 0 ] && { - log_i18n 0 "Socks switch detection: %s node switch complete!" "${id}" + log_i18n 0 "Socks switch detection: Port [%s] node switch complete!" "${socks_port}" } return 0 } fi - if [ "$status" = "0" ]; then - #log_i18n 0 "Socks switch detection: %s 【%s:[%s]】 normal." "${id}" "$(config_n_get $now_node type)" "$(config_n_get $now_node remarks)" + [ "$status" = "0" ] && { + #log_i18n 0 "Socks switch detection: Port [%s] [%s:[%s]] normal." "${socks_port}" "$(config_n_get $now_node type)" "$(config_n_get $now_node remarks)" return 0 - elif [ "$status" = "1" ]; then - local new_node msg - if [ "$backup_node_num" -gt 1 ]; then - # When there are multiple backup nodes - local first_node found node - for node in $b_nodes; do - [ -z "$first_node" ] && first_node="$node" # Record the first node. - [ "$found" = "1" ] && { new_node="$node"; break; } # Find the current node and then retrieve the next one. - [ "$node" = "$now_node" ] && found=1 # Mark the current node found. - done - # If the current node is not found, or if the current node is the last node, then take the first node. - [ -z "$new_node" ] && new_node="$first_node" - local msg2="$(i18n "next backup node")" - if [ "$new_node" = "$main_node" ]; then - msg2="$(i18n "main node")" - else - [ "$now_node" = "$main_node" ] && msg2="$(i18n "backup node")" - fi - msg="$(i18n "switch to %s test detect!" "${msg2}")" - else - # When there is only one backup node, poll with the primary node. - new_node=$([ "$now_node" = "$main_node" ] && echo "$b_nodes" || echo "$main_node") - local msg2="$(i18n "main node")" - [ "$now_node" = "$main_node" ] && msg2="$(i18n "backup node")" - msg="$(i18n "switch to %s test detect!" "${msg2}")" - fi - log_i18n 0 "Socks switch detection: %s 【%s:[%s]】 abnormal, %s" "${id}" "$(config_n_get $now_node type)" "$(config_n_get $now_node remarks)" "${msg}" - test_node ${new_node} - if [ $? -eq 0 ]; then - check_process - log_i18n 0 "Socks switch detection: %s 【%s:[%s]】 normal, switch to this node!" "${id}" "$(config_n_get $new_node type)" "$(config_n_get $new_node remarks)" - $APP_FILE socks_node_switch flag=${id} new_node=${new_node} - [ $? -eq 0 ] && { - log_i18n 0 "Socks switch detection: %s node switch complete!" "${id}" - } - return 0 - else - test_auto_switch "${b_nodes}" ${new_node} - fi - fi + } + + log_i18n 0 "Socks switch detection: Port [%s] [%s:[%s]] abnormal." "${socks_port}" "$(config_n_get $now_node type)" "$(config_n_get $now_node remarks)" + try_switch_backup "$b_nodes" "$now_node" + return $? } start() { @@ -188,6 +196,7 @@ start() { backup_node=$(lua_api "get_socks_backup_nodes(\"${id}\")") if [ -n "$backup_node" ]; then backup_node_num=$(printf "%s\n" "$backup_node" | wc -w) + log_i18n 0 "Socks switch detection: Port [%s] Number of backup nodes: %s" "${socks_port}" "${backup_node_num}" if [ "$backup_node_num" -eq 1 ]; then [ "$main_node" = "$backup_node" ] && return elif [ "$backup_node_num" -gt 1 ]; then @@ -196,6 +205,7 @@ start() { } fi else + log_i18n 0 "Socks switch detection: Port [%s] Number of backup nodes: %s" "${socks_port}" "0" return fi while [ -n "$backup_node" ]; do diff --git a/luci-app-passwall2/root/usr/share/passwall2/test.sh b/luci-app-passwall2/root/usr/share/passwall2/test.sh index 92f382ad7..375a11d0d 100755 --- a/luci-app-passwall2/root/usr/share/passwall2/test.sh +++ b/luci-app-passwall2/root/usr/share/passwall2/test.sh @@ -46,10 +46,10 @@ url_test_node() { local node_id=$1 local _type=$(echo $(config_n_get ${node_id} type) | tr 'A-Z' 'a-z') [ -n "${_type}" ] && { - local _tmp_port=$(get_new_port 48900) + local _tmp_port=$(get_new_port 48900 tcp,udp) NO_REC_PROCESS=1 /usr/share/${CONFIG}/app.sh run_socks flag="url_test_${node_id}" node=${node_id} bind=127.0.0.1 socks_port=${_tmp_port} config_file=url_test_${node_id}.json - sleep 2s local curlx="socks5h://127.0.0.1:${_tmp_port}" + sleep 2s local probeUrl=$(config_n_get @global_other[0] url_test_url https://www.google.com/generate_204) result=$(curl --connect-timeout 3 --max-time 5 -o /dev/null -I -skL -w "%{http_code}:%{time_pretransfer}" -x ${curlx} "${probeUrl}") # End the SS plugin process diff --git a/luci-theme-footstrap/Makefile b/luci-theme-footstrap/Makefile index 17a194f83..fecf8c945 100644 --- a/luci-theme-footstrap/Makefile +++ b/luci-theme-footstrap/Makefile @@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap FOOTSTRAP_VERSION?= ifneq ($(FOOTSTRAP_VERSION),) PKG_VERSION:=$(FOOTSTRAP_VERSION) -PKG_RELEASE:=74 +PKG_RELEASE:=75 endif LUCI_TITLE:=Footstrap Theme diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js index 6049c40e5..73e134d12 100644 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js @@ -138,6 +138,16 @@ function clearViewIntervals() { /* Map, not Set: the key is the timer id and the value is what it takes to re-arm it */ _viewIntervals.forEach((spec, id) => { if (id !== keep) window.clearInterval(id); }); } +/* The "Refreshing"/"Paused" pill must not outlive the poll it reports on — see the `poll-stop` + * listener below, which is the only caller in the shipped file. Exported (fs:probe) too, because + * navigate()'s own async chain (require(), staging into a real `.fs-content`) needs more DOM than + * tests/lib's fakes give it; this is the one check that actually matters for the ordering bug and is + * exercised directly, without the listener's deferral, by tests/poll-status.test.mjs. */ +function hidePollIndicatorIfEmpty() { + if (!(L.Poll && L.Poll.queue && L.Poll.queue.length === 0)) return; + try { ui.hideIndicator('poll-status'); } + catch (e) { console.error('footstrap: hideIndicator threw on the poll-status teardown', e); } +} /* one line per document: this runs on every navigation, and a router that cannot read L.Poll * cannot read it on the next click either */ let _pollWarned = false; @@ -1095,6 +1105,11 @@ function navigate(pathname, push, kbd) { if (L.Poll && L.Poll.queue) { L.Poll.queue.length = 0; L.Poll.stop(); + /* The "Refreshing"/"Paused" pill's own teardown rides this `stop()`'s `poll-stop` event — + * see the listener below, not a call here. A page can also empty the queue on its own, with + * no navigation at all (`L.Poll.remove()`, e.g. `luci-mod-status`'s graphs.js on unload, + * `luci-app-banip`'s log view), and `stop()` dispatches the same event for that call too; + * a call placed only here would miss it, exactly the finding this once shipped without. */ L.Poll.start(); } /* kill the outgoing view's plain setInterval pollers too, as a full load would; L.Poll's own @@ -1401,24 +1416,42 @@ function wireRouter() { }); } -/* ---- the poll indicator must not outlive the poll ---- +/* ---- the poll-status pill must not outlive the poll it reports on ---- * - * LuCI shows the "Refreshing" pill on `poll-start`, flips it to "Paused" on `poll-stop` and never - * hides it again (core calls ui.hideIndicator() only for `uci-changes`). That is invisible on a full - * load, since Poll.start() dispatches `poll-start` only for a non-empty queue — but this router - * flushes the queue and calls stop() on every navigation, so walking from a polled page to an - * unpolled one leaves a "Paused" pill reporting on a poll that does not exist. The pill exists iff - * there is something to poll. Registered at module eval, i.e. after luci.js's own listener, so this - * runs second. */ + * LuCI shows the pill on `poll-start`, flips it to "Paused" on `poll-stop`, and never hides it + * again. Two different callers empty the queue and reach `L.Poll.stop()`: this router's own + * navigate() teardown, and a VIEW emptying its own queue mid-page with no navigation at all + * (`L.Poll.remove()` — `luci-mod-status`'s Realtime Graphs on unload, `luci-app-banip`'s log + * template) — both dispatch the same `poll-stop`, so one listener here covers both, where a call + * placed only in navigate()'s teardown covered the first and silently missed the second: reproduced + * live, owrt2410b and owrt2512b, add-then-remove on Statistics — `{"active":false,"q":0,"pill": + * {"text":"Paused","clickable":true}}`, unchanged by a click, because nothing had ever asked to hide + * a pill for a poll that a `remove()` rather than a navigation had ended. + * + * The HIDE itself is deferred past the synchronous dispatch, one microtask, rather than run inline + * in this listener: `stop()` dispatches `poll-stop` synchronously to every listener registered at + * that moment, including luci.js's own (`showIndicator('poll-status', 'Paused', null, 'inactive')` — + * "Paused" is always shown with `handler: null`), and which of the two ran first here used to decide + * everything — luci.js registers its listener from `setupDOM()`, after an async chain + * (DOMContentLoaded + ui/rpc/form + probeRPCBaseURL), this module at eval, from the inline + * `L.require('menu-footstrap')` in `partials/footer.ut`, so network/cache timing picked the order. + * Run with this listener first, its hide removed the span before luci.js re-created it for "Paused" + * with no handler, and the next `poll-start` found that span already there and only changed its + * text — clickless for the rest of the document. A microtask runs only once the whole synchronous + * turn that dispatched the event has unwound, i.e. after every `poll-stop` listener already fired + * regardless of which ran first, so the check sees the state stock's listener actually left rather + * than racing it. It still reads `L.Poll.queue.length` at that later moment, not at dispatch time, so + * a queue the SAME navigation's incoming page has already refilled by then is left alone — the + * incoming page's own `require()`/render() is real async work (network, RPCs), always later than a + * microtask queued during the synchronous `stop()` call that preceded it. `tests/poll-status.test.mjs` + * drives both paths (a navigation's queue flush and a bare `Poll.remove()` to empty) in both listener + * orders. docs/spa-router.md, "Teardown". */ document.addEventListener('poll-stop', () => { - if (L.Poll && L.Poll.queue && L.Poll.queue.length === 0) { - try { ui.hideIndicator('poll-status'); } - catch (e) { console.error('footstrap: hideIndicator threw on poll-stop', e); } - } + queueMicrotask(hidePollIndicatorIfEmpty); }); -/* At module eval, like the listener above: the session can die during the first view's own data - * calls, before anything has called wire(), and an interceptor registered later never sees it. */ +/* At module eval: the session can die during the first view's own data calls, before anything has + * called wire(), and an interceptor registered later never sees it. */ watchSession(); /* Pause LuCI's 1 s poll loop while the tab is hidden: LuCI has no visibilitychange handler, so an @@ -1470,6 +1503,10 @@ return baseclass.extend({ * navigate() is the real caller of the first and `_expired` gates the second. */ clearViewIntervals, /* fs:probe */ sessionExpired, /* fs:probe */ + /* tests/poll-status.test.mjs: the deferred `poll-stop` listener's own check, called directly so a + * test need not also fake a microtask tick to see it fire (see the comment at its definition and + * at the listener above) */ + hidePollIndicatorIfEmpty, /* fs:probe */ /* fs-search warms its recents and the arrow-key-highlighted result, neither of which the * pointer/focus triggers above can see. The edge points search -> router, because the router * must keep no dependency on the palette. */ diff --git a/luci-theme-footstrap/styles/theme/30-tables.css b/luci-theme-footstrap/styles/theme/30-tables.css index 588dc47f0..1cb31d811 100644 --- a/luci-theme-footstrap/styles/theme/30-tables.css +++ b/luci-theme-footstrap/styles/theme/30-tables.css @@ -258,13 +258,21 @@ } /* The frame is overflow:visible, so nothing clips the square corner cells to its radius and * they poked past it. Round the four OUTER cells — the frame's corners, not a per-row pill. - * `#view .table[id]` is listed because the frame rule draws the border there too. */ - .cbi-section .table[id] .tr.table-titles .th:first-child, - #view .table[id] .tr.table-titles .th:first-child, - table.fs-dt .tr.table-titles .th:first-child { border-top-left-radius: var(--fs-radius-lg); } - .cbi-section .table[id] .tr.table-titles .th:last-child, - #view .table[id] .tr.table-titles .th:last-child, - table.fs-dt .tr.table-titles .th:last-child { border-top-right-radius: var(--fs-radius-lg); } + * `#view .table[id]` is listed because the frame rule draws the border there too. + * + * `:is(.tr.table-titles, .thead)` because a header is any of the shapes fs-select.js adopts: + * `.tr.table-titles` is the bare-th-row markup, `.thead` covers a real `` whether it + * holds its ``s directly (E()-built, no ``) or through one — adoptMarkup() never adds + * `.tr` to that inner row, so a class-only selector has to reach the `.th` by descent, not by + * child. Without `.thead` the header kept its square top corners under any real ``: the + * DHCP and Wi-Fi dashboard tables measured a first/last `.th` radius of 0 against the frame's + * 12px. */ + .cbi-section .table[id] :is(.tr.table-titles, .thead) .th:first-child, + #view .table[id] :is(.tr.table-titles, .thead) .th:first-child, + table.fs-dt :is(.tr.table-titles, .thead) .th:first-child { border-top-left-radius: var(--fs-radius-lg); } + .cbi-section .table[id] :is(.tr.table-titles, .thead) .th:last-child, + #view .table[id] :is(.tr.table-titles, .thead) .th:last-child, + table.fs-dt :is(.tr.table-titles, .thead) .th:last-child { border-top-right-radius: var(--fs-radius-lg); } .cbi-section .table[id] .tr:last-child .td:first-child, #view .table[id] .tr:last-child .td:first-child, table.fs-dt .tr:last-child .td:first-child { border-bottom-left-radius: var(--fs-radius-lg); } @@ -473,6 +481,19 @@ .table .tr.cbi-section-table-titles::before { border-bottom: var(--fs-hairline); } .table .tr:not(.table-titles):not(.cbi-section-table-titles):not(:last-child) > .td, .table .tr.cbi-section-table-row:not(:last-child)::before { border-bottom: var(--fs-hairline); } + /* Because the separator above is a per-CELL border, an app that nudges one cell with + * `position: relative; top: …` (luci-mod-dashboard's `.devices-info .td:nth-child(3)`) drags + * that cell's border-bottom out of line with its row neighbours — a step, not a slanted line, + * since each cell paints its own hairline. Bootstrap never shows this because its rows are + * `border-collapse: collapse`, with no per-cell border to displace. The app's rule lands in the + * same fenced `theme` layer (fs-sheets.js), so an ID root outranks its 7 classes on specificity + * alone and needs no `!important`; `top` AND `bottom`, not just `top: 0`, so this also cancels + * a `bottom` offset and leaves `position: relative` itself untouched for any + * absolutely-positioned descendant. `top`/`bottom`, not the `inset-block` shorthand: the sheet + * has never used a logical inset property before and `css-floor` has no support decision for + * one, where the physical pair is already on the floor. Measured: offset .td top -3px -> 0, its + * border-bottom back level with its neighbours'. */ + :is(#view, #modal_overlay) .fs-dt .td { top: auto; bottom: auto; } /* descendant combinator (not >) so a real 's browser-inserted is covered * too — one rule themes every data table, div- or table-based. */ .table .tr.table-titles, .table .tr.cbi-section-table-titles { diff --git a/luci-theme-footstrap/styles/theme/60-inputs.css b/luci-theme-footstrap/styles/theme/60-inputs.css index 84dd27b40..5e4b0e372 100644 --- a/luci-theme-footstrap/styles/theme/60-inputs.css +++ b/luci-theme-footstrap/styles/theme/60-inputs.css @@ -138,11 +138,19 @@ /* A raw checkbox inside a button-styled label sat flush against its text: base zeroes the * checkbox margin (30-forms.css) and only a literal space separated the ✓ from the words * (issue #5.1 — attendedsysupgrade's "Keep settings" confirm: label.btn > input[type=checkbox] - * + text). Restore a gap. The toggle-switch .cbi-checkbox below is unaffected — its input is - * opacity:0, so this margin moves nothing there. */ + * + text). Restore a gap. The toggle-switch .cbi-checkbox below is unaffected — its own + * margin: 0 outranks this rule by specificity ((0,2,1) vs this rule's (0,2,1) on a DIFFERENT + * ancestor class, so the two never both match one element: `.cbi-checkbox` is never `.btn`). */ .btn > input[type="checkbox"], .btn > input[type="radio"] { margin-inline-end: .5em; } - /* checkbox -> toggle switch + /* checkbox -> toggle switch, drawn on the INPUT itself. + * + * Was drawn on label[for] with the real input hidden (position:absolute; opacity:0; width/ + * height:0) — forum.openwrt.org/t/251930 post #141: luci-app-modemdata (and any third-party CSS + * built the way bootstrap expects, styling the native input) injects an unlayered + * `label[for] { display: none }` flagged important, scoped to its own markup, which left + * NOTHING visible, since the input it left alone was the invisible one. Drawing on the input + * means an app that hides label[for] hides an already-empty placeholder, not the switch. * * The pill, the knob and the knob's travel are all on --fs-density-box. As bare literals they * make LuCI's most common control the only one in the theme that ignores the Density axis, while @@ -154,25 +162,19 @@ * * flex-shrink: 0 below (task 0145): `.cbi-checkbox` is the flex row, and a caption after the * pill — `label.cbi-checkbox > input + label[for] + TEXT`, package-manager.js's and banip's real - * markup, not the caption-less div the gallery had — makes the pill a SECOND, shrinkable flex - * item. Unshrunk it stays exactly --sw-w; measured shrunk on the live Install dialog on both - * stands, RU/Normal/390px gave a used width of 30.17px against 40 declared and a knob 6.83px past - * the pill's right edge, EN/Large/360px 36.22 against 46 declared and a 6.47px overhang — - * flex-shrink: 0 alone took both to -3.00px and -3.31px (the knob safely inside the pill, not - * flush with it), so a second guard (e.g. min-width: var(--sw-w), redundant with a non-shrinking - * flex-basis) was not needed and does not ship. + * markup — makes the input (now the real, sized flex item; label[for] no longer is one, below) a + * SECOND, shrinkable flex item. Unshrunk it stays exactly --sw-w; measured shrunk on the live + * Install dialog on both stands when this box was still label[for]'s (task 0145): RU/Normal/390px + * gave a used width of 30.17px against 40 declared and a knob 6.83px past the pill's right edge, + * EN/Large/360px 36.22 against 46 declared and a 6.47px overhang — flex-shrink: 0 alone took both + * to -3.00px and -3.31px (the knob safely inside the pill, not flush with it). The flex row and + * its sibling caption are unchanged by which element carries the pill, so the guard carries over. * - * Scoped to `.cbi-checkbox > label[for]`, not a bare `.cbi-checkbox > label`: ui.js's - * Checkbox.render() appends a SECOND label when `tooltip` is set (form.Flag.tooltip — e.g. - * luci-app-firewall's zones.js on `masq`), `label.cbi-tooltip-container` holding the hint icon, - * and the bare selector painted it as a second switch too — measured on the gallery's tooltip - * fixture, both labels 40x22 at rgb(55,62,71), flush against each other with no gap. `[for]` is - * the discriminator rather than `input[type="checkbox"] + label`: Checkbox.render() always - * builds the switch label as `E('label', { 'for': id })` and the tooltip label with no `for` at - * all, so the attribute alone tells the two apart, at a fifth of the bytes — the sibling - * combinator repeated across five rules (this one, its ::before/::after, and the two - * forced-colors rules in 95-a11y-media.css) put cascade.css 186 B over its budget; rescoping - * dropped it back under. */ + * label[for] stays in the DOM (ui.js and every third-party app that builds this markup emit it) + * but draws and occupies nothing: `[for]` still discriminates it from the SECOND label + * Checkbox.render() appends when `tooltip` is set (form.Flag.tooltip — e.g. luci-app-firewall's + * zones.js on `masq`), `label.cbi-tooltip-container` holding the hint icon, which must keep + * rendering. */ /* `overflow-wrap: anywhere`: the caption after the pill is a plain text flex item, which already * wraps at spaces on its own — this is only for the one WORD that does not fit even at the row's * own full width. Measured on this exact caption (see the pill's own comment below): pseudo-loc @@ -181,31 +183,45 @@ * is not implausible (the theme already ships an untranslated one), so the row should absorb it * by breaking the word, not by escaping its column. */ .cbi-checkbox { display: inline-flex; align-items: center; gap: var(--fs-space-2); overflow-wrap: anywhere; } - .cbi-checkbox > input[type="checkbox"] { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; } - .cbi-checkbox > label[for] { + /* label[for] takes over the input's old trick (position:absolute, zero box, opacity:0): out of + * flow and out of the flex item count, so `.cbi-checkbox`'s `gap` still opens on only the SAME + * two neighbours it always did — see the tooltip-container note below. */ + .cbi-checkbox > label[for] { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; } + .cbi-checkbox > input[type="checkbox"] { --sw-w: calc(40px * var(--fs-density-box)); --sw-h: calc(22px * var(--fs-density-box)); --sw-knob: calc(16px * var(--fs-density-box)); --sw-pad: calc(2px * var(--fs-density-box)); + /* `appearance: none` and this compound's other unstated properties (color, the invalid + * --bd-color/--fg-color custom props) still come from the bare `input[type="checkbox"]` + * rule further down, written for a third-party app's raw markup: harmless to share, since + * this input never reads --bd-color/--fg-color. The parts of that rule that WOULD paint + * over this switch (the drawn box, the mark, the focus ring) are scoped with + * `:not(:where(.cbi-checkbox > input))` at their own selector instead of being undone here — + * cheaper than restating every property this rule already states for the opposite reason, + * and it is one property, not eight, that both rules would otherwise fight over. */ display: inline-block; position: relative; margin: 0; flex-shrink: 0; width: var(--sw-w); height: var(--sw-h); border-radius: var(--fs-radius-pill); background: var(--fs-track); border: var(--fs-hairline); cursor: pointer; transition: background var(--fs-dur), border-color var(--fs-dur); vertical-align: middle; } - /* The input is opacity:0 at 0x0, so this label IS the whole hit box of LuCI's most common - * control — and at 22px tall it sits under the 24px floor of WCAG 2.5.8. The floor is about the - * CLICKABLE area, not the drawing: an invisible overlay stretches the target to 44x30 without - * fattening the pill (a visual 44x24 was tried and looked bloated). position:absolute, so it - * adds nothing to layout; the knob keeps ::after. + /* The invisible overlay stretching the hit target to 44x30 (WCAG 2.5.8: the pill is 22px tall, + * under the 24px floor) — moved here from label[for] unchanged. position:absolute, so it adds + * nothing to layout; the knob keeps ::after. + * + * `content`/`position` come from the raw checkbox's own shared `::before, ::after { position: + * absolute; content: "" }` rule further down — left UNSCOPED and reused here rather than + * restated, since both pseudo-elements need exactly those two values anyway. The two rules + * that would otherwise paint a drawn box and a mark ON this pseudo (their own selectors, + * further down) are scoped with `:not(:where(.cbi-checkbox > input))` instead, so nothing is left to + * override here. * * The inset stays literal while the pill above scales: this is an accessibility floor, and a * floor that shrinks with a density setting is not one. At Compact the target is still 38x26.7, * over the minimum. */ - .cbi-checkbox > label[for]::before { - content: ""; position: absolute; inset: -4px -2px; - } - .cbi-checkbox > label[for]::after { - content: ""; position: absolute; top: var(--sw-pad); inset-inline-start: var(--sw-pad); + .cbi-checkbox > input[type="checkbox"]::before { inset: -4px -2px; } + .cbi-checkbox > input[type="checkbox"]::after { + top: var(--sw-pad); inset-inline-start: var(--sw-pad); width: var(--sw-knob); height: var(--sw-knob); border-radius: 50%; background: var(--fs-dim); /* transform, not `left`: `left` is a layout property, so the knob re-laid out the row on @@ -213,47 +229,58 @@ transform: translateX(0); transition: transform var(--fs-dur-move), background var(--fs-dur); } - /* The tooltip-container label (above) is now unstyled by the switch rules, so it falls back to - * the browser's plain inline label plus base's `cursor: help` — the icon glyph, no pill. It still + /* The tooltip-container label (above) is unstyled by the switch rules and falls back to the + * browser's plain inline label plus base's `cursor: help` — the icon glyph, no pill. It still * needs a gap from the switch, and `.cbi-checkbox`'s own `gap` above gives it one instead of a - * margin declared here: the checkbox input inside that flex row is `position: absolute`, so it is - * never a flex item, and `gap` only ever opens BETWEEN flex items — an ordinary `.cbi-checkbox` - * with just the switch label has one flex item and gets no gap at all (measured: cb1's box is - * still 40px wide, unchanged), while the tooltip fixture's two labels get exactly one. --fs-space-2 - * (8px), same number the removed margin used — the gap the theme already uses beside an inline - * checkbox glyph (`.btn > input[type="checkbox"]` above) — measured on the gallery fixture: top - * edges 0.5px apart (the icon glyph's own line box, not this gap) and left-to-right edge 8px, - * both unchanged from before this rescope. */ - .cbi-checkbox > input[type="checkbox"]:checked + label { background: var(--fs-accent); border-color: transparent; } + * margin declared here: label[for] inside that flex row is `position: absolute` (above), so it + * is never a flex item, and `gap` only ever opens BETWEEN flex items — an ordinary `.cbi-checkbox` + * with just the switch input has one flex item (the input itself) and gets no gap at all, while + * the tooltip fixture's input+tooltip-label pair gets exactly one. --fs-space-2 (8px), same + * number the removed margin used — the gap the theme already uses beside an inline checkbox + * glyph (`.btn > input[type="checkbox"]` above). The flex-item COUNT and the gap token are the + * same as before this rule moved from label[for] to the input; only which element is the + * absolute one swapped. */ + .cbi-checkbox > input[type="checkbox"]:checked { background: var(--fs-accent); border-color: transparent; } /* The travel is derived rather than hand-written, so it cannot drift out of step with the three * sizes above. The `- 2px` is the pill's two 1px BORDERS and is the whole subtlety: --sw-w is a * border-box width, while an absolutely-positioned ::after is placed against the PADDING box, so * the knob has 2px less room and deriving without that term lands it off-centre — the travel * comes out 20px where symmetry wants 18. The border does not scale with density, so the term * stays literal. */ - .cbi-checkbox > input[type="checkbox"]:checked + label::after { + .cbi-checkbox > input[type="checkbox"]:checked::after { transform: translateX(calc(var(--sw-w) - var(--sw-knob) - var(--sw-pad) * 2 - 2px)); background: var(--fs-on-accent); } - /* The real checkbox is opacity:0 at 0x0 above, so base's `input:focus::before` glow paints on a - * 0x0 box. The