⛅ Sync 2026-09-16 03:46:54

This commit is contained in:
github-actions[bot] committed 2026-09-16 03:46:54 +08:00
1 parent b5b3006d79
commit 0deac8fcbb
29 files changed
+1215 -429

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ LUCI_DEPENDS:=+luci-base +logd +jsonfilter
LUCI_PKGARCH:=all
PKG_VERSION:=0.1.41
PKG_RELEASE:=16
PKG_RELEASE:=17
# Reproducible build: honor SOURCE_DATE_EPOCH from the build environment (set by docker-sdk.sh / CI).
ifdef SOURCE_DATE_EPOCH
@@ -11,5 +11,5 @@
* GENERATED — do not edit. Edit fwlive.css and run: node scripts/embed-fwlive-css.js
*/
return baseclass.extend({
styleText: "\n.fwlive-map {\n\tmax-width: none;\n\twidth: 100%;\n\t--fwlive-pass-color: var(--success-color-high, var(--success-color, #46a546));\n\t--fwlive-deny-color: var(--error-color-high, var(--error-color, #ca3c3c));\n\t--fwlive-bg-medium: var(--background-color-medium, var(--white-color-low, #f9f9f9));\n\t--fwlive-paused-color: #b45309;\n}\n/* Wide page in Detail only — Simple keeps LuCI’s 940px column. */\n#maincontent.container:has(.fwlive-map[data-view=\"detailed\"]) {\n\tmax-width: none;\n\tpadding-left: 16px;\n\tpadding-right: 16px;\n}\n.fwlive-map[data-row-tint=\"accessible\"] {\n\t--fwlive-pass-color: #0d9488;\n\t--fwlive-deny-color: #c2410c;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row,\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tbackground: rgba(180, 83, 9, 0.08);\n\tbackground: color-mix(in srgb, var(--fwlive-paused-color) 8%, transparent);\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row {\n\tmargin: 0 -8px;\n\tpadding: 6px 8px 0;\n\tborder-radius: 3px 3px 0 0;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tmargin: 0 -8px 10px;\n\tpadding: 8px;\n\tborder-radius: 0 0 3px 3px;\n\tborder-bottom-color: transparent;\n}\n.fwlive-title-row {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: baseline;\n\tjustify-content: space-between;\n\tgap: 8px 16px;\n\tmargin: 0 0 4px;\n}\n.fwlive-title-row h2 {\n\tmargin: 0;\n\tflex: 0 1 auto;\n}\n.fwlive-title-status {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 8px;\n\tflex-wrap: wrap;\n\tmin-width: 0;\n\tmargin-left: auto;\n}\n.fwlive-watch-strip {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: stretch;\n\tjustify-content: flex-start;\n\tgap: 0;\n\tmin-height: 40px;\n\tpadding: 8px 0 10px;\n\tborder-bottom: 1px solid var(--border-color-medium);\n\tmargin-bottom: 10px;\n}\n.fwlive-watch-group {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 6px;\n\tmin-height: 28px;\n\tpadding: 0 10px;\n\tborder-left: 1px solid var(--border-color-medium);\n\tflex-wrap: wrap;\n}\n.fwlive-watch-group:last-child {\n\tpadding-right: 0;\n}\n.fwlive-watch-group-label {\n\tmargin-right: 2px;\n\tcolor: var(--text-color-medium);\n\tfont-size: 0.72em;\n\tfont-weight: 700;\n\tletter-spacing: 0.08em;\n\ttext-transform: uppercase;\n\twhite-space: nowrap;\n}\n.fwlive-watch-seg {\n\tdisplay: inline-flex;\n\talign-items: stretch;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\toverflow: hidden;\n}\n.fwlive-watch-seg .fwlive-seg-btn {\n\tmargin: 0;\n\tborder: 0;\n\tborder-radius: 0;\n\tborder-right: 1px solid var(--border-color-medium);\n\tbackground: transparent;\n\tpadding: 4px 10px;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n}\n.fwlive-watch-seg .fwlive-seg-btn:last-child {\n\tborder-right: 0;\n}\n.fwlive-watch-seg .fwlive-seg-btn[aria-pressed=\"true\"] {\n\tbackground: rgba(26, 111, 156, 0.12);\n\tbackground: color-mix(in srgb, var(--primary-color-high) 12%, transparent);\n\tcolor: var(--primary-color-high);\n\tfont-weight: 600;\n}\n.fwlive-watch-label { font-weight: 600; }\n.fwlive-watch-paused .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-map.fwlive-watch-paused .fwlive-title-status .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-dot {\n\twidth: 8px;\n\theight: 8px;\n\tborder-radius: 50%;\n\tbackground: var(--text-color-low, var(--border-color-high));\n\tdisplay: inline-block;\n\tflex-shrink: 0;\n}\n.fwlive-dot.fwlive-dot-on {\n\tbackground: var(--fwlive-pass-color);\n\tbox-shadow: 0 0 0 3px color-mix(in srgb, var(--fwlive-pass-color) 22%, transparent);\n}\n.fwlive-btn-ghost {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n}\n.fwlive-btn-quiet {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n\tcolor: var(--text-color-medium);\n\tfont-weight: 500;\n}\n.fwlive-log-merged {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tpadding: 4px 10px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-medium, var(--white-color-low, var(--background-color-high)));\n\tcolor: var(--text-color-high);\n\tfont-weight: 500;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n\tcursor: pointer;\n}\n.fwlive-log-merged:hover {\n\tbackground: var(--background-color-low, var(--background-color-medium));\n}\n.fwlive-log-merged:disabled {\n\topacity: 0.65;\n\tcursor: default;\n}\n.fwlive-log-on-dot {\n\twidth: 6px;\n\theight: 6px;\n\tborder-radius: 50%;\n\tbackground: var(--fwlive-pass-color);\n\tflex-shrink: 0;\n}\n.fwlive-log-rate {\n\tcolor: var(--text-color-medium);\n\tfont-weight: 400;\n}\n.fwlive-display-bar {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: center;\n\tgap: 8px 14px;\n\tmargin: 0 0 12px;\n\tpadding: 8px 12px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-high);\n}\n.fwlive-display-bar-label {\n\tflex: 0 0 auto;\n\tfoLine truncated
styleText: "\n.fwlive-map {\n\tmax-width: none;\n\twidth: 100%;\n\t--fwlive-pass-color: var(--success-color-high, var(--success-color, #46a546));\n\t--fwlive-deny-color: var(--error-color-high, var(--error-color, #ca3c3c));\n\t--fwlive-bg-medium: var(--background-color-medium, var(--white-color-low, #f9f9f9));\n\t--fwlive-paused-color: #b45309;\n}\n/* Wide page in Detail only — Simple keeps LuCI’s 940px column. */\n#maincontent.container:has(.fwlive-map[data-view=\"detailed\"]) {\n\tmax-width: none;\n\tpadding-left: 16px;\n\tpadding-right: 16px;\n}\n.fwlive-map[data-row-tint=\"accessible\"] {\n\t--fwlive-pass-color: #0d9488;\n\t--fwlive-deny-color: #c2410c;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row,\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tbackground: rgba(180, 83, 9, 0.08);\n\tbackground: color-mix(in srgb, var(--fwlive-paused-color) 8%, transparent);\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row {\n\tmargin: 0 -8px;\n\tpadding: 6px 8px 0;\n\tborder-radius: 3px 3px 0 0;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tmargin: 0 -8px 10px;\n\tpadding: 8px;\n\tborder-radius: 0 0 3px 3px;\n\tborder-bottom-color: transparent;\n}\n.fwlive-title-row {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: baseline;\n\tjustify-content: space-between;\n\tgap: 8px 16px;\n\tmargin: 0 0 4px;\n}\n.fwlive-title-row h2 {\n\tmargin: 0;\n\tflex: 0 1 auto;\n}\n.fwlive-title-status {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 8px;\n\tflex-wrap: wrap;\n\tmin-width: 0;\n\tmargin-left: auto;\n}\n.fwlive-watch-strip {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: stretch;\n\tjustify-content: flex-start;\n\tgap: 0;\n\tmin-height: 40px;\n\tpadding: 8px 0 10px;\n\tborder-bottom: 1px solid var(--border-color-medium);\n\tmargin-bottom: 10px;\n}\n.fwlive-watch-group {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 6px;\n\tmin-height: 28px;\n\tpadding: 0 10px;\n\tborder-left: 1px solid var(--border-color-medium);\n\tflex-wrap: wrap;\n}\n.fwlive-watch-group:last-child {\n\tpadding-right: 0;\n}\n.fwlive-watch-group-label {\n\tmargin-right: 2px;\n\tcolor: var(--text-color-medium);\n\tfont-size: 0.72em;\n\tfont-weight: 700;\n\tletter-spacing: 0.08em;\n\ttext-transform: uppercase;\n\twhite-space: nowrap;\n}\n.fwlive-watch-seg {\n\tdisplay: inline-flex;\n\talign-items: stretch;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\toverflow: hidden;\n}\n.fwlive-watch-seg .fwlive-seg-btn {\n\tmargin: 0;\n\tborder: 0;\n\tborder-radius: 0;\n\tborder-right: 1px solid var(--border-color-medium);\n\tbackground: transparent;\n\tpadding: 4px 10px;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n}\n.fwlive-watch-seg .fwlive-seg-btn:last-child {\n\tborder-right: 0;\n}\n.fwlive-watch-seg .fwlive-seg-btn[aria-pressed=\"true\"] {\n\tbackground: rgba(26, 111, 156, 0.12);\n\tbackground: color-mix(in srgb, var(--primary-color-high) 12%, transparent);\n\tcolor: var(--primary-color-high);\n\tfont-weight: 600;\n}\n.fwlive-watch-label { font-weight: 600; }\n.fwlive-watch-paused .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-map.fwlive-watch-paused .fwlive-title-status .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-dot {\n\twidth: 8px;\n\theight: 8px;\n\tborder-radius: 50%;\n\tbackground: var(--text-color-low, var(--border-color-high));\n\tdisplay: inline-block;\n\tflex-shrink: 0;\n}\n.fwlive-dot.fwlive-dot-on {\n\tbackground: var(--fwlive-pass-color);\n\tbox-shadow: 0 0 0 3px color-mix(in srgb, var(--fwlive-pass-color) 22%, transparent);\n}\n.fwlive-btn-ghost {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n}\n.fwlive-btn-quiet {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n\tcolor: var(--text-color-medium);\n\tfont-weight: 500;\n}\n.fwlive-log-merged {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tpadding: 4px 10px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-medium, var(--white-color-low, var(--background-color-high)));\n\tcolor: var(--text-color-high);\n\tfont-weight: 500;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n\tcursor: pointer;\n}\n.fwlive-log-merged:hover {\n\tbackground: var(--background-color-low, var(--background-color-medium));\n}\n.fwlive-log-merged:disabled {\n\topacity: 0.65;\n\tcursor: default;\n}\n.fwlive-log-on-dot {\n\twidth: 6px;\n\theight: 6px;\n\tborder-radius: 50%;\n\tbackground: var(--fwlive-pass-color);\n\tflex-shrink: 0;\n}\n.fwlive-log-rate {\n\tcolor: var(--text-color-medium);\n\tfont-weight: 400;\n}\n.fwlive-display-bar {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: center;\n\tgap: 8px 14px;\n\tmargin: 0 0 12px;\n\tpadding: 8px 12px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-high);\n}\n.fwlive-display-bar-label {\n\tflex: 0 0 auto;\n\tfoLine truncated
});
@@ -670,6 +670,25 @@
color: var(--text-color-high);
font-size: 0.92em;
}
.fwlive-summary {
margin: 0 0 10px;
padding: 8px 12px;
background: var(--background-color-low);
border: 1px solid var(--border-color-medium);
border-radius: 3px;
color: var(--text-color-high);
}
.fwlive-summary-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.fwlive-summary-body {
margin: 8px 0 0;
font: inherit;
white-space: pre-wrap;
}
.fwlive-tint-warn {
display: none;
color: var(--warn-color-high);
@@ -14,7 +14,7 @@
* callbacks - {} (unused; present for API consistency)
*
* renderRows(host, state, callbacks) → void
* host - <tbody> element (cleared and rebuilt; element itself is kept)
* host - <tbody> 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({
@@ -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' }, [
+30
View File
@@ -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"
+30
View File
@@ -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 "Частые источники"
@@ -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 ""
@@ -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 "主要通信方"
@@ -2,7 +2,7 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com>
#
# 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:-<state-dir>/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"
}
@@ -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
}
@@ -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
}
@@ -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"
@@ -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() {
+52 -30
View File
@@ -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)
+1 -1
View File
@@ -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:= \
+28 -25
View File
@@ -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."
+29 -23
View File
@@ -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."
+28 -25
View File
@@ -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 服务。"
+28 -25
View File
@@ -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 服務。"
@@ -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
@@ -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
@@ -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
+1 -1
View File
@@ -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
@@ -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. */
@@ -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 `<thead>` whether it
* holds its `<th>`s directly (E()-built, no `<tr>`) 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 `<thead>`: 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 <table>'s browser-inserted <tbody> is covered
* too — one rule themes every data table, div- or table-based. */
.table .tr.table-titles, .table .tr.cbi-section-table-titles {
+92 -63
View File
@@ -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 <label> IS the switch, so the ring goes there, or the most common control in LuCI
* shows nothing under keyboard focus.
/* The bare fallback ring further down (`input:focus`, for every untyped/unlisted input, and its
* `input[type="checkbox"]:focus::before` twin) is scoped with `:not(:where(.cbi-checkbox > input))` at
* its own selector, so a plain click on this real, visible input rings nothing — as it never
* did before this input was the drawn element, since only :focus-visible below is meant to. */
/* The <input> IS the switch now, so the ring goes there directly, or the most common control in
* LuCI shows nothing under keyboard focus.
*
* -solo, because on the CHECKED switch — half the switches on any page — neither other channel
* exists: the rule above fills the track with --fs-accent and sets `border-color: transparent`,
* so the border flip is accent on accent (measured: fill rgb(86,157,245) against a border of the
* same accent at .97 alpha) and the tinted ring is accent at .15 alpha. */
.cbi-checkbox > input[type="checkbox"]:focus-visible + label {
.cbi-checkbox > input[type="checkbox"]:focus-visible {
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring-solo);
}
.cbi-checkbox > input[type="checkbox"]:disabled + label { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
.cbi-checkbox > input[type="checkbox"]:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
/* Raw checkbox/radio — the drawing, absorbed from base whole: it undoes the generic `input` box
* below, and left in base every one of those overrides would lose to that rule by layer. The box
* is 16px on the grid and the tick or dot is inset FROM it rather than restating its own number,
* so resizing the box cannot leave the mark off-centre. LuCI's own forms wrap checkboxes in the
* .cbi-checkbox toggle above; the raw drawing is what a third-party app's bare markup gets. */
* .cbi-checkbox toggle above; the raw drawing is what a third-party app's bare markup gets.
*
* `.cbi-checkbox > input` matches the switch's own input too: this base rule stays unscoped for
* it, since the switch's own rules above want its `appearance: none` and read neither
* --bd-color nor --fg-color. But the drawn box, the mark and the focus ring below DO paint over
* the switch once its input is a real, visible element rather than 0x0 — each of those three
* selectors carries `:not(:where(.cbi-checkbox > input))` for that reason alone; undoing them instead,
* property by property, on the switch's own rules costs more than the one exclusion clause
* each needs. */
input[type="checkbox"],
input[type="radio"] {
--bd-color: var(--fs-border);
@@ -307,7 +334,7 @@
position: absolute;
content: "";
}
input[type="checkbox"]::before,
input[type="checkbox"]:not(:where(.cbi-checkbox > input))::before,
input[type="radio"]::before {
/* centred in the 24px target, and direction-agnostic: `inset: 0; margin: auto` on a box with
* a fixed size is the one centring that needs no translate and therefore behaves the same in
@@ -333,7 +360,7 @@
* of ringing a clicked box. Both states (plain and invalid) are declared once, further
* down this file, in the selector lists every other same-body ring joins — see the two
* `--fs-focus-ring`/`--fs-focus-ring-invalid` rules below the text-input group. */
input[type="checkbox"]::after,
input[type="checkbox"]:not(:where(.cbi-checkbox > input))::after,
input[type="radio"]::after {
/* the mark is inset from the DRAWING, and the drawing is centred in the target, so the mark
* centres the same way rather than measuring from the target's own edge */
@@ -342,7 +369,7 @@
width: calc(var(--box) - var(--mark-inset) * 2);
height: calc(var(--box) - var(--mark-inset) * 2);
}
input[type="checkbox"]:checked::after,
input[type="checkbox"]:not(:where(.cbi-checkbox > input)):checked::after,
input[type="radio"]:checked::after {
/* OURS. Drawn as a STROKE, which is this theme's icon language (see --spinner-icon in
* base/95-luci.css and --fs-icon-refresh in 02-tokens.css: fill:none, round caps, a 24 grid).
@@ -626,15 +653,16 @@
textarea:hover { border-color: var(--fs-dim); }
/* bare `input:focus` absorbed from base's generic glow onto the same ring: it is the fallback
* for the types the list does not name and for a typeless <input>; on checkbox/radio it lands
* on the invisible element box UNDER the drawn ::before ring above — coincident boxes, one
* visible ring, as the old glow behaved. `select:focus` and the keyboard-removable dynlist
* chip (ui.js gives it a tabindex) join here rather than repeating the same three
* declarations under their own selector: one shared body instead of three, 149 B off
* cascade.css. */
* for the types the list does not name and for a typeless <input>; on a bare checkbox/radio it
* lands on the drawn box UNDER the ::before ring below — coincident boxes, one visible ring, as
* the old glow behaved. `:not(:where(.cbi-checkbox > input))` excludes the toggle switch, whose input is
* a real, visible element with its own :focus-visible-only ring (above) — a plain click on it
* must ring nothing. `select:focus` and the keyboard-removable dynlist chip (ui.js gives it a
* tabindex) join here rather than repeating the same three declarations under their own
* selector: one shared body instead of three, 149 B off cascade.css. */
.cbi-input-text:focus, .cbi-input-password:focus, .cbi-input-textarea:focus,
.cbi-section-create-name:focus,
input:focus,
input:focus:not(:where(.cbi-checkbox > input)),
input[type="text"]:focus, input[type="password"]:focus, input[type="number"]:focus,
input[type="email"]:focus, input[type="url"]:focus, input[type="search"]:focus,
input[type="tel"]:focus, input[type="date"]:focus, input[type="time"]:focus,
@@ -651,8 +679,9 @@
* off cascade.css. `:is()` around the `:has()` compound keeps the list forgiving: unwrapped, a
* `:has()` compound sharing a selector list with plain ones fails the WHOLE rule below the
* floor (Firefox < 121; docs/css.md "Two shapes fail it"), instead of just losing that one
* selector. */
input[type="checkbox"]:focus::before, input[type="radio"]:focus::before,
* selector. `:not(:where(.cbi-checkbox > input))` on the checkbox part for the same reason as the plain
* ring above: the toggle switch's own :focus-visible rule is the only one allowed to ring it. */
input[type="checkbox"]:not(:where(.cbi-checkbox > input)):focus::before, input[type="radio"]:focus::before,
.cbi-select.focus,
.cbi-dynlist > .add-item:focus-within,
.control-group:is(:has(> .cbi-input-password)):focus-within {
@@ -116,17 +116,18 @@
padding-inline-start: calc(var(--fs-space-3) - 3px);
}
/* the toggle switch is a bare rounded box in forced colors — give the knob and
* the track an edge so on/off is distinguishable at all. Scoped via `label[for]`, matching
* 60-inputs.css: a bare `.cbi-checkbox > label` also reaches ui.Checkbox's `tooltip` label
* (the hint-icon one, unstyled otherwise, and built with no `for` attribute), which would
* grow the same border a switch does not own. */
.cbi-checkbox > label[for] { border: 1px solid ButtonBorder; }
.cbi-checkbox > input[type="checkbox"]:checked + label {
* the track an edge so on/off is distinguishable at all. Scoped via `input[type="checkbox"]`,
* matching 60-inputs.css, which now draws the switch there: an `<input>` selector cannot
* reach ui.Checkbox's `tooltip` label (the hint-icon one, unstyled otherwise, built with no
* `for` attribute) by construction, so the old `[for]` discriminator is no longer needed to
* keep that label from growing a border it does not own. */
.cbi-checkbox > input[type="checkbox"] { border: 1px solid ButtonBorder; }
.cbi-checkbox > input[type="checkbox"]:checked {
background: Highlight;
forced-color-adjust: none;
}
.cbi-checkbox > label[for]::after { background: ButtonBorder; }
.cbi-checkbox > input[type="checkbox"]:checked + label::after { background: HighlightText; }
.cbi-checkbox > input[type="checkbox"]::after { background: ButtonBorder; }
.cbi-checkbox > input[type="checkbox"]:checked::after { background: HighlightText; }
/* icon-only buttons (rail toggle, search/appearance, logout) lose their hover fill */
.fs-railtoggle, .fs-themerow { border-color: ButtonBorder; }
/* the focus ring is a box-shadow everywhere in this theme, and box-shadow is
+2 -2
View File
@@ -5,12 +5,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=rtl8189es
PKG_RELEASE:=5
PKG_RELEASE:=6
PKG_SOURCE_URL:=https://github.com/jwrdegoede/rtl8189ES_linux.git
PKG_SOURCE_PROTO:=git
PKG_SOURCE_DATE:=2025-09-27
PKG_SOURCE_VERSION:=f4712611578312fcd67795a0d43d78eb70378238
PKG_SOURCE_VERSION:=0a5d04114fac3c9f48a343cb905fbb6a3f9f5df5
PKG_MIRROR_HASH:=skip
PKG_BUILD_PARALLEL:=1