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