🛸 Sync 2026-08-18 08:57:58

This commit is contained in:
github-actions[bot] committed 2026-08-18 08:57:58 +08:00
1 parent f3ba5f7b5d
commit 3e76c2c768
56 files changed
+8850 -2138

No files matched your search

+2 -2
View File
@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=amule
PKG_VERSION:=2.3.3
PKG_RELEASE:=3
PKG_VERSION:=3.0.0
PKG_RELEASE:=4
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/amule-project/amule/tar.gz/$(PKG_VERSION)?
+2 -2
View File
@@ -5,12 +5,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=antileech
PKG_RELEASE:=2
PKG_RELEASE:=3
PKG_SOURCE_PROTO:=git
PKG_SOURCE_DATE:=2021-12-05
PKG_SOURCE_URL:=https://github.com/persmule/amule-dlp.antiLeech.git
PKG_SOURCE_VERSION:=null
PKG_SOURCE_VERSION:=1b14ed828ed227933333de31ffb8e24e6c10451c
PKG_MIRROR_HASH:=skip
PKG_FIXUP:=autoreconf
+2 -2
View File
@@ -10,8 +10,8 @@ include $(TOPDIR)/rules.mk
PKG_ARCH_BAIDUDRIVE:=$(ARCH)
PKG_NAME:=baidudrive
PKG_VERSION:=1.0.4
PKG_RELEASE:=11
PKG_VERSION:=linkeasefull-runtime-v3.0.9
PKG_RELEASE:=12
PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/baidudrive-runtime-v$(PKG_VERSION)/
PKG_HASH:=skip
+2 -2
View File
@@ -6,8 +6,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=BaiduPCS-Go
PKG_VERSION:=3.9.0
PKG_RELEASE:=6
PKG_VERSION:=4.0.1
PKG_RELEASE:=7
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/qjfoidnh/BaiduPCS-Go/tar.gz/v$(PKG_VERSION)?
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=beszel-agent
PKG_VERSION:=0.18.7
PKG_RELEASE:=7
PKG_VERSION:=0.18.8
PKG_RELEASE:=8
PKG_SOURCE:=$(PKG_NAME)_linux_$(or $(BESZEL_GOARCH),arm64).tar.gz
PKG_SOURCE_URL:=https://github.com/henrygd/beszel/releases/download/v$(PKG_VERSION)/
+2 -2
View File
@@ -1,12 +1,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=boltbrowser
PKG_RELEASE:=7
PKG_RELEASE:=8
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/br0xen/boltbrowser.git
PKG_SOURCE_DATE:=2023-05-31
PKG_SOURCE_VERSION:=null
PKG_SOURCE_VERSION:=6ad3511ddb3c271fbaed2f22e409aa453ed89042
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-3.0-or-later
+2 -2
View File
@@ -10,8 +10,8 @@ LUCI_DEPENDS:=+luci-base +luci-lib-jsonc +curl +bandix
PKG_MAINTAINER:=timsaya
PKG_VERSION:=0.12.9
PKG_RELEASE:=8
PKG_VERSION:=0.12.10
PKG_RELEASE:=9
include $(TOPDIR)/feeds/luci/luci.mk
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

@@ -673,46 +673,6 @@ return view.extend({
return false;
};
// 支持作者(基本设置的最后一项)
var alipayImage = L.resource('bandix/donate/alipay.jpg');
var wechatImage = L.resource('bandix/donate/wechat-pay.png');
function donationCard(title, imageUrl, altText, dotClass) {
return E('div', { 'class': 'bandix-donation-card' }, [
E('h4', { 'class': 'bandix-donation-card-title' }, [
E('span', { 'class': 'bandix-donation-dot ' + dotClass }),
title
]),
E('a', {
'class': 'bandix-donation-image-link',
'href': imageUrl,
'target': '_blank',
'rel': 'noopener noreferrer',
'title': altText
}, [
E('img', {
'class': 'bandix-donation-image',
'src': imageUrl,
'alt': altText,
'loading': 'lazy'
})
])
]);
}
o = s.option(form.DummyValue, '_donation_qr_codes', _('Support the Author'));
o.renderWidget = function () {
return E('div', { 'class': 'bandix-donation-content' }, [
E('p', { 'class': 'bandix-donation-description' },
_('If Bandix is useful to you, you can support its continued development.')),
E('div', { 'class': 'bandix-donation-grid' }, [
donationCard(_('Alipay'), alipayImage, _('Scan with Alipay to support'), 'bandix-donation-dot-alipay'),
donationCard(_('WeChat Pay'), wechatImage, _('Scan with WeChat Pay to support'), 'bandix-donation-dot-wechat')
]),
E('p', { 'class': 'bandix-donation-note' }, _('Donations are voluntary. Thank you for supporting Bandix.'))
]);
};
// 2. 流量监控设置部分 (traffic)
s = m.section(form.NamedSection, 'traffic', 'traffic', _('Traffic Monitor Settings'));
s.description = _('Configure traffic monitoring related parameters');
@@ -866,98 +826,6 @@ return view.extend({
// 将 view 实例关联到 form map,以便在 cfgvalue 中访问
m.view = this;
return m.render().then(function (mapEl) {
var oldStyle = document.getElementById('bandix-donation-styles');
if (oldStyle && oldStyle.parentNode) oldStyle.parentNode.removeChild(oldStyle);
document.head.appendChild(E('style', { 'id': 'bandix-donation-styles' }, `
.bandix-donation-content {
text-align: center;
}
.bandix-donation-description {
margin: 0 auto 18px;
max-width: 620px;
opacity: 0.72;
line-height: 1.6;
}
.bandix-donation-grid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 280px));
justify-content: center;
gap: 20px;
}
.bandix-donation-card {
padding: 12px;
border: 1px solid rgba(107, 114, 128, 0.3);
border-radius: 10px;
background: rgba(107, 114, 128, 0.04);
box-sizing: border-box;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.bandix-donation-card:hover {
border-color: rgba(59, 130, 246, 0.55);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
transform: translateY(-2px);
}
.bandix-donation-card-title {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
margin: 0 0 10px;
font-size: 1rem;
font-weight: 600;
}
.bandix-donation-dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex-shrink: 0;
}
.bandix-donation-dot-alipay {
background: #1677ff;
}
.bandix-donation-dot-wechat {
background: #07c160;
}
.bandix-donation-image-link {
display: block;
border-radius: 7px;
overflow: hidden;
background: #ffffff;
line-height: 0;
}
.bandix-donation-image {
display: block;
width: 100%;
height: auto;
}
.bandix-donation-note {
margin: 14px 0 0;
font-size: 0.75rem;
opacity: 0.58;
}
@media (max-width: 600px) {
.bandix-donation-grid {
grid-template-columns: minmax(200px, 280px);
gap: 14px;
}
}
`));
return mapEl;
});
return m.render();
}
});
-21
View File
@@ -1115,27 +1115,6 @@ msgstr "Hoy"
msgid "Yesterday"
msgstr "Ayer"
msgid "Support the Author"
msgstr "Apoyar al autor"
msgid "If Bandix is useful to you, you can support its continued development."
msgstr "Si Bandix te resulta útil, puedes apoyar su desarrollo continuo."
msgid "Alipay"
msgstr "Alipay"
msgid "Scan with Alipay to support"
msgstr "Escanea con Alipay para apoyar"
msgid "WeChat Pay"
msgstr "WeChat Pay"
msgid "Scan with WeChat Pay to support"
msgstr "Escanea con WeChat Pay para apoyar"
msgid "Donations are voluntary. Thank you for supporting Bandix."
msgstr "Las donaciones son voluntarias. Gracias por apoyar a Bandix."
msgid "This Week"
msgstr "Esta Semana"
-21
View File
@@ -1115,27 +1115,6 @@ msgstr "Aujourd'hui"
msgid "Yesterday"
msgstr "Hier"
msgid "Support the Author"
msgstr "Soutenir l’auteur"
msgid "If Bandix is useful to you, you can support its continued development."
msgstr "Si Bandix vous est utile, vous pouvez soutenir la poursuite de son développement."
msgid "Alipay"
msgstr "Alipay"
msgid "Scan with Alipay to support"
msgstr "Scannez avec Alipay pour nous soutenir"
msgid "WeChat Pay"
msgstr "WeChat Pay"
msgid "Scan with WeChat Pay to support"
msgstr "Scannez avec WeChat Pay pour nous soutenir"
msgid "Donations are voluntary. Thank you for supporting Bandix."
msgstr "Les dons sont facultatifs. Merci de soutenir Bandix."
msgid "This Week"
msgstr "Cette Semaine"
-21
View File
@@ -1115,27 +1115,6 @@ msgstr "今日"
msgid "Yesterday"
msgstr "昨日"
msgid "Support the Author"
msgstr "作者を支援"
msgid "If Bandix is useful to you, you can support its continued development."
msgstr "Bandix が役に立った場合は、継続的な開発をご支援いただけます。"
msgid "Alipay"
msgstr "Alipay"
msgid "Scan with Alipay to support"
msgstr "Alipay でスキャンして支援"
msgid "WeChat Pay"
msgstr "WeChat Pay"
msgid "Scan with WeChat Pay to support"
msgstr "WeChat Pay でスキャンして支援"
msgid "Donations are voluntary. Thank you for supporting Bandix."
msgstr "寄付は任意です。Bandix をご支援いただきありがとうございます。"
msgid "This Week"
msgstr "今週"
-21
View File
@@ -1115,27 +1115,6 @@ msgstr "Dzisiaj"
msgid "Yesterday"
msgstr "Wczoraj"
msgid "Support the Author"
msgstr "Wesprzyj autora"
msgid "If Bandix is useful to you, you can support its continued development."
msgstr "Jeśli Bandix jest dla Ciebie przydatny, możesz wesprzeć jego dalszy rozwój."
msgid "Alipay"
msgstr "Alipay"
msgid "Scan with Alipay to support"
msgstr "Zeskanuj w Alipay, aby wesprzeć"
msgid "WeChat Pay"
msgstr "WeChat Pay"
msgid "Scan with WeChat Pay to support"
msgstr "Zeskanuj w WeChat Pay, aby wesprzeć"
msgid "Donations are voluntary. Thank you for supporting Bandix."
msgstr "Darowizny są dobrowolne. Dziękujemy za wsparcie Bandix."
msgid "This Week"
msgstr "Ten Tydzień"
-21
View File
@@ -1115,27 +1115,6 @@ msgstr "Сегодня"
msgid "Yesterday"
msgstr "Вчера"
msgid "Support the Author"
msgstr "Поддержать автора"
msgid "If Bandix is useful to you, you can support its continued development."
msgstr "Если Bandix полезен вам, вы можете поддержать его дальнейшую разработку."
msgid "Alipay"
msgstr "Alipay"
msgid "Scan with Alipay to support"
msgstr "Отсканируйте в Alipay, чтобы поддержать"
msgid "WeChat Pay"
msgstr "WeChat Pay"
msgid "Scan with WeChat Pay to support"
msgstr "Отсканируйте в WeChat Pay, чтобы поддержать"
msgid "Donations are voluntary. Thank you for supporting Bandix."
msgstr "Пожертвования добровольны. Спасибо за поддержку Bandix."
msgid "This Week"
msgstr "На этой неделе"
-21
View File
@@ -1118,27 +1118,6 @@ msgstr "今天"
msgid "Yesterday"
msgstr "昨天"
msgid "Support the Author"
msgstr "支持作者"
msgid "If Bandix is useful to you, you can support its continued development."
msgstr "如果 Bandix 对您有帮助,您可以支持项目的持续开发。"
msgid "Alipay"
msgstr "支付宝"
msgid "Scan with Alipay to support"
msgstr "使用支付宝扫码支持"
msgid "WeChat Pay"
msgstr "微信支付"
msgid "Scan with WeChat Pay to support"
msgstr "使用微信支付扫码支持"
msgid "Donations are voluntary. Thank you for supporting Bandix."
msgstr "赞助完全自愿。感谢您对 Bandix 的支持。"
msgid "This Week"
msgstr "本周"
-21
View File
@@ -1115,27 +1115,6 @@ msgstr "今天"
msgid "Yesterday"
msgstr "昨天"
msgid "Support the Author"
msgstr "支持作者"
msgid "If Bandix is useful to you, you can support its continued development."
msgstr "如果 Bandix 對您有幫助,您可以支持專案的持續開發。"
msgid "Alipay"
msgstr "支付寶"
msgid "Scan with Alipay to support"
msgstr "使用支付寶掃碼支持"
msgid "WeChat Pay"
msgstr "微信支付"
msgid "Scan with WeChat Pay to support"
msgstr "使用微信支付掃碼支持"
msgid "Donations are voluntary. Thank you for supporting Bandix."
msgstr "贊助完全自願。感謝您對 Bandix 的支持。"
msgid "This Week"
msgstr "本週"
@@ -0,0 +1,93 @@
/* ── CSS custom properties (same palette as wanstatus.css, duplicated so
this page doesn't depend on wanstatus.css having been loaded first) ── */
:root {
--diag-bg: #ffffff;
--diag-border: #e2e6ea;
--diag-text: #1a2332;
--diag-muted: #6b7a8d;
--diag-ok: #22c55e;
--diag-warn: #f59e0b;
--diag-err: #ef4444;
--diag-ok-bg: #f0fdf4;
--diag-warn-bg: #fffbeb;
--diag-err-bg: #fef2f2;
--diag-ok-border: #bbf7d0;
--diag-warn-border:#fde68a;
--diag-err-border: #fecaca;
--diag-radius: 10px;
}
.omrdiag-summary {
display: flex;
flex-direction: column;
gap: 10px;
margin: 10px 0 20px 0;
}
.omrdiag-issue {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 14px;
border-radius: var(--diag-radius);
border: 1px solid var(--diag-border);
background: var(--diag-bg);
}
.omrdiag-issue.ok { border-color: var(--diag-ok-border); background: var(--diag-ok-bg); }
.omrdiag-issue.warning { border-color: var(--diag-warn-border); background: var(--diag-warn-bg); }
.omrdiag-issue.error { border-color: var(--diag-err-border); background: var(--diag-err-bg); }
.omrdiag-issue .omrdiag-dot {
flex: 0 0 10px;
width: 10px;
height: 10px;
border-radius: 50%;
margin-top: 5px;
background: var(--diag-muted);
}
.omrdiag-issue.ok .omrdiag-dot { background: var(--diag-ok); }
.omrdiag-issue.warning .omrdiag-dot { background: var(--diag-warn); }
.omrdiag-issue.error .omrdiag-dot { background: var(--diag-err); }
.omrdiag-issue .omrdiag-body { flex: 1; min-width: 0; }
.omrdiag-issue .omrdiag-message { font-weight: 700; color: var(--diag-text); }
.omrdiag-issue .omrdiag-detail { font-size: 12px; color: var(--diag-muted); margin-top: 3px; line-height: 1.5; }
.omrdiag-wan-table,
.omrdiag-counters-table {
border-collapse: collapse;
width: 100%;
margin: 8px 0 18px 0;
}
.omrdiag-wan-table th,
.omrdiag-wan-table td,
.omrdiag-counters-table th,
.omrdiag-counters-table td {
border: 1px solid var(--diag-border);
padding: 6px 10px;
text-align: left;
font-size: 12.5px;
}
.omrdiag-wan-table th,
.omrdiag-counters-table th {
background: rgba(0,0,0,.03);
font-weight: 700;
}
.omrdiag-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
.omrdiag-badge.ok { background: var(--diag-ok-bg); color: #15803d; border: 1px solid var(--diag-ok-border); }
.omrdiag-badge.warning { background: var(--diag-warn-bg); color: #b45309; border: 1px solid var(--diag-warn-border); }
.omrdiag-badge.error { background: var(--diag-err-bg); color: #b91c1c; border: 1px solid var(--diag-err-border); }
.omrdiag-counters-table td.omrdiag-nonzero { font-weight: 700; color: #b45309; }
.omrdiag-updated {
font-size: 11px;
color: var(--diag-muted);
margin-bottom: 6px;
}
@@ -0,0 +1,305 @@
'use strict';
'require view';
'require poll';
'require rpc';
var callDiagnose = rpc.declare({
object: 'luci.mptcp',
method: 'diagnose',
expect: { '': {} }
});
var COUNTER_LABELS = {
/* Initial MP_CAPABLE handshake (does this connection become MPTCP at all?) */
MPCapableSYNTX: _('MPTCP handshake attempts sent (SYN)'),
MPCapableSYNRX: _('MPTCP handshake attempts received (SYN)'),
MPCapableSYNACKRX: _('MPTCP handshake accepted by peer (SYN/ACK)'),
MPCapableACKRX: _('MPTCP handshake completed (ACK)'),
MPCapableFallbackSYNACK: _('Fallback to TCP (SYN/ACK stripped)'),
MPCapableFallbackACK: _('Fallback to TCP (ACK stripped)'),
MPCapableSYNTXDrop: _('MPTCP handshake dropped client-side, fell back to TCP'),
MPCapableSYNTXDisabled: _('MPTCP handshake disabled client-side after past issues'),
MPCapableEndpAttempt: _('MPTCP rejected on a port-only endpoint'),
MPFallbackTokenInit: _('Could not allocate an MPTCP token, fell back to TCP'),
/* MP_JOIN handshake (does a WAN succeed in adding a subflow?) */
MPJoinSynTx: _('Subflow JOIN attempts sent (SYN)'),
MPJoinSynTxCreatSkErr: _('Subflow JOIN failed: could not create socket'),
MPJoinSynTxBindErr: _('Subflow JOIN failed: could not bind address'),
MPJoinSynTxConnectErr: _('Subflow JOIN failed: could not connect'),
MPJoinSynRx: _('Subflow JOIN attempts received (SYN)'),
MPJoinSynBackupRx: _('Subflow JOIN attempts received (SYN, backup)'),
MPJoinSynAckRx: _('Subflow JOIN accepted by peer (SYN/ACK)'),
MPJoinSynAckBackupRx: _('Subflow JOIN accepted by peer (SYN/ACK, backup)'),
MPJoinSynAckHMacFailure: _('JOIN SYN/ACK authentication failures'),
MPJoinAckRx: _('Subflow JOIN completed (ACK)'),
MPJoinAckHMacFailure: _('JOIN ACK authentication failures'),
MPJoinNoTokenFound: _('JOIN attempts with unknown token'),
MPJoinRejected: _('JOIN request rejected by the path manager'),
MPJoinPortSynRx: _('Subflow JOIN SYN received on a different port'),
MPJoinPortSynAckRx: _('Subflow JOIN SYN/ACK received on a different port'),
MPJoinPortAckRx: _('Subflow JOIN ACK received on a different port'),
MismatchPortSynRx: _('Subflow JOIN SYN with mismatched port'),
MismatchPortAckRx: _('Subflow JOIN ACK with mismatched port'),
/* Data-sequence mapping / data path integrity */
MPTCPRetrans: _('MPTCP-level retransmissions'),
DSSNotMatching: _('Data sequence mapping did not match the previous one'),
DSSCorruptionFallback: _('DSS corruption detected, fell back to plain TCP'),
DSSCorruptionReset: _('DSS corruption detected, subflow reset'),
DSSNoMatchTCP: _('Data sequence mapping did not match TCP sequence numbers'),
InfiniteMapTx: _('Infinite mapping sent (fell back to plain TCP semantics)'),
InfiniteMapRx: _('Infinite mapping received (peer fell back to plain TCP semantics)'),
DataCsumErr: _('Data checksum errors'),
NoDSSInWindow: _('Segments received outside the MPTCP mapping window'),
DuplicateData: _('Duplicate data segments discarded'),
OFOQueueTail: _('Out-of-order segments queued at tail'),
OFOQueue: _('Out-of-order segments queued'),
OFOMerge: _('Out-of-order segments merged'),
/* Subflow lifecycle: stale/blackhole detection and removal */
Blackhole: _('Blackhole detected on the path'),
SubflowStale: _('Subflows marked stale (possible blackhole)'),
SubflowRecover: _('Stale subflows recovered'),
RmSubflow: _('Subflows removed'),
/* ADD_ADDR / RM_ADDR signalling (extra WAN addresses advertised to the peer) */
AddAddr: _('ADD_ADDR signals received'),
AddAddrTx: _('ADD_ADDR signals sent'),
AddAddrTxDrop: _('ADD_ADDR signals failed to send'),
AddAddrDrop: _('ADD_ADDR signals dropped'),
EchoAdd: _('ADD_ADDR echo received'),
EchoAddTx: _('ADD_ADDR echo sent'),
EchoAddTxDrop: _('ADD_ADDR echo failed to send'),
PortAdd: _('ADD_ADDR received with a port number'),
RmAddr: _('RM_ADDR signals received'),
RmAddrTx: _('RM_ADDR signals sent'),
RmAddrTxDrop: _('RM_ADDR signals failed to send'),
RmAddrDrop: _('RM_ADDR signals dropped'),
/* Subflow priority (MP_PRIO: backup/non-backup flag changes) */
MPPrioTx: _('Subflow priority change sent (MP_PRIO)'),
MPPrioRx: _('Subflow priority change received (MP_PRIO)'),
/* Connection teardown */
MPFailTx: _('Data mapping failures sent (MP_FAIL)'),
MPFailRx: _('Data mapping failures received (MP_FAIL)'),
MPFastcloseTx: _('Fastclose sent'),
MPFastcloseRx: _('Fastclose received'),
MPRstTx: _('MPTCP resets sent'),
MPRstRx: _('MPTCP resets received'),
/* Receive-window sharing across subflows */
SndWndShared: _('Send window shared across subflows'),
RcvWndShared: _('Receive window shared across subflows'),
RcvWndConflictUpdate: _('Receive window conflict resolved on update'),
RcvWndConflict: _('Receive window update conflicts'),
/* Misc: whole-connection fallback reasons, gauges */
MPCurrEstab: _('Established MPTCP connections (current)'),
MPCapableDataFallback: _('Fell back to TCP: missing data on first established packet'),
MD5SigFallback: _('Fell back to TCP: conflicting TCP MD5SIG option'),
DssFallback: _('Fell back to TCP: bad or missing data sequence signal'),
SimultConnectFallback: _('Fell back to TCP: simultaneous connect'),
FallbackFailed: _("Couldn't fall back to TCP (connection state didn't allow it)"),
WinProbe: _('MPTCP-level zero-window probes')
};
return view.extend({
load: function() {
return callDiagnose().catch(function() { return null; });
},
pollData: function(container) {
poll.add(L.bind(function() {
return callDiagnose().then(L.bind(function(data) {
this.renderAll(container, data);
}, this)).catch(function() {
/* Ignore transient poll errors -- keep showing the last good snapshot */
});
}, this), 15);
},
render: function(initialData) {
if (!document.querySelector('link[href*="mptcp_diagnostics.css"]')) {
var link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = L.resource('mptcp/css/mptcp_diagnostics.css');
document.head.appendChild(link);
}
var container = E('div', { 'id': 'omrdiag-container' }, [
E('img', { 'src': L.resource('spinner.gif') })
]);
var seeAlso = E('div', { 'class': 'cbi-section' }, [
E('legend', {}, [ _('Related pages') ]),
E('ul', {}, [
E('li', {}, [ E('a', { 'href': L.url('admin/system/openmptcprouter/status') }, [ _('OpenMPTCProuter Network overview / Status') ]) ]),
E('li', {}, [ E('a', { 'href': L.url('admin/network/mptcp/mptcp') }, [ _('MPTCP Settings') ]) ]),
E('li', {}, [ E('a', { 'href': L.url('admin/network/mptcp/mptcp_fullmesh') }, [ _('MPTCP Fullmesh (subflow map)') ]) ]),
E('li', {}, [ E('a', { 'href': L.url('admin/network/mptcp/mptcp_connections') }, [ _('MPTCP Established connections') ]) ]),
E('li', {}, [ E('a', { 'href': L.url('admin/network/mptcp/mptcp_monitor') }, [ _('MPTCP monitoring (raw kernel counters)') ]) ])
])
]);
var page = E('div', {}, [
E('h2', {}, [ _('MPTCP Diagnostics') ]),
E('div', { 'class': 'cbi-map-descr' }, [
_('Helps explain why MPTCP might not be aggregating your WANs correctly: fallback to plain TCP, ' +
'blackholed/stale subflows, resets, checksum errors, and WANs that never got an MPTCP endpoint ' +
'registered at all. Refreshes automatically every 15 seconds.')
]),
container,
seeAlso
]);
this.renderAll(container, initialData);
this.pollData(container);
return page;
},
renderAll: function(container, data) {
if (!container) return;
var d = data || {};
container.textContent = '';
container.appendChild(E('div', { 'class': 'omrdiag-updated' },
[ _('Last updated:') + ' ' + (new Date()).toLocaleTimeString() ]));
container.appendChild(this.renderIssues(d.issues || []));
container.appendChild(this.renderWanTable(d.wans || []));
container.appendChild(this.renderSettings(d.settings || {}, d.limits || {}, d.established_count));
container.appendChild(this.renderCounters(d.counters || {}));
},
renderIssues: function(issues) {
var box = E('div', { 'class': 'omrdiag-summary' });
if (!issues.length) {
box.appendChild(E('div', { 'class': 'omrdiag-issue' }, [
E('div', { 'class': 'omrdiag-dot' }),
E('div', { 'class': 'omrdiag-body' }, [
E('div', { 'class': 'omrdiag-message' }, [ _('No diagnostic data available yet.') ])
])
]));
return box;
}
issues.forEach(function(issue) {
var sev = issue.severity || 'warning';
box.appendChild(E('div', { 'class': 'omrdiag-issue ' + sev }, [
E('div', { 'class': 'omrdiag-dot' }),
E('div', { 'class': 'omrdiag-body' }, [
E('div', { 'class': 'omrdiag-message' }, [ issue.message || '' ]),
issue.detail ? E('div', { 'class': 'omrdiag-detail' }, [ issue.detail ]) : ''
])
]));
});
return box;
},
renderWanTable: function(wans) {
var wrap = E('div', {}, [ E('h3', {}, [ _('Per-WAN MPTCP endpoint status') ]) ]);
if (!wans.length) {
wrap.appendChild(E('p', {}, [ _('No multipath-enabled WAN interfaces found.') ]));
return wrap;
}
var table = E('table', { 'class': 'omrdiag-wan-table' }, [
E('tr', {}, [
E('th', {}, [ _('WAN') ]),
E('th', {}, [ _('Device') ]),
E('th', {}, [ _('Mode') ]),
E('th', {}, [ _('MPTCP endpoint') ]),
E('th', {}, [ _('Endpoint flags') ])
])
]);
wans.forEach(function(wan) {
var ok = !!wan.has_endpoint;
table.appendChild(E('tr', {}, [
E('td', {}, [ wan.name || '' ]),
E('td', {}, [ wan.device || '-' ]),
E('td', {}, [ wan.multipath || 'off' ]),
E('td', {}, [
E('span', { 'class': 'omrdiag-badge ' + (ok ? 'ok' : 'error') },
[ ok ? _('registered') : _('missing') ])
]),
E('td', {}, [ wan.endpoint_flags || '-' ])
]));
});
wrap.appendChild(table);
return wrap;
},
renderSettings: function(settings, limits, establishedCount) {
// "1"/"0" mean enabled/disabled for the enabled and checksum sysctls --
// but NOT for path_manager: on the in-kernel MPTCP sysctl layout,
// net.mptcp.pm_type is a selector between two path manager
// *implementations* (0 = in-kernel, 1 = userspace/mptcpd), not an
// on/off switch -- "0" here is the normal default, not "disabled".
// On the legacy out-of-tree sysctl layout path_manager is already a
// name (e.g. "fullmesh"), not a 0/1 code, so leave those untouched.
var formatBool = function(v) {
if (v === '1') return _('Enabled') + ' (1)';
if (v === '0') return _('Disabled') + ' (0)';
return v;
};
var formatPathManager = function(v) {
if (v === '0') return _('In-kernel') + ' (0)';
if (v === '1') return _('Userspace') + ' (1)';
return v;
};
var rows = [
[ _('MPTCP enabled'), formatBool(settings.enabled) ],
[ _('Path manager'), formatPathManager(settings.path_manager) ],
[ _('Scheduler'), settings.scheduler ],
[ _('Checksum'), formatBool(settings.checksum) ],
[ _('Stale loss count threshold'), settings.stale_loss_cnt ],
[ _('Blackhole timeout (s)'), settings.blackhole_timeout ],
[ _('Add-addr timeout (s)'), settings.add_addr_timeout ],
[ _('Close timeout (s)'), settings.close_timeout ],
[ _('Subflow limit'), limits.subflows ],
[ _('Accepted ADD_ADDR limit'), limits.add_addr_accepted ],
[ _('Established MPTCP connections (right now)'), establishedCount ]
];
var wrap = E('div', {}, [ E('h3', {}, [ _('Kernel MPTCP configuration') ]) ]);
var table = E('table', { 'class': 'omrdiag-wan-table' });
rows.forEach(function(r) {
if (r[1] === undefined || r[1] === null || r[1] === '') return;
table.appendChild(E('tr', {}, [ E('th', {}, [ r[0] ]), E('td', {}, [ String(r[1]) ]) ]));
});
wrap.appendChild(table);
return wrap;
},
renderCounters: function(counters) {
var keys = Object.keys(counters || {});
var wrap = E('div', {}, [ E('h3', {}, [ _('MPTCP kernel counters (since boot)') ]) ]);
if (!keys.length) {
wrap.appendChild(E('p', {}, [ _('No counters available (nstat/multipath -m returned nothing).') ]));
return wrap;
}
keys.sort(function(a, b) {
var av = counters[a] || 0, bv = counters[b] || 0;
if (av !== bv) return bv - av;
return a < b ? -1 : (a > b ? 1 : 0);
});
var table = E('table', { 'class': 'omrdiag-counters-table' }, [
E('tr', {}, [ E('th', {}, [ _('Counter') ]), E('th', {}, [ _('Value') ]) ])
]);
keys.forEach(function(key) {
var value = counters[key] || 0;
var label = COUNTER_LABELS[key] ? (COUNTER_LABELS[key] + ' (' + key + ')') : key;
table.appendChild(E('tr', {}, [
E('td', {}, [ label ]),
E('td', { 'class': value > 0 ? 'omrdiag-nonzero' : '' }, [ String(value) ])
]));
});
wrap.appendChild(table);
return wrap;
},
handleSave: null,
handleSaveApply: null,
handleReset: null
});
@@ -41,6 +41,15 @@ function isEligibleIface(name) {
name !== 'omrvpn' && name !== 'OWVPN' && name !== 'omr6in4';
}
// Same "multipath actually turned on" whitelist as the bandwidth page's
// _getMultipathIfaces (multipath.js) and the rpcd backend's
// multipath_bandwidth: "off"/unset doesn't count.
function isMultipathEnabledIface(name) {
var multipath = uci.get('network', name, 'multipath');
return multipath === 'on' || multipath === 'master' ||
multipath === 'backup' || multipath === 'handover';
}
function fillIfaceOptions(option) {
option.value('', _('-- not pinned --'));
uci.sections('network', 'interface', function(ifc) {
@@ -140,7 +149,9 @@ return L.view.extend({
if (vpsSyncEnabled) {
o = s.option(form.ListValue, 'download_interface', _('Download interface'),
_('Gateway (VPS) → router. Only takes effect for MPTCP’s bpf_dscp scheduler (pins the VPS’s own send-side choice via dscp_remote_id) and can be a different WAN than Upload. mqvpn has no equivalent: its downlink always mirrors whichever WAN carries the class on upload, so this field has no effect on mqvpn.'));
_('Gateway (VPS) → router. Only takes effect for MPTCP’s bpf_dscp scheduler (pins the VPS’s own send-side choice via dscp_remote_id) and can be a different WAN than Upload.') + ' ' +
_('Leave empty to just mirror this row’s Upload interface to the gateway instead -- the same "download always follows upload" behavior mqvpn already has built in natively.') + ' ' +
_('mqvpn has no equivalent: its downlink always mirrors whichever WAN carries the class on upload, so this field has no effect on mqvpn.'));
o.load = function(section_id) {
fillIfaceOptions(this);
return this.super('load', [section_id]);
@@ -150,9 +161,10 @@ return L.view.extend({
if (showWeight) {
s = m.section(form.TypedSection, 'interface', _('WAN weight'),
_('Used by the *weight MPTCP schedulers (bpf_weight/bpf_weight_rr) and by mqvpn’s "Weighted RTT"/"Weighted Round Robin" schedulers -- same value, applies to both upload and download (mirrored to the gateway automatically). Unlike the DSCP pins above, weight is a single value per WAN, not per traffic class.'));
_('Used by the *weight MPTCP schedulers (bpf_weight/bpf_weight_rr) and by mqvpn’s "Weighted RTT"/"Weighted Round Robin" schedulers -- same value, applies to both upload and download (mirrored to the gateway automatically). Unlike the DSCP pins above, weight is a single value per WAN, not per traffic class.') + ' ' +
_('Only WANs with Multipath TCP enabled (the “Multipath TCP” option on the MPTCP page’s Interfaces Settings) are listed below.'));
s.filter = function(section) {
return isEligibleIface(section);
return isEligibleIface(section) && isMultipathEnabledIface(section);
};
o = s.option(form.Value, 'multipath_weight', _('Weight'),
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -51,6 +51,369 @@ local function bwc_rows(dev)
return {}
end
local IP_BIN = "/sbin/ip"
local function trim(s)
return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", ""))
end
local function read_proc(path)
local f = io.open(path, "r")
if not f then return nil end
local v = f:read("*l")
f:close()
if v == nil then return nil end
return trim(v)
end
-- First field of /proc/uptime (seconds since boot), used to turn raw
-- cumulative MPTcpExt counters into an "events/hour since boot" rate --
-- a bare cumulative count (e.g. "7021 resets") doesn't say whether that's
-- alarming or a trickle, since it doesn't say over what timespan.
local function uptime_seconds()
local raw = read_proc("/proc/uptime")
local secs = raw and raw:match("^(%S+)")
return secs and tonumber(secs) or nil
end
-- -------------------------------------------------------------------------
-- MPTCP Diagnostics: explain *why* MPTCP might not be aggregating WANs
-- correctly -- kernel-level MPTCP counters (fallback to plain TCP,
-- blackhole/stale-subflow detection, resets, checksum errors, JOIN
-- failures, ...), the live endpoint/limits state, and a per-WAN cross-check
-- of whether each multipath-enabled interface actually has a registered
-- MPTCP endpoint -- turned into a plain-English issue list.
--
-- All the underlying commands (multipath -k/-m/-c, ip mptcp ...) are
-- read-only/local and near-instant, so none of them are timeout-wrapped.
-- NOTE: never wrap "ip" in "timeout" here -- on this router's busybox
-- userland, "timeout N ip ..." has been seen to silently dispatch
-- busybox's own crippled "ip" applet instead of the real /sbin/ip, which
-- has no "mptcp" subcommand at all. Always call /sbin/ip directly.
-- -------------------------------------------------------------------------
-- Kernel MPTCP sysctl snapshot (works across the legacy out-of-tree sysctl
-- layout AND the current in-kernel net.mptcp.* layout -- whichever files
-- actually exist on this kernel).
function kernel_settings()
local settings = {}
settings.enabled = read_proc("/proc/sys/net/mptcp/mptcp_enabled")
or read_proc("/proc/sys/net/mptcp/enabled")
settings.checksum = read_proc("/proc/sys/net/mptcp/mptcp_checksum")
or read_proc("/proc/sys/net/mptcp/checksum_enabled")
settings.path_manager = read_proc("/proc/sys/net/mptcp/mptcp_path_manager")
or read_proc("/proc/sys/net/mptcp/pm_type")
settings.scheduler = read_proc("/proc/sys/net/mptcp/mptcp_scheduler")
or read_proc("/proc/sys/net/mptcp/scheduler")
settings.syn_retries = read_proc("/proc/sys/net/mptcp/mptcp_syn_retries")
settings.stale_loss_cnt = read_proc("/proc/sys/net/mptcp/stale_loss_cnt")
settings.add_addr_timeout = read_proc("/proc/sys/net/mptcp/add_addr_timeout")
settings.close_timeout = read_proc("/proc/sys/net/mptcp/close_timeout")
settings.blackhole_timeout = read_proc("/proc/sys/net/mptcp/blackhole_timeout")
settings.allow_join_initial_addr_port = read_proc("/proc/sys/net/mptcp/allow_join_initial_addr_port")
return settings
end
-- The kernel's MPTcpExt* MIB counters, either as the "nstat -z" layout
-- (name, value, rate -- one counter per line, what "multipath -m" prints)
-- or the SNMP-style two-line layout (a header line of field names followed
-- by a value line, both prefixed "MPTcpExt:", found in /proc/net/netstat
-- and the legacy /proc/net/mptcp_net/snmp). Parse both.
function parse_counters(raw)
local counters = {}
local header
for line in (raw or ""):gmatch("[^\n]+") do
-- The nstat-style counter name always continues with a letter right
-- after the "MPTcpExt" prefix (e.g. "MPTcpExtMPCapableSYNTX"); the
-- SNMP-style header/value lines start "MPTcpExt:" instead. Requiring
-- a letter here keeps the two forms from being confused with each
-- other (a bare "MPTcpExt:" line's second "word" can itself be a
-- number, which would otherwise wrongly match as a counter).
local name, value = line:match("^%s*(MPTcpExt%a%w*)%s+(%-?%d+)")
if name and value then
counters[name:gsub("^MPTcpExt", "")] = tonumber(value)
elseif line:match("^MPTcpExt:") then
-- SNMP-style: first "MPTcpExt:" line is field names, the next is values
if not header then
header = {}
for field in line:gmatch("%S+") do header[#header + 1] = field end
else
local i = 0
for field in line:gmatch("%S+") do
i = i + 1
local key = header[i]
if key and key ~= "MPTcpExt:" then
counters[key] = tonumber(field)
end
end
end
end
end
return counters
end
-- Where to read the MPTcpExt* counters from for Diagnostics. Deliberately
-- NOT "multipath -m" here: that wraps "nstat -z", and nstat (without -a)
-- reports the delta since the *previous* nstat invocation by anyone on the
-- box (it keeps a shared per-uid history file) -- confirmed live: two
-- consecutive calls a couple seconds apart came back ~0, while "nstat -a -z"
-- (ignore history) matched /proc/net/netstat exactly. A page that polls
-- every 15s and calls that would show "since last poll" numbers while
-- labelling them "since boot", and would race with anything else on the
-- router that also calls nstat. Reading the kernel's own procfs SNMP-style
-- counters directly sidesteps all of that -- always the true cumulative
-- count, no shared state with other callers.
function mptcp_counters_raw()
local raw = exec_output("cat /proc/net/mptcp_net/snmp 2>/dev/null")
if raw == "" then
raw = exec_output("grep '^MPTcpExt:' /proc/net/netstat 2>/dev/null")
end
return raw
end
-- "ip mptcp endpoint show": one line per registered endpoint, e.g.
-- 10.0.0.2 id 1 subflow dev eth0
-- 88.1.2.3 id 2 signal dev wwan0
-- 10.0.0.5 id 3 subflow backup fullmesh dev eth1
function parse_endpoints(raw)
local endpoints = {}
for line in (raw or ""):gmatch("[^\n]+") do
local id = line:match("id%s+(%d+)")
local dev = line:match("dev%s+(%S+)")
local addr = line:match("^%s*(%S+)")
if id and addr and addr ~= "id" then
endpoints[#endpoints + 1] = {
id = tonumber(id),
address = addr,
dev = dev or "",
signal = line:match("%f[%a]signal%f[%A]") ~= nil,
subflow = line:match("%f[%a]subflow%f[%A]") ~= nil,
backup = line:match("%f[%a]backup%f[%A]") ~= nil,
fullmesh = line:match("%f[%a]fullmesh%f[%A]") ~= nil,
}
end
end
return endpoints
end
-- "ip mptcp limits show" -> "add_addr_accepted 8 subflows 8"
function parse_limits(raw)
local limits = {}
for key, value in (raw or ""):gmatch("(%a[%w_]*)%s+(%d+)") do
limits[key] = tonumber(value)
end
return limits
end
-- Per-WAN endpoint cross-check: for every interface the router considers
-- multipath-enabled, is its device actually registered as an MPTCP
-- endpoint? If not, that WAN can never carry an MPTCP subflow no matter
-- what the schedulers/counters say.
function wan_endpoint_status(endpoints)
local wans = {}
uci:foreach("network", "interface", function(s)
local name = s[".name"]
if name == "loopback" then return end
local multipath = s["multipath"]
if multipath == nil or multipath == "" then
multipath = uci:get("openmptcprouter", name, "multipath")
end
multipath = multipath or "off"
if multipath == "off" then return end
local dev = get_device(name)
if dev == "" then dev = s["device"] or s["ifname"] or "" end
local has_endpoint = false
local endpoint_flags = ""
for _, ep in ipairs(endpoints) do
if dev ~= "" and ep.dev == dev then
has_endpoint = true
local flags = {}
if ep.signal then flags[#flags + 1] = "signal" end
if ep.subflow then flags[#flags + 1] = "subflow" end
if ep.backup then flags[#flags + 1] = "backup" end
if ep.fullmesh then flags[#flags + 1] = "fullmesh" end
endpoint_flags = table.concat(flags, ",")
break
end
end
wans[#wans + 1] = {
name = name,
device = dev,
multipath = multipath,
has_endpoint = has_endpoint,
endpoint_flags = endpoint_flags,
}
end)
return wans
end
-- Turn everything above into a plain-English issue list.
local function add_issue(issues, severity, code, message, detail)
issues[#issues + 1] = {
severity = severity,
code = code,
message = message,
detail = detail or "",
}
end
function build_issues(settings, counters, wans)
local issues = {}
if settings.enabled == "0" then
add_issue(issues, "error", "mptcp_disabled",
"MPTCP is disabled in the kernel.",
"No connection can use more than one WAN until MPTCP is re-enabled.")
end
for _, wan in ipairs(wans) do
if wan.device == "" then
add_issue(issues, "warning", "wan_no_device",
string.format("%s: multipath is enabled but the interface has no device yet.", wan.name),
"The interface may still be coming up (no IP/link yet).")
elseif not wan.has_endpoint then
add_issue(issues, "error", "wan_no_endpoint",
string.format("%s (%s): no MPTCP endpoint registered for this WAN.", wan.name, wan.device),
"This WAN will never be used for an MPTCP subflow, regardless of scheduler/weight settings. " ..
"Usually fixed by a hotplug/interface restart (which re-runs 'multipath <dev> on|backup'), " ..
"or by checking that the interface actually has a global (non-private-only, non-CGNAT) IP.")
end
end
local fallback = (counters.MPCapableFallbackSYNACK or 0) + (counters.MPCapableFallbackACK or 0)
if fallback > 0 then
add_issue(issues, "warning", "mptcp_fallback",
string.format("MPTCP fell back to plain TCP %d time(s).", fallback),
"A device on the path (firewall/middlebox/some ISP CGNAT boxes) is stripping the MPTCP TCP option " ..
"from the SYN or SYN/ACK, so the kernel gave up and used regular single-path TCP for those " ..
"connections. Use the MPTCP Support Check page (tracebox) per-WAN to confirm which WAN(s) strip it.")
end
local stale, recover = counters.SubflowStale or 0, counters.SubflowRecover or 0
if stale > 0 then
local stuck = stale - recover
if stuck > 0 then
add_issue(issues, "error", "mptcp_blackhole",
string.format("%d subflow(s) currently marked stale (possible blackhole) out of %d total.", stuck, stale),
"A path stopped acknowledging data for one or more subflows; the kernel is avoiding it. " ..
"If this keeps climbing on one specific WAN, that WAN's path is likely being blackholed.")
else
add_issue(issues, "warning", "mptcp_blackhole_recovered",
string.format("%d subflow(s) were marked stale but all recovered.", stale),
"Transient -- a path briefly stopped acking data and the kernel recovered it automatically.")
end
end
local hmac_fail = (counters.MPJoinSynAckHMacFailure or 0) + (counters.MPJoinAckHMacFailure or 0)
if hmac_fail > 0 then
add_issue(issues, "warning", "mptcp_join_hmac_failure",
string.format("%d MPTCP JOIN handshake authentication failure(s).", hmac_fail),
"A new subflow's JOIN handshake failed HMAC verification -- either packets were altered in " ..
"transit or the two ends disagree on the connection's key/token.")
end
if (counters.MPJoinNoTokenFound or 0) > 0 then
add_issue(issues, "warning", "mptcp_join_no_token",
string.format("%d subflow JOIN attempt(s) referenced an unknown token.", counters.MPJoinNoTokenFound),
"The peer (usually the VPS) no longer recognizes the MPTCP connection -- often because its proxy " ..
"process restarted and lost state. A new subflow can't attach to a connection the peer forgot.")
end
if (counters.DataCsumErr or 0) > 0 then
add_issue(issues, "warning", "mptcp_checksum_error",
string.format("%d MPTCP data checksum error(s).", counters.DataCsumErr),
"Check that 'mptcp_checksum' matches on both the router and the VPS -- a mismatch (or a path " ..
"that corrupts data) triggers this.")
end
-- Split by direction, not just summed: which side is doing the closing
-- matters. Confirmed live on the bench (2026-08-16) -- a steady climb
-- here traced back to OMR's own per-WAN shadowsocks health-check loop
-- (omr-tracker-ss) opening a fresh MPTCP connection through the
-- mptcp-enabled sslocal proxy every ~10s and closing it abortively, NOT
-- to path interference or the VPS dropping the tunnel -- confirmed by
-- MPRstRx staying ~0 while MPRstTx climbed, and by reproducing an
-- MPRstTx increment live by firing that exact health-check request.
local reset_tx = (counters.MPRstTx or 0) + (counters.MPFastcloseTx or 0)
local reset_rx = (counters.MPRstRx or 0) + (counters.MPFastcloseRx or 0)
local resets = reset_tx + reset_rx
if resets > 0 then
local rate_suffix = ""
local uptime = uptime_seconds()
if uptime and uptime >= 60 then
rate_suffix = string.format(" (~%.1f/hour since boot)", resets / (uptime / 3600))
end
if reset_rx == 0 or reset_tx >= reset_rx * 3 then
-- Tx-heavy: this router is doing almost all the closing and the
-- peer is barely ever resetting back. That's the signature of a
-- local abortive close (e.g. a health-check script tearing down
-- its own short-lived proxied connection), not the peer/path
-- forcing the connection shut.
add_issue(issues, "warning", "mptcp_resets",
string.format("%d MPTCP-level reset/fastclose event(s) sent by this router%s.", reset_tx, rate_suffix),
"Almost all of these were sent by this router, not the peer (received resets are near zero) -- " ..
"that points at something local doing an abortive close of its own MPTCP connections (a common " ..
"cause: a health-check/keepalive script that opens a proxied connection, makes one quick request, " ..
"then closes it hard) rather than active interference on the path or the VPS dropping the tunnel. " ..
"Only worth escalating if it's paired with rising SubflowStale/Blackhole counters above.")
else
add_issue(issues, "warning", "mptcp_resets",
string.format("%d MPTCP-level reset/fastclose event(s)%s.", resets, rate_suffix),
"One side force-closed the whole MPTCP connection (all subflows at once) rather than a single " ..
"subflow -- can indicate active interference or the proxy/VPN tunnel dropping abruptly.")
end
end
local addr_drop = (counters.AddAddrDrop or 0) + (counters.RmAddrDrop or 0)
if addr_drop > 0 then
-- A handful of drops only matters relative to how much ADD_ADDR/RM_ADDR
-- traffic there's actually been -- against a large total it's usually a
-- one-off from an interface flap or brief restart, already self-healed
-- by the next successful signal, not an ongoing problem. Only surface
-- this as an issue once drops are a meaningful share of that traffic
-- (>1%); with no successful signals at all to compare against, treat
-- it as 100% (nothing has gotten through), which is always worth
-- surfacing.
local addr_total = (counters.AddAddr or 0) + (counters.AddAddrTx or 0)
+ (counters.RmAddr or 0) + (counters.RmAddrTx or 0)
local addr_drop_pct = addr_total > 0 and (100 * addr_drop / addr_total) or 100
if addr_drop_pct > 1 then
local ratio_suffix = ""
if addr_total > 0 then
ratio_suffix = string.format(" (%d of %d ADD_ADDR/RM_ADDR signals, %.3g%%)",
addr_drop, addr_total, addr_drop_pct)
end
add_issue(issues, "warning", "mptcp_addr_signal_dropped",
string.format("%d ADD_ADDR/RM_ADDR signal(s) dropped%s.", addr_drop, ratio_suffix),
"A WAN address announcement (or removal) didn't make it to the peer, so the peer may never " ..
"attempt a subflow toward that WAN. A handful of drops against a much larger signal volume is " ..
"usually a one-off from an interface flap or a brief proxy restart, and self-heals on the next " ..
"successful signal -- only worth chasing further if this keeps climbing.")
end
end
if (counters.MPFailTx or 0) > 0 or (counters.MPFailRx or 0) > 0 then
add_issue(issues, "warning", "mptcp_mapping_failure",
"MPTCP data mapping failure signalled (MP_FAIL).",
"One side couldn't map received data to the right place in the byte stream on a subflow -- " ..
"that subflow gets torn down and traffic continues on the others, if any remain.")
end
if #issues == 0 then
add_issue(issues, "ok", "no_issues_detected",
"No MPTCP fallback, blackhole, reset or checksum issues detected right now.",
"This only reflects counters since boot/last reset -- a healthy snapshot doesn't rule out an " ..
"intermittent issue on a specific path; keep this page open while reproducing the problem.")
end
return issues
end
-- -------------------------------------------------------------------------
-- RPC methods
-- -------------------------------------------------------------------------
@@ -354,6 +717,36 @@ local methods = {
return { output = exec_output(cmd) }
end
},
-- Diagnostics: kernel MPTCP counters/endpoints/limits + a per-WAN
-- endpoint cross-check, turned into a plain-English issue list. See the
-- helpers above ("MPTCP Diagnostics" section) for the actual logic.
diagnose = {
call = function()
local settings = kernel_settings()
local counters = parse_counters(mptcp_counters_raw())
local endpoints = parse_endpoints(exec_output(IP_BIN .. " mptcp endpoint show 2>/dev/null"))
local limits = parse_limits(exec_output(IP_BIN .. " mptcp limits show 2>/dev/null"))
local wans = wan_endpoint_status(endpoints)
local issues = build_issues(settings, counters, wans)
local connections_raw = exec_output("multipath -c 2>/dev/null")
local established_count = 0
for _ in connections_raw:gmatch("[Ee][Ss][Tt][Aa][Bb]") do
established_count = established_count + 1
end
return {
settings = settings,
counters = counters,
endpoints = endpoints,
limits = limits,
wans = wans,
issues = issues,
established_count = established_count,
}
end
},
}
-- -------------------------------------------------------------------------
@@ -21,6 +21,17 @@
"acl": [ "luci-app-mptcp" ]
}
},
"admin/network/mptcp/mptcp_diagnostics": {
"title": "Diagnostics",
"order": 12,
"action": {
"type": "view",
"path": "mptcp/mptcp_diagnostics"
},
"depends": {
"acl": [ "luci-app-mptcp" ]
}
},
"admin/network/mptcp/mptcp_dscp_routing": {
"title": "DSCP / Weight Routing",
"order": 15,
@@ -22,7 +22,8 @@
"get_settings",
"set_settings",
"get_interface_settings",
"set_interface_settings"
"set_interface_settings",
"diagnose"
]
}
},
+8 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.8.12
PKG_RELEASE:=226
PKG_RELEASE:=227
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -17,6 +17,7 @@ PKG_CONFIG_DEPENDS:= \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Geoview \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Haproxy \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Hysteria \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_NaiveProxy \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Shadowsocks_Rust_Client \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Shadowsocks_Rust_Server \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_ShadowsocksR_Libev_Client \
@@ -96,6 +97,12 @@ config PACKAGE_$(PKG_NAME)_INCLUDE_Hysteria
select PACKAGE_hysteria
default n
config PACKAGE_$(PKG_NAME)_INCLUDE_NaiveProxy
bool "Include NaiveProxy"
depends on !(arc||armeb||loongarch64||mips||mips64||powerpc||TARGET_gemini)
select PACKAGE_naiveproxy
default n
config PACKAGE_$(PKG_NAME)_INCLUDE_Shadowsocks_Rust_Client
bool "Include Shadowsocks Rust Client"
depends on !i386
@@ -0,0 +1,33 @@
if not api.is_finded("naive") then
return
end
-- [[ Naive ]]
local m, s1 = ...
local type_name = "Naiveproxy"
s1.fields["type"]:value(type_name, "NaïveProxy")
if s1.val["type"] ~= type_name then
return
end
local s = NamedSection(m, arg[1], "server")
s.type_name = type_name
s.option_prefix = "naive_"
o = s:option(ListValue, "protocol", translate("Protocol"))
o:value("https", translate("HTTPS"))
o:value("quic", translate("QUIC"))
o = s:option(Value, "address", translate("Address (Support Domain Name)"))
o = s:option(Value, "port", translate("Port"))
o.datatype = "port"
o = s:option(Value, "username", translate("Username"))
o = s:option(Value, "password", translate("Password"))
o.password = true
api.luci_types(s1, s)
@@ -0,0 +1,42 @@
module("luci.passwall.util_naiveproxy", package.seeall)
local api = require "luci.passwall.api"
local jsonc = api.jsonc
function gen_config(var)
local node_id = var["node"]
if not node_id then
print("node 不能为空")
return
end
local node = api.uci_get_c(node_id)
local run_type = var["run_type"]
local local_addr = var["local_addr"]
local local_port = var["local_port"]
local server_host = var["server_host"] or (node.address or ""):lower()
local server_port = var["server_port"] or node.port
if api.is_ipv6(server_host) then
server_host = api.get_ipv6_full(server_host)
end
local server = server_host .. ":" .. server_port
local config = {
listen = run_type .. "://" .. local_addr .. ":" .. local_port,
proxy = node.protocol .. "://" .. node.username .. ":" .. node.password .. "@" .. server
}
return jsonc.stringify(config, 1)
end
_G.gen_config = gen_config
if arg[1] then
local func =_G[arg[1]]
if func then
local var = nil
if arg[2] then
var = jsonc.parse(arg[2])
end
print(func(var))
end
end
@@ -13,6 +13,7 @@ LUA_UTIL_PATH=/usr/lib/lua/luci/passwall
UTIL_SINGBOX=$LUA_UTIL_PATH/util_sing-box.lua
UTIL_SS=$LUA_UTIL_PATH/util_shadowsocks.lua
UTIL_XRAY=$LUA_UTIL_PATH/util_xray.lua
UTIL_NAIVE=$LUA_UTIL_PATH/util_naiveproxy.lua
UTIL_HYSTERIA2=$LUA_UTIL_PATH/util_hysteria2.lua
SINGBOX_BIN=$(first_type $(config_t_get global_app sing_box_file) sing-box)
XRAY_BIN=$(first_type $(config_t_get global_app xray_file) xray)
@@ -455,6 +456,13 @@ run_socks() {
[ -n "$no_run" ] && _args="${_args} no_run=1"
run_xray flag=$flag node=$node socks_address=$bind socks_port=$socks_port config_file=$config_file log_file=$log_file ${_args}
;;
naiveproxy)
json_add_string "run_type" "socks"
json_add_string "local_addr" "$bind"
json_add_string "local_port" "$socks_port"
lua $UTIL_NAIVE gen_config "$(json_dump)" > $config_file
[ -n "$no_run" ] || ln_run "$(first_type naive)" naive $log_file "$config_file"
;;
ssr)
json_add_string "local_addr" "$bind"
json_add_string "local_port" "$socks_port"
+1 -1
View File
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=22
PKG_RELEASE:=23
endif
LUCI_TITLE:=Footstrap Theme
+15
View File
@@ -132,4 +132,19 @@ rm -f /tmp/luci-indexcache*"
scp -q "$D"/root/etc/uci-defaults/30_luci-theme-footstrap "$R":/tmp/30_luci-theme-footstrap
ssh "$R" "PKG_UPGRADE=1 sh /tmp/30_luci-theme-footstrap; rm -f /tmp/30_luci-theme-footstrap /tmp/luci-indexcache*"
# ---- BUST THE BROWSER CACHE, or a dev deploy is invisible to the device you are testing on ----
#
# head.ut versions every asset with `?v={{ pkgs_update_time }}`, and luci-base derives that from the
# mtime of the PACKAGE DATABASE (runtime.uc: /lib/apk/db/installed, falling back to
# /usr/lib/opkg/status). Installing a package moves it; scp'ing files over an existing install does
# not — so a dev deploy leaves the URL identical while the bytes underneath change, and a browser is
# entitled to keep serving what it already has. On a phone that is not a theory: a whole afternoon of
# "still shakes" reports were taken against a stylesheet several deploys old.
#
# Touching the database file is what an install would have done to it, so the next page carries a new
# `?v=` for every asset and nothing can be stale. Router-only, dev-only: nothing in the package does
# this.
ssh "$R" 'touch /lib/apk/db/installed 2>/dev/null || touch /usr/lib/opkg/status 2>/dev/null || true'
echo "synced to $R (single Footstrap theme registered, active theme unchanged)"
@@ -210,6 +210,14 @@ function fitShell() {
}
function fitChrome() {
/* Nothing this function asks can change while the reader scrolls — the bar's width, the menu's
* width, the room beside the brand — and every one of those questions is a layout read landing
* in the middle of a flick. Put off until the scrolling stops; see fs-fit.js. */
if (fit.scrolling()) {
fit.deferMeasurement();
return;
}
fitShell();
const bar = document.querySelector('.fs-sidebar');
@@ -463,5 +471,20 @@ return baseclass.extend({
/* registered with fs-fit by the theme's init(): the bar's "does the menu fit beside the brand"
* measurement rides the same engine as the data tables' */
fitChrome,
/* The width a page's content column has, WITHOUT reading layout. The sidebar (or the rail) eats
* a known amount of the window and the shell adds a known padding, and all three are already
* memoised against the density attribute for `fitShell()`. It is exported because a pass that
* must answer mid-scroll — fs-select's, for a table the poll has just brought in — otherwise has
* only `window.innerWidth`, which in the sidebar layout is the wrong number by exactly the
* sidebar: at an 800px window the column is 520px, so the cheap judgement said "plenty of room"
* for a table that then overflowed and was clipped. */
contentWidth() {
const g = shellGeometry();
const narrow = document.documentElement.hasAttribute('data-narrow');
/* in the bar layout the chrome is above the content, not beside it */
const cut = narrow ? 0 : (prefs.currentRail() ? g.railW : g.sidebarW);
return Math.max(0, window.innerWidth - cut - (g.contentPad * 2));
},
wireRail
});
@@ -20,21 +20,184 @@
* ResizeObserver, not onresize: a rail collapse and a layout toggle change the content width
* without resizing the window. */
/* THE STYLESHEET MAY ONLY HIDE WHAT THIS FILE WILL SHOW, AND THE ARM BELONGS TO THE DISARM.
* `theme/30-tables.css` keeps a data table out of the layout until something marks it `.fs-fitted`.
* This attribute is what arms that rule — and it used to be written here, at module eval, while the
* only code that ever writes `.fs-fitted` lives in fs-select.js, which the footer requires
* SEPARATELY (`partials/footer.ut`: one require for menu-footstrap, another for fs-select, with no
* dependency edge between them). A document that loaded this file and not that one — a failed
* fetch, a parse error, a throw in fs-select's own init — armed a rule nobody could clear, and every
* data table on Status/Leases/Processes/Wireless rendered as nothing at all, silently.
*
* So the arming is exported instead, and the module that clears the rule is the one that raises it.
* A document where fs-select never ran carries no attribute and shows every table exactly as it did
* before the rule existed. */
function armGate() {
if (!fittersEnabled()) return;
try { document.documentElement.dataset.fsFit = '1'; } catch (e) { /* no document, no gate */ }
}
const _fitters = [];
let _rafPending = false;
let _ro = null, _mo = null, _moFlag = null;
/* Run every fitter NOW, synchronously. A fitter must be idempotent — this fires on every
* relevant mutation. */
function run() {
for (const fit of _fitters) {
/* ---- A PASS THAT READS LAYOUT MAY NOT RUN WHILE THE READER SCROLLS ----
*
* Reading layout — `getBoundingClientRect()`, `clientWidth`, `scrollWidth` — forces the engine to
* lay the page out synchronously. Once a second, from a poll tick, in the middle of a flick on a
* phone, that is what iOS holds the main thread back to prevent, and it was the largest part of the
* shaking reported from an iPhone; a stock theme has no JS in that path at all.
*
* The rule is stated by each pass rather than by this file, and that is not an oversight — the
* central version was written, measured and reverted, because moving the decision here also moved
* WHEN the deferred work lands and the device started shaking again. A pass that reads layout asks
* `scrolling()` and calls `deferMeasurement()`; a pass that only writes does neither and runs
* always. The one that must run always is the marking of a freshly polled table: the stylesheet
* keeps an unmarked data table out of the layout, so waiting would leave it invisible for as long
* as the reader keeps scrolling. */
function runAll(list, what) {
for (const fit of list) {
try { fit(); }
/* one broken fitter must take neither the others nor the poll's MutationObserver
* callback with it — that would silently stop ALL re-fitting */
catch (e) { console.error('fs-fit: a fitter threw', e); }
catch (e) { console.error('fs-fit: a ' + what + ' threw', e); }
}
}
/* Everything that may run right now, with the reader kept where they were.
*
* ONE PATH: the mutation observer, the coalesced re-fit and the pass that was put off during a
* scroll all come through here, so the order — reference, work, correction — is stated once.
* `anchorRef()` answers null while the reader scrolls and the correction below does nothing with a
* pass that happens mid-scroll costs neither of the two layout reads.
*
* WHAT MAY RUN MID-SCROLL IS EACH PASS'S OWN ANSWER, not this function's. A pass that only writes —
* marking a freshly polled table, which cannot wait because the stylesheet holds an unmarked table
* out of the layout — runs always; a pass that reads layout asks `scrolling()` first and calls
* `deferMeasurement()`. Deciding it here instead was tried and reverted: it changed WHEN the
* deferred work lands, and the device that had the problem started shaking again. */
/* DEV SWITCH: `localStorage.fsFit = 'off'` stops every fitter. It exists because the only way to
* tell "the theme's measuring is what shakes this phone" from "something else is" is to turn the
* measuring off ON THAT PHONE, and a laptop cannot answer it. */
function fittersEnabled() {
try { return localStorage.getItem('fsFit') !== 'off'; }
catch (e) { return true; }
}
function run() {
if (!fittersEnabled()) return;
runAll(_fitters, 'fitter');
}
/* ---- IS THE PAGE MOVING RIGHT NOW? — ASKED OF THE POSITION, NEVER OF THE EVENTS ----
*
* A pass that reads layout must not run while the page moves: on a phone every such read forces a
* synchronous layout in the middle of a flick, which is the work iOS holds the main thread back to
* prevent. So the passes ask this before measuring, and what they skipped is run once the movement
* stops.
*
* THE FIRST VERSION ASKED THE EVENTS — `scroll`, `wheel`, `touchmove`, plus 200 ms of quiet — and it
* was wrong in the one place it mattered. On iOS the momentum keeps carrying the page long after the
* finger has gone, and events do not reliably arrive through it; the timer then declared the reader
* still and dropped the whole deferred pass — every table stripped and measured, the chrome
* re-fitted — into the middle of the glide. That is a stall and a relayout while the page is
* visibly moving, and it is the shaking that came BACK when a refactor made less work happen during
* the scroll and more of it land in that burst. The measurement that named it: the same device
* shook more with strictly less work in the scroll path.
*
* So movement is read from the SCROLL POSITION. A frame in which the offset differs from the last is
* movement, whatever the event stream is doing; the sampler runs only while there is reason to
* think the page moves, and stops itself when the offset has held for SCROLL_IDLE. Momentum,
* rubber-banding and a programmatic `scrollTo` all look the same to it, which is the point.
*
* The offset read is one per frame, and it is the cheapest question there is — no geometry, no
* element, no forced layout beyond what the frame already needs. */
/* HOW LONG THE PAGE MUST HOLD STILL BEFORE THE PUT-OFF WORK MAY RUN, and the number is the fix for
* the shaking, not a tuning knob.
*
* 200ms was shorter than the pauses a slow reader leaves. Rocking a page gently at the tables — the
* reader's own description of when it happens — the offset stops for a moment between one movement
* and the next, this timer called that a stop, and the whole deferred pass landed in the middle of
* the gesture: every table stripped of its marks, measured, marked again. That is a full relayout
* while the page is visibly moving, and it is what "jerks back and forth" was.
*
* Measured on a stand with the reader's motion imitated (a slow rock, 60 frames, the error between
* what the wheel asked for and what the page did, summed): 137-256px of roughness at 200ms with
* every twentieth frame off by 40px, and 59px — the floor, one pixel of rounding per frame, the same
* as switching the fitters off entirely — at 250ms and above. 400 is that floor with room to spare,
* and still well inside the time a reader takes to look at what they scrolled to. */
const SCROLL_IDLE = 400;
/* set by a pass that skipped its measurement because the page was moving; consumed by the sampler
* below the moment it stops */
let _deferred = false;
function deferMeasurement() { _deferred = true; }
let _movingUntil = 0;
let _lastOffset = null;
let _sampling = false;
/* WHICH ELEMENT SCROLLS, ASKED ONCE PER WIDTH RATHER THAN ONCE PER FRAME.
*
* This function is the one every other pass consults before it dares to measure, and it ran in the
* frame loop below for as long as the page moved — so the `scrollHeight`/`clientHeight` probe it
* used to make WAS a forced synchronous layout, once per frame, in the middle of the flick this file
* exists to keep clear. Worse, a poll tick lands as a microtask that writes classes, so the very
* next frame's probe paid for a layout that had just been dirtied; and `touchstart` starts the
* sampler, so a plain tap on a button bought ~24 of them.
*
* The answer cannot change without the room changing: the two layouts scroll different elements and
* the switch between them is a width change, which `onResize` already watches and stamps. So the
* verdict is cached against that stamp and the offset is then read from the scroller directly —
* `scrollTop` on an element and `scrollY` on the window are both free. */
let _scroller = null, _scrollerAt = -1;
function scroller() {
if (_scrollerAt === _resizeSeq && (_scroller === null || _scroller.isConnected)) return _scroller;
const sc = document.getElementById('maincontent');
_scroller = (sc && sc.scrollHeight - sc.clientHeight > 4) ? sc : null;
_scrollerAt = _resizeSeq;
return _scroller;
}
function scrollTop() {
const sc = scroller();
return sc ? sc.scrollTop : window.scrollY;
}
function scrolling() { return Date.now() < _movingUntil; }
function sampleMotion() {
const y = scrollTop();
if (_lastOffset === null || y !== _lastOffset) {
_lastOffset = y;
_movingUntil = Date.now() + SCROLL_IDLE;
}
if (scrolling()) { requestAnimationFrame(sampleMotion); return; }
_sampling = false;
/* the page has held still for SCROLL_IDLE: whatever was put off may run now */
if (_deferred) {
_deferred = false;
const ref = anchorRef();
run();
scheduleAnchor(ref);
}
}
function noteMotion() {
_movingUntil = Date.now() + SCROLL_IDLE;
if (_sampling) return;
_sampling = true;
requestAnimationFrame(sampleMotion);
}
/* `passive: true` and `capture: true`: this must never sit in front of the scroll it is watching,
* and `scroll` does not bubble from an element — it only travels down the capture phase, which is
* how the sidebar layout's inner scroller is seen as well as the document. The events only START
* the sampler; whether the page is still moving is the sampler's answer, not theirs. */
(function watchMotion() {
const opts = { passive: true, capture: true };
window.addEventListener('scroll', noteMotion, opts);
window.addEventListener('wheel', noteMotion, opts);
window.addEventListener('touchstart', noteMotion, opts);
window.addEventListener('touchmove', noteMotion, opts);
})();
/* Next frame, at most once per frame (rule 3). */
function schedule() {
if (_rafPending) return;
@@ -59,6 +222,9 @@ function schedule() {
*
* Per element, because the roots are observed separately and a dialog can resize while #view does
* not. The first entry for an element always counts as a change, so nothing is lost at start-up. */
/* bumped whenever an observed root changes WIDTH — the only thing that can change which element
* scrolls, and therefore what `scroller()` above may cache */
let _resizeSeq = 0;
const _lastWidth = new WeakMap();
function onResize(entries) {
let widthMoved = false;
@@ -71,7 +237,7 @@ function onResize(entries) {
widthMoved = true;
}
}
if (widthMoved) schedule();
if (widthMoved) { _resizeSeq++; schedule(); }
}
/* Watch an element's size. A change in WIDTH re-fits everything — the fitters are cheap and few. */
@@ -85,6 +251,111 @@ function watch(el) {
_ro.observe(el);
}
/* ---- SCROLL ANCHORING, WHERE THE ENGINE HAS NONE ----
*
* A poll tick changes the height of what is ABOVE the reader: a lease expires, a station joins, a
* section renders one row fewer. An engine with scroll anchoring absorbs that — it moves the scroll
* offset by the same amount, so the page under the reader does not move. WebKit has none, and it is
* every browser on iOS: measured on the reporter's own router, `content +133px, +134px, +123px,
* +108px…` one after another, each next to a `child +1/-1` in a polled section. That is the shaking,
* and it is not the tall-intermediate problem the rest of this file solves — the height change here
* is REAL, and simply nobody compensates for it.
*
* So this compensates for it, and only when nobody else did. A reference is taken from the elements
* that survive a poll — the section frames — choosing the one that crosses the top of the viewport,
* because that is the boundary a reader perceives as "where I am". The fitters run, the reference is
* read again, and the offset is moved by however far it drifted.
*
* WHY THIS IS SAFE ON AN ENGINE THAT DOES ANCHOR, which an earlier attempt got wrong by measuring
* the wrong thing: the correction is computed from the REFERENCE, not from the scroll offset. An
* anchoring engine has already put the reference back where it was by the time this reads it — the
* read forces layout, and the adjustment happens during layout — so the drift is zero and this does
* nothing at all. Measuring the offset instead reads an anchoring adjustment as a fault and corrects
* a correction, which is exactly how a previous version made Chromium worse (16 movements, 1827px).
*
* It never fights the user: a page at the very top has no offset to give back and is left alone, and
* a drift under a pixel is rounding rather than movement. */
function anchorRef() {
/* NOT WHILE THE READER SCROLLS. Every rect read here is a forced layout, and this runs on every
* content mutation — i.e. once a second, in the middle of a flick, which is the work iOS holds
* the main thread back to avoid. The compensation exists for a page the reader is looking at,
* not for one they are already moving; while they scroll there is nothing to keep still. */
if (scrolling()) return null;
const frames = document.querySelectorAll('#view .cbi-section, #view > .cbi-section-node, #view > div');
for (const el of frames) {
const r = el.getBoundingClientRect();
/* the frame the viewport's top edge cuts through */
if (r.top <= 0 && r.bottom > 0) return { el, top: r.top };
}
return null;
}
/* THE CORRECTION IS UNCONDITIONAL, AND IT IS A NO-OP WHERE THE ENGINE DOES THE JOB.
*
* There used to be a watching phase here: the first three mutations were only measured, and a
* reference that had held still meant "this engine anchors, never touch the offset again". It was
* wrong in the way that mattered. WebKit does not anchor, but the drift it leaves is often small —
* a value grew a line, a row appeared — so three quiet samples were easy to come by, the
* compensation switched itself off for the life of the document, and the page walked downwards a
* few pixels at a time, once per poll tick. That is exactly how it was reported: "jerks a little
* downwards, all the time".
*
* Correcting always is safe because the correction is computed from the REFERENCE, not from the
* scroll offset: an engine that anchors has already put the reference back by the time this reads
* it — the read forces layout, and the adjustment happens during layout — so the drift is zero and
* nothing is written. Measuring the OFFSET instead reads an anchoring adjustment as a fault and
* corrects a correction, which is how an earlier version made Chromium worse (10 movements up to
* 1616px against 0 without it); that mistake is not in this shape.
*
* Two guards remain, and both are about not fighting the reader: a page at the very top has no
* offset to give back, and a drift under a pixel is rounding rather than movement. */
/* ONCE PER FRAME, AFTER EVERYTHING HAS LANDED — not once per mutation batch.
*
* A poll tick does not arrive as one mutation: luci-mod-status fills its sections one after another,
* and each fill wakes this observer. Correcting on each of them measures a page that is still being
* written: the reference is read between two batches, the drift is computed against a layout that
* the next batch immediately invalidates, and the correction is wrong by whatever the rest of the
* tick brought. The next tick corrects the other way. That is a page that jerks while nobody
* touches it, and it is what was reported — with a telling detail: it went away whenever a
* diagnostic probe that forced layout on EVERY frame was loaded, because then every reading this
* code took happened to be against a settled layout.
*
* So the reference is taken at the first mutation of a batch and the correction is applied in a
* requestAnimationFrame — after the batch, after style and layout have settled, once. A newer
* reference is not taken while one is pending: the first one is where the reader actually was. */
let _anchorPending = null;
let _anchorFrame = 0;
/* DEV SWITCH, for the device that has the problem: `localStorage.fsAnchor = 'off'` stops the theme
* from ever writing the scroll offset, which is the one thing here that can move a page nobody is
* touching. It exists to answer a question a laptop cannot — whether the correction is the cure or
* the disease — and comes out with the answer. */
function anchorEnabled() {
try { return localStorage.getItem('fsAnchor') !== 'off'; }
catch (e) { return true; }
}
function scheduleAnchor(ref) {
if (!ref || !anchorEnabled()) return;
if (_anchorPending) return;
_anchorPending = ref;
if (_anchorFrame) return;
_anchorFrame = requestAnimationFrame(() => {
_anchorFrame = 0;
const pending = _anchorPending;
_anchorPending = null;
applyAnchor(pending);
});
}
function applyAnchor(ref) {
if (!ref || !ref.el.isConnected) return;
const sc = document.getElementById('maincontent');
const inner = sc && sc.scrollHeight - sc.clientHeight > 4;
const at = inner ? sc.scrollTop : window.scrollY;
if (at <= 0) return;
const drift = ref.el.getBoundingClientRect().top - ref.top;
if (Math.abs(drift) < 1) return;
if (inner) sc.scrollTop = at + drift;
else window.scrollTo(0, at + drift);
}
/* Rule 2's mutation side. Deliberately NOT filtered by node type: a filter is a second place to
* get wrong (the table fitter's own once said `table.table`, and LuCI renders most of its tables
* as DIVs — so the poll never re-measured at all), and run() is a handful of measurements. */
@@ -100,7 +371,12 @@ function watch(el) {
* constructor, and luci-base instantiates a class exactly once, at the first require. */
function observeContent() {
if (_mo) return;
_mo = new MutationObserver(run);
_mo = new MutationObserver(() => {
/* what the reader is looking at, before the poll's mutation and the fitters move anything */
const ref = anchorRef();
run();
scheduleAnchor(ref);
});
for (const host of [ document.getElementById('view') || document.body, document.getElementById('modal_overlay') ]) {
if (!host) continue;
_mo.observe(host, { childList: true, subtree: true });
@@ -136,11 +412,21 @@ return baseclass.extend({
fit();
},
/* Is the reader scrolling, and "I could not measure, wake me when they stop". A pass that has to
* read layout asks the first and calls the second; a pass that only writes does neither. */
scrolling,
deferMeasurement,
/* Raise the stylesheet's "an unanswered table takes no room" rule. Called by the module that
* answers — see armGate above; nothing else may call it. */
armGate,
/* Re-fit on the next frame, coalesced. (There is no exported `run`: everything that changes
* the available room — the layout toggle, the rail collapse — schedules. Only the mutation
* observer re-fits synchronously, and that is rule 2's whole point.) */
schedule,
/* Coalesce ANY callback into one call per frame (rule 3, for non-fitters): schedule() runs
* EVERY fitter, so a caller wanting only its own work batched cannot use it — three had
* hand-rolled the identical five lines. NOT for the per-element case: menu-footstrap.js's
@@ -32,20 +32,45 @@
* Hooked at module eval — before any view render can set a timer, and LuCI resolves a module's
* dependencies BEFORE running the dependent's factory, so this runs no later than it used to when
* it sat in menu-footstrap-common.js itself. */
const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals = new Set()));
const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals = new Map()));
(function hookIntervals() {
if (window.__fsIntervalsHooked) return;
window.__fsIntervalsHooked = true;
const _si = window.setInterval, _ci = window.clearInterval;
window.setInterval = function () {
window.setInterval = function (fn, ms) {
const id = _si.apply(window, arguments);
_viewIntervals.add(id);
/* the arguments are kept, not just the id: a paused timer has to be re-armed with what it
* was armed with, and a `setInterval` id carries none of that back */
_viewIntervals.set(id, { fn, ms, rest: Array.prototype.slice.call(arguments, 2) });
return id;
};
window.clearInterval = function (id) {
_viewIntervals.delete(id);
return _ci.apply(window, arguments);
};
/* A HIDDEN TAB MUST NOT KEEP CALLING THE ROUTER. `wireVisibility()` below stops LuCI's own poll
* when the tab goes away, which is most of the traffic — but a view is free to run a plain
* `setInterval` of its own (luci-app-podkop's log tailer does), and those kept hammering ubus in
* a background tab for as long as it stayed open. The registry that navigation already uses to
* clear them is enough to pause them too: cleared on hide, re-armed on show with the same
* callback and period, so a view that was polling every 3 s is polling every 3 s again and one
* that was cleared meanwhile stays cleared. */
let _paused = [];
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
_paused = [];
for (const [ id, spec ] of _viewIntervals) {
_paused.push(spec);
_ci.call(window, id);
_viewIntervals.delete(id);
}
}
else {
const back = _paused;
_paused = [];
for (const spec of back) window.setInterval(spec.fn, spec.ms, ...spec.rest);
}
});
})();
function clearViewIntervals() {
/* `L.Poll.timer` is the id of LuCI's OWN 1 s tick, and it is private state — `add`/`remove`/
@@ -84,7 +109,8 @@ function clearViewIntervals() {
return;
}
const keep = running ? L.Poll.timer : null;
_viewIntervals.forEach((id) => { if (id !== keep) window.clearInterval(id); });
/* Map, not Set: the key is the timer id and the value is what it would take to re-arm it */
_viewIntervals.forEach((spec, id) => { if (id !== keep) window.clearInterval(id); });
}
/* ONE line per document whatever went wrong: this runs on every navigation, and a router that
* cannot read L.Poll cannot read it on the next click either — a message per click would bury the
@@ -3,6 +3,8 @@
'require ui';
'require dom';
'require fs-fit as fit';
/* for the content column's width without a layout read — see the mid-scroll branch in fitTables */
'require fs-chrome as chrome';
/* Theme plain LuCI <select> fields (ui.Select, widget:'select') by rendering a styled
* cbi-dropdown beside them — a native <select> popup cannot be CSS-styled.
@@ -503,10 +505,180 @@ function breakWidestColumn(t) {
return false;
}
/* ---- A TABLE THAT REPLACES A TABLE INHERITS ITS ANSWER ----
*
* The measurement below has to strip a table's marks first (fs-fit rule 1: a stacked table is a pile
* of flex rows and always "fits"), and that lays the page out with a full-width table for the length
* of the pass — at 390px, several screens taller than the card stack it is about to be. Standing
* still nobody sees it. The poll REPLACES these tables rather than updating them, so every tick used
* to hand the fitter a fresh element with no marks and that intermediate happened again; on a remote
* router at iPhone width it threw the reader 612px out and back, twice per tick.
*
* Compensating for it was tried four ways and each was wrong somewhere; the changelog entry for this
* release records all four, because each looked right. This attacks the cause instead: the
* intermediate exists only because the answer was thrown away with the element, so the answer is
* kept on the SLOT. The slot is the section frame — `slotHome()` below says why it cannot be the
* table's own parent — and inside it a table is named by its id, or by its position among the
* tables of that frame when it has none.
*
* WHEN THE INHERITED ANSWER IS TRUSTED, and it is not "always". The tier depends on how many columns
* share the room and on how wide the room is, so both are remembered with it. Room is `roomFor()`,
* which reads the container rather than the table, so it is unaffected by the marks. The shape is
* the column count, read from the DOM — `shapeOf()` says why rows are deliberately not in it.
*
* A change in either one re-measures. So does the arrival of a table in a slot nobody has measured
* yet, and so does every full pass, which happens whenever the reader is not scrolling — that last
* one is what catches the case a column count cannot see, a value that grew long enough to change
* the tier, within a second of it happening and never during a scroll.
*
* WHAT IT COSTS. A tick on a table whose columns and room did not change writes its class names and
* reads one width, against stripping every mark, forcing a layout, reading `scrollWidth` and
* possibly walking the columns. Mid-scroll it does not even read the width: only tables with no
* answer at all are visited, and each is given one without measuring anything. */
const _slots = new WeakMap();
/* THE SLOT IS THE FRAME, NOT THE PARENT, and the difference is the whole cache. A poll tick hands
* the container a freshly rendered subtree, so the table's immediate parent is usually new as well —
* keyed on that, every lookup missed and every tick measured from scratch, which is exactly the
* behaviour this was written to end (measured: the ±612px kept coming). What survives a tick is the
* frame the theme and luci-mod-status build once, `.cbi-section`; inside it a table is named by its
* id where it has one, and by its position among the tables of that frame where it does not. */
function slotHome(t) {
return (t.closest && t.closest('.cbi-section, .cbi-section-node, #view')) || t.parentElement;
}
function slotKey(t, home) {
if (t.id) return '#' + t.id;
const kin = home ? Array.from(home.querySelectorAll('.table')) : [];
const at = kin.indexOf(t);
return at >= 0 ? 'at' + at : 'lone';
}
/* WHAT CAN CHANGE THE ANSWER, AND WHAT CANNOT. The tier depends on how many columns have to share
* the room and on how wide the room is — not on how many rows there are. Rows were in this
* signature first and that was the bug behind a whole afternoon: a lease list gains and loses a row
* on almost every poll tick, so the signature changed every tick, the cache never hit, and every
* tick measured from scratch — which is the tall intermediate this exists to avoid.
*
* Read from the tree, never from layout: `children.length` is a DOM question, so asking it cannot
* force the layout the whole design is arranging not to force. */
function shapeOf(t) {
const head = t.querySelector('.tr.table-titles, .cbi-section-table-titles') || t.firstElementChild;
return String(head ? head.children.length : 0);
}
function applyDecision(t, d) {
/* the mark that lets the table into the layout at all — see theme/30-tables.css */
t.classList.add('fs-fitted');
t.classList.toggle('fs-stacked', !!d.stack);
t.classList.toggle('fs-drop-xs', !!d.drop);
if (d.breakCol !== undefined && d.breakCol !== -1)
markBreakColumn(t, t.querySelectorAll('.tr'), d.breakCol);
}
function fitTables() {
document.querySelectorAll(stackables()).forEach((t) => {
/* MID-SCROLL, ONLY THE TABLES THAT HAVE NO ANSWER YET. A table that already carries its answer
* needs nothing; one that does not is being held out of the layout by the stylesheet and cannot
* wait. Neither branch of this function reads layout while `scrolling()` is true. */
const sel = fit.scrolling() ? inRoots('.table.fs-dt:not(.fs-fitted)', liveRoots()) : stackables();
document.querySelectorAll(sel).forEach((t) => {
/* ---- WHILE THE READER SCROLLS, THIS PASS READS NOTHING ----
*
* Every layout read here — `roomFor()` is one per table — forces the engine to lay the page
* out synchronously, and a poll tick lands once a second, so on a phone that is ten forced
* layouts in the middle of a flick. iOS runs the main thread sparingly during momentum
* scrolling exactly to keep the frame rate; work like this is what it is protecting against,
* and the reporter's word for the result was "shakes". A stock theme has no JS in this path
* at all, which is why the same page under luci-theme-bootstrap scrolls smoothly.
*
* So a pass that happens mid-scroll only WRITES: the answer this slot already has goes on the
* fresh table, and nothing is measured until the scrolling stops. A slot nobody has measured
* yet is judged from the window width instead of the container's — the one number available
* without touching layout, and at phone width it gives the same answer. */
const home = slotHome(t);
const slots = home ? (_slots.get(home) || new Map()) : null;
const key = slots ? slotKey(t, home) : null;
const known = slots ? slots.get(key) : null;
/* the answer without a measurement: the slot's, or — for a slot nobody has measured yet —
* the window's width, which is the one number available without touching layout. At the
* width where this matters the cheap judgement and the measured one agree. */
/* MID-SCROLL THE ANSWER STILL GOES ON, and it must. Letting a freshly polled table wait for
* the scrolling to stop was tried on the device that has the fault: the reader then WATCHES
* the tables fold into cards under their thumb, and that fold is the hardest jerk of all. The
* answer is a write, it forces nothing, and it keeps the fresh table the same shape as the one
* it replaced. */
if (fit.scrolling()) {
/* the column's width, not the window's: in the sidebar layout they differ by the sidebar,
* and an 800px window whose column is 520px used to clear CRAMPED and leave the table
* unstacked at full width, clipped by `.fs-main { overflow-x: clip }` until the reader
* held still. `chrome.contentWidth()` answers from memoised tokens and attributes — no
* layout is read here either. */
applyDecision(t, known || { stack: chrome.contentWidth() < CRAMPED, drop: false, breakCol: -1 });
fit.deferMeasurement();
return;
}
/* the column count, read from the tree: no layout is forced by asking */
const shape = shapeOf(t);
/* ---- THE ANSWER GOES ON BEFORE ANYTHING IS MEASURED, AND THAT ORDER IS THE FIX ----
*
* `roomFor()` reads a clientWidth, which forces layout — and a fresh table from a poll tick
* is UNMARKED when it lands, so a pass that measured first laid the page out with a
* full-width table before it had said a word. That is the tall intermediate the engine
* re-anchors on: measured on a remote router at iPhone width, ±612px twice per tick, and it
* survived every attempt to compensate for it because the compensation ran after the layout
* that caused it.
*
* With the slot's answer applied first, the only layout this pass forces is one where the
* table already wears the answer it had a second ago. */
if (known) applyDecision(t, known);
const was = t.classList.contains('fs-stacked');
const wasDrop = t.classList.contains('fs-drop-xs');
/* rounded, because `roomFor()` subtracts parsed padding and answers in fractions: comparing
* those exactly made the cache miss on a width that had not moved at all */
const room = Math.round(fit.roomFor(t));
if (!(room > 0)) {
/* Detached, hidden, or a dialog that is closed: keep what it had rather than decide
* against a width it does not have — but LET IT INTO THE LAYOUT anyway. The stylesheet
* keeps an unanswered table out of the flow (theme/30-tables.css), and a table whose
* section is collapsed when it arrives would otherwise stay hidden after the section is
* opened: nothing measures it again, because opening a section changes no width this
* file watches. A table with no room cannot move anything either way, so admitting it
* costs nothing and the next pass with room decides properly. */
t.classList.add('fs-fitted');
return;
}
/* the answer this slot already has, for a table of the same shape in the same room — UNLESS the
* table has since outgrown it. Room and column count are not the whole input: a poll tick can
* put longer values into the same columns, and then a table that fitted a second ago does not.
* Measured on a live router at 900px, where the cache held: status_leases6 stood 1000px wide in
* an 810px column and wifi_assoclist_table 896px, neither carded, both quietly clipped by
* `.fs-main { overflow-x: clip }` — columns cut off with nothing to say so. The extra question
* is one the pass already has the layout for. */
/* AND THE OTHER DIRECTION, WHICH THAT QUESTION CANNOT ASK. `overflows()` is put to a table
* that is already wearing its remedy, and every remedy makes a table fit by construction: a
* card is a pile of flex rows, `.fs-drop-xs` has hidden the expendable columns, a broken
* column shreds. So the answer is always "it fits", and a remedy applied once could never be
* lifted while the room and the column count held still — a one-way ratchet. The case is
* real: one station with a long hostname makes Associated Stations drop its `hide-xs`
* columns, and those columns stayed hidden after that station left, on a screen with room to
* spare and nothing to say a column existed.
*
* The only honest measurement is of a table with no remedy on it, and taking that on every
* tick would mean stripping the marks on every tick — the strip-measure-restore that lands as
* a visible relayout, which is what the deferral above exists to keep out of a scroll. So the
* re-decision is asked for by the CONTENT instead: a remedied table whose row count has gone
* DOWN has lost something, and losing something is the only way it can stop needing the
* remedy. Rows are counted from the tree, not the layout. A table that keeps its rows keeps
* its answer, which is what makes the poll cheap. */
const rows = t.querySelectorAll('.tr').length;
const remedied = !!(known && (known.stack || known.drop || known.breakCol !== -1));
const shrank = remedied && known.rows !== undefined && rows < known.rows;
if (known && known.room === room && known.shape === shape && !shrank && !fit.overflows(t))
return;
/* fs-fit rule 1: a stacked table is a pile of flex rows and always "fits", so reading
* it as it stands un-stacks it and the next frame stacks it again — oscillation. Every
@@ -515,15 +687,6 @@ function fitTables() {
if (t._fsBreakCol !== undefined && t._fsBreakCol !== -1)
markBreakColumn(t, t.querySelectorAll('.tr'), -1);
const room = fit.roomFor(t);
if (!(room > 0)) {
/* detached, hidden, or a dialog that is closed: keep what it had rather than decide
* against a width it does not have */
if (was) t.classList.add('fs-stacked');
if (wasDrop) t.classList.add('fs-drop-xs');
return;
}
/* the one judgement a measurement cannot make, and the only number in this file */
let stack = room < CRAMPED;
if (!stack && fit.overflows(t)) {
@@ -533,6 +696,7 @@ function fitTables() {
if (fit.overflows(t) && !breakWidestColumn(t)) stack = true;
}
t.classList.add('fs-fitted');
/* write only on a real change: the poll re-renders these tables once a second, and
* toggling the class off and on each tick would invalidate style for every row of
* Processes/Leases for nothing */
@@ -544,6 +708,20 @@ function fitTables() {
markBreakColumn(t, t.querySelectorAll('.tr'), -1);
}
else if (was) t.classList.remove('fs-stacked');
if (slots) {
slots.set(key, {
stack,
drop: t.classList.contains('fs-drop-xs'),
breakCol: (t._fsBreakCol === undefined) ? -1 : t._fsBreakCol,
room,
shape,
/* what the answer was decided over — a drop below this is the one signal that a
* remedy may no longer be needed (see the fast path above) */
rows,
});
_slots.set(home, slots);
}
});
}
@@ -616,6 +794,11 @@ function unreach(t) {
}
function fitScrollables() {
/* A layout read here lands in the middle of a flick once per poll tick, and that is the work iOS
* holds the main thread back to prevent — the same argument fitTables() makes above, and the
* reason the theme shook on a phone while a stock one did not. Put off until the page is still;
* fs-fit's sampler runs what was deferred the moment the scroll offset stops changing. */
if (fit.scrolling()) { fit.deferMeasurement(); return; }
document.querySelectorAll(scrollables()).forEach((t) => {
const was = t.classList.contains('fs-xscroll');
const wasStack = t.classList.contains('fs-rowstack');
@@ -687,6 +870,11 @@ function fitScrollables() {
* into — so wiping the pin cannot change the key and set this oscillating. It fires once per CHANGE,
* never per tick, so it does not fight upstream for the pin on a polled page. */
function unpinActionColumn() {
/* A layout read here lands in the middle of a flick once per poll tick, and that is the work iOS
* holds the main thread back to prevent — the same argument fitTables() makes above, and the
* reason the theme shook on a phone while a stock one did not. Put off until the page is still;
* fs-fit's sampler runs what was deferred the moment the scroll offset stops changing. */
if (fit.scrolling()) { fit.deferMeasurement(); return; }
for (const t of document.querySelectorAll(inRoots('.table.cbi-section-table', liveRoots()))) {
if (!t.querySelector('.cbi-section-actions')) continue;
/* ---- and CLAIM upstream's resize hook, because under SPA navigation it is a leak ----
@@ -851,10 +1039,26 @@ return baseclass.extend({
};
scan();
/* ARM THE STYLESHEET'S GATE FROM HERE, because this is the file that clears it. The rule in
* theme/30-tables.css holds an unanswered data table out of the layout, and `.fs-fitted` —
* the answer — is written nowhere but in this module. Arming it in fs-fit.js meant a document
* that loaded fs-fit and not this file (the footer requires them separately) hid every table
* forever. Raised here, one line before the pass that clears it is registered. */
fit.armGate();
/* A table must be TAGGED .fs-dt before it can be fitted, and re-tagged whenever the poll
* brings a fresh one back — so the two travel as one fitter, which fs-fit runs now, on
* every content mutation (synchronously, pre-paint) and on every resize of #view. */
fit.add(() => { tagDataTables(); fitTables(); fitScrollables(); unpinActionColumn(); resyncValues(); });
* brings a fresh one back — so tagging leads, and the passes that depend on it follow.
*
* FIVE REGISTRATIONS, NOT ONE CALLBACK. fs-fit catches per registered fitter, so a throw in
* one of these used to take the other four with it — and the first of them walks third-party
* markup, which is the shape most likely to surprise it. Bundled, a single throw in
* `tagDataTables()` left no table tagged, no table answered, and — with the gate above raised
* — a page with no tables on it at all. Registered separately, each pass fails alone. */
fit.add(tagDataTables);
fit.add(fitTables);
fit.add(fitScrollables);
fit.add(unpinActionColumn);
fit.add(resyncValues);
/* one scan per frame, however many mutations arrive (fit.frame — the theme's shared
* coalescer) */
@@ -838,6 +838,24 @@ function rehostInvasiveSheets() {
if (!universe) return;
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
if (el.dataset.fsLayered) return;
/* A SHEET WITH NO RULES YET IS NOT AN INNOCENT SHEET. `insertRule()` produces no mutation
* record of any kind, so an app that appends an empty <style> and fills it later — lazily, on
* first hover, from a `.then()` — is judged here as empty, found not invasive, and never
* looked at again unless some other sheet happens to be added afterwards. Measured: a
* `* { padding: 0 !important }` inserted that way flattened the chrome (the sidebar's 20px
* padding went to 0) and stayed unfenced for the life of the document. One deferred look per
* such element is enough, and it costs nothing in the ordinary case where a <style> arrives
* with its rules already in it. */
let rules = null;
try { rules = el.sheet && el.sheet.cssRules; } catch (e) { /* cross-origin: judged below */ }
if (rules && !rules.length && !el.__fsRecheck) {
el.__fsRecheck = true;
window.setTimeout(() => {
let now;
try { now = el.isConnected && el.sheet && el.sheet.cssRules; } catch (e) { return; }
if (now && now.length) rehostInvasiveSheets();
}, 2000);
}
if (invasiveSheet(el, universe)) rehostIntoThemeLayer(el, universe);
});
}
@@ -106,13 +106,23 @@ function probeColor(expr) {
/* off-screen rather than display:none — a display:none element still computes `color`, but
* keeping it laid out avoids depending on that being true of every engine. It carries no
* text and no size, so it paints nothing. */
_probe = E('span', { 'style': 'position:fixed;left:-9999px;top:0;width:0;height:0;overflow:hidden;pointer-events:none', 'aria-hidden': 'true' });
/* EVERY DECLARATION IMPORTANT, and the reason is the same one fs-chrome.js's length probe
* carries (issue #19): this is an unmarked plain element in a document shared with
* `luci-app-*`, the sheet fence deliberately does not spare it, and an app's unlayered
* `span { color: … !important }` or `* { color: … !important }` outranks a layer and a plain
* inline style alike. A probe that loses its own colour reports the app's instead — and this
* probe's answer is written into the hex field, painted into the swatch and graded for
* contrast, so a stolen reading becomes the admin's saved axis on the next confirm. */
_probe = E('span', { 'aria-hidden': 'true' });
_probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;'
+ 'width:0!important;height:0!important;overflow:hidden!important;'
+ 'pointer-events:none!important;';
document.body.appendChild(_probe);
}
/* cleared first: an expression the engine rejects leaves the PREVIOUS colour standing, which
* would report a stale answer as a fresh one. */
_probe.style.color = '';
_probe.style.color = expr;
_probe.style.setProperty('color', '');
_probe.style.setProperty('color', expr, 'important');
return getComputedStyle(_probe).color;
}
+3 -3
View File
@@ -70,7 +70,7 @@ msgctxt "footstrap"
msgid "Clearly visible %s"
msgstr "Bien visible %s"
#: htdocs/luci-static/resources/fs-chrome.js:407
#: htdocs/luci-static/resources/fs-chrome.js:415
#: ucode/template/themes/footstrap/header.ut:150
msgid "Collapse menu"
msgstr "Contraer menú"
@@ -144,7 +144,7 @@ msgctxt "footstrap"
msgid "Easy to read %s"
msgstr "Se lee bien %s"
#: htdocs/luci-static/resources/fs-chrome.js:407
#: htdocs/luci-static/resources/fs-chrome.js:415
msgid "Expand menu"
msgstr "Expandir menú"
@@ -411,7 +411,7 @@ msgstr "Sistema"
msgid "System running in recovery (initramfs) mode."
msgstr "El sistema se está ejecutando en modo de recuperación (initramfs)."
#: htdocs/luci-static/resources/fs-select.js:607
#: htdocs/luci-static/resources/fs-select.js:756
msgid "Table"
msgstr "Tabla"
+3 -3
View File
@@ -66,7 +66,7 @@ msgctxt "footstrap"
msgid "Clearly visible %s"
msgstr "Хорошо видна %s"
#: htdocs/luci-static/resources/fs-chrome.js:407
#: htdocs/luci-static/resources/fs-chrome.js:415
#: ucode/template/themes/footstrap/header.ut:150
msgid "Collapse menu"
msgstr "Свернуть меню"
@@ -138,7 +138,7 @@ msgctxt "footstrap"
msgid "Easy to read %s"
msgstr "Хорошо читается %s"
#: htdocs/luci-static/resources/fs-chrome.js:407
#: htdocs/luci-static/resources/fs-chrome.js:415
msgid "Expand menu"
msgstr "Развернуть меню"
@@ -404,7 +404,7 @@ msgstr "Система"
msgid "System running in recovery (initramfs) mode."
msgstr "Система работает в режиме восстановления (initramfs)."
#: htdocs/luci-static/resources/fs-select.js:607
#: htdocs/luci-static/resources/fs-select.js:756
msgid "Table"
msgstr "Таблица"
@@ -60,7 +60,7 @@ msgctxt "footstrap"
msgid "Clearly visible %s"
msgstr ""
#: htdocs/luci-static/resources/fs-chrome.js:407
#: htdocs/luci-static/resources/fs-chrome.js:415
#: ucode/template/themes/footstrap/header.ut:150
msgid "Collapse menu"
msgstr ""
@@ -132,7 +132,7 @@ msgctxt "footstrap"
msgid "Easy to read %s"
msgstr ""
#: htdocs/luci-static/resources/fs-chrome.js:407
#: htdocs/luci-static/resources/fs-chrome.js:415
msgid "Expand menu"
msgstr ""
@@ -394,7 +394,7 @@ msgstr ""
msgid "System running in recovery (initramfs) mode."
msgstr ""
#: htdocs/luci-static/resources/fs-select.js:607
#: htdocs/luci-static/resources/fs-select.js:756
msgid "Table"
msgstr ""
@@ -54,7 +54,10 @@
body.modal-overlay-active {
overflow: hidden;
height: 100dvh; /* dvh: 100vh counts the mobile URL bar and cropped the modal */
/* svh: `100vh` counts the mobile URL bar as if it were not there and cropped the modal, and
* `dvh` re-lays the dialog out on every frame of the bar's slide (theme/20-shell.css). The
* small viewport height is the one that is both correct and still. */
height: 100svh;
}
body.modal-overlay-active #modal_overlay {
@@ -128,10 +128,11 @@
/* …and THAT is only half of it, which 0.10.2 got wrong and made worse. The same fork writes
* `align-items:center;justify-items:center` on the grid CONTAINER, so a tile's `justify-self`
* resolves to `center` — and a centred item with `width: auto` is sized `fit-content`, not
* stretched to its track. This card is `container-type: inline-size`, i.e. size-CONTAINED, so
* its max-content contribution is ZERO: releasing the fork's `width:100px` handed the tile a
* fit-content width of nothing but padding, and every row (name, speed, traffic) spilled out
* to the right of a ~17px pill (reported against 0.10.2, ImmortalWrt 25.12 r37854).
* stretched to its track, so releasing the fork's `width:100px` left the tile as narrow as
* its content and the rows spilled out to the right (reported against 0.10.2, ImmortalWrt
* 25.12 r37854; the tile was a ~17px pill then, because the card was still size-contained
* and its max-content contribution was therefore zero — the containment is gone, the
* fit-content sizing is not, and neither is the need to state the self-alignment).
* Stating the self-alignment here needs NO flag — the fork's declarations are `*-items` on the
* PARENT, which only supplies the value when the item says `auto`; a direct `*-self` wins on
* the cascade proper. On stock, where both are already `stretch`, this is a no-op. */
@@ -142,7 +143,21 @@
border: 1px solid var(--fs-border);
border-radius: var(--fs-radius-lg);
padding: var(--fs-space-2) var(--fs-space-3);
container-type: inline-size; container-name: fs-port;
/* NO `container-type` HERE, and its absence is the fix for a page that jumped every five
* seconds. The card used to be `container-type: inline-size; container-name: fs-port`, for two
* `@container fs-port` queries that were deleted when the flex wrap below replaced their pixel
* threshold (see the note by `text-align: end` further down) — the containment was left behind
* with nothing asking it anything.
*
* It was not free. The poll rewrites these cards once a tick, and a freshly built size-contained
* box is laid out before the containment resolves, so everything below it moved by the same
* amount on every tick: "jerks down by a fixed distance every five seconds", on the reporter's
* own router in Safari. Bisected on that router — the theme's stylesheet cut to its cascade
* layers (tokens+base+theme: still; +this page layer: jumping), then to this one declaration:
* with `container-type: normal` forced on the cards, the page is still.
*
* If a future rule needs a container query here, name the container on a wrapper the poll does
* NOT replace, never on the card itself. */
display: flex;
/* `flex-direction: row` is NOT redundant: base/95-luci.css paints every .ifacebox
* `display: inline-flex; flex-direction: column`, and the grid this rule used to declare
@@ -181,11 +196,13 @@
}
/* name row + its ::after status dot */
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:first-child {
/* Its OWN row — and `width`, NOT `flex-basis: 100%`, which is the obvious way to write this
* and does not work here: the card carries `container-type: inline-size`, so its main size is
* not a definite length to resolve a percentage flex-basis against, and the browser falls
* back to content sizing. Measured on the router: `flex: 0 0 100%` left the name 46px wide,
* sitting beside the speed; `width: 100%` gives it the full 176. */
/* Its OWN row — and `width`, NOT `flex-basis: 100%`. The two were not equivalent when this
* was written: the card carried `container-type: inline-size`, so its main size was not a
* definite length for a percentage flex-basis to resolve against and the browser fell back to
* content sizing (measured on the router: `flex: 0 0 100%` left the name 46px wide beside the
* speed, `width: 100%` gave it the full 176). The containment has since been removed — see
* the block above — so the two forms now agree; this one is kept because it says what it
* means and does not depend on the card's sizing staying definite. */
flex: 0 0 auto;
width: 100%;
display: flex; align-items: center; justify-content: space-between; gap: var(--fs-space-1-5);
+19 -6
View File
@@ -94,7 +94,20 @@
* ================================================================== */
.fs-shell {
display: flex; flex-direction: column;
min-height: 100dvh; width: 100%;
/* ---- `svh`, NOT `dvh`, AND THAT IS THE PHONE'S WHOLE PROBLEM ----
*
* `dvh` is defined to track the viewport as browser UI comes and goes, and on iOS the URL bar
* slides continuously WHILE THE USER SCROLLS — so a shell keyed on `dvh` is re-laid-out on
* every frame of that animation, which is the shaking reported from an iPhone. `svh` is the
* SMALL viewport height: the value the viewport has with the browser UI shown, and it does
* not move while the UI does. It is also the right value here: this is a floor, so the shell
* must be at least one screenful tall with the bar visible; when the bar hides, the page is
* free to be taller and nothing needs to re-measure.
*
* The whole file follows the same rule below — a height that must not change under the reader
* uses `svh`. Nothing here wants `dvh`'s tracking; that unit is for a thing that should GROW
* as the UI retracts, and this shell is not one. */
min-height: 100svh; width: 100%;
}
.fs-sidebar {
display: flex;
@@ -312,11 +325,11 @@
* the ROOT stacking context and sit above the content for free. Confined to the desktop
* sidebar: the top layout and the phone bar keep window scrolling and their sticky bar. */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-shell {
flex-direction: row; height: 100dvh; overflow: hidden;
flex-direction: row; height: 100svh; overflow: hidden;
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-main {
height: 100dvh; overflow-y: auto;
/* `contain: paint` — the fix for #12. .fs-shell is height:100dvh + overflow:hidden and
height: 100svh; overflow-y: auto;
/* `contain: paint` — the fix for #12. .fs-shell is height:100svh + overflow:hidden and
* .fs-main is its own overflow:auto scroller, so Chrome never grows the document past the
* viewport. Firefox does: on Обзор (where the .fs-ovl grid makes the column ~2800px tall)
* it propagates ~158px of an out-of-flow descendant's scrollable overflow PAST both
@@ -331,7 +344,7 @@
contain: paint;
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar {
width: var(--fs-sidebar-w); height: 100dvh;
width: var(--fs-sidebar-w); height: 100svh;
flex: 0 0 auto;
position: static;
flex-direction: column; flex-wrap: nowrap; align-items: normal;
@@ -682,7 +695,7 @@
* FIXED, AT THE TOP OF THE VIEWPORT, AND A CHILD OF <body> — all three are load-bearing and the
* first draft got two of them wrong. In the content column it is a flex item of `.fs-main`, and
* a column flex container shrank its 2px to ZERO (measured: height 0 in the sidebar layout,
* where `.fs-shell` is 100dvh); with `margin-bottom: -2px` the content painted over what was
* where `.fs-shell` is 100svh); with `margin-bottom: -2px` the content painted over what was
* left. And it cannot live inside `.fs-shell` either: that box carries `contain: paint`, which
* makes a fixed descendant position itself against the shell and be clipped by it. At the top of
* the viewport is also where a browser puts its own progress, so it is the least surprising
@@ -537,6 +537,28 @@
* rendered ALL FIVE of its .hide-xs cells; at 760px both were correct. So they key off the CLASS.
* The media query keeps its own copies, for the tables that do NOT stack on a phone and for the
* config tables (@container, never .fs-stacked). The `.col-N` weights need no guard — see below. */
/* ---- A TABLE NOBODY HAS ANSWERED FOR YET TAKES NO ROOM ----
*
* A poll tick REPLACES these tables, and a fresh one carries no marks: for the moment between
* landing and being stamped it is laid out as a full-width table — at 390px, several screens
* taller than the card stack it is about to be. The engine re-anchors on that intermediate and
* throws the reader; measured on a remote router at iPhone width, 612px out and back, twice per
* tick. fs-select.js answers in a MutationObserver microtask, i.e. before paint — but only if
* nothing forces layout in between, and an app that reads a width right after rendering does
* exactly that.
*
* So the table waits outside the layout until it has an answer. `display: none` and not
* `visibility: hidden`, because a hidden box still occupies its height, which is the whole
* problem; the wait is a microtask long.
*
* GUARDED ON THE FITTERS BEING ALIVE. `:root[data-fs-fit]` is written by fs-fit.js at module
* eval and by nothing else, so a document where that file never ran — an old cache, a JS error
* before it, a browser that refused the module — shows every table exactly as it did before this
* rule existed. A stylesheet that can hide content on its own is a stylesheet that can hide it
* forever. */
:root[data-fs-fit] #view .table.fs-dt:not(.fs-fitted),
:root[data-fs-fit] #modal_overlay .table.fs-dt:not(.fs-fitted) { display: none; }
.table.fs-stacked { display: block; width: 100%; }
/* `.fs-drop-xs` is the SAME drop, one rung earlier: fs-select.js writes it on a table that has
* overflowed, before it decides to card, because the view author has already said which columns
+23 -5
View File
@@ -92,9 +92,22 @@
.cbi-value-field .ifacebadge { font-family: var(--fs-font-sans); }
code, pre { font-weight: var(--fs-weight-normal); }
/* fade-in used by overview poll */
.fade-in { animation: fs-fade var(--fs-dur-fade); }
@keyframes fs-fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
/* ---- THE POLL'S `fade-in` IS NOT ANIMATED AT ALL, AND THAT IS THE WHOLE RULE ----
*
* `fade-in` is upstream's class and luci-mod-status re-marks with it on EVERY poll tick — each
* section, and the `div.includes` wrapper that holds all of them. Anything this theme hangs on
* that class therefore replays once a second across the entire content column.
*
* It was a fade plus a 4px slide, which moved the page under the reader; the slide came off
* first, and the fade alone still pulsed the whole column once a second — reported from an
* iPhone as shaking that happens WITHOUT SCROLLING AT ALL, which is what finally named it: a
* poll tick is the only thing that happens on a still page. The stock themes animate nothing
* here, which is why the same router and the same poll are calm under luci-theme-bootstrap.
*
* So the theme animates nothing on it either. The class stays in the markup (it is upstream's
* and other rules may key on it); what is gone is the animation this file used to attach. The
* `fade-in` keyframes in base/95-luci.css stay for the same reason they always did — the NAME is
* upstream's and an app may use it. */
.cbi-title-ref { color: var(--fs-accent); }
.close { color: var(--fs-text); text-shadow: none; opacity: .6; }
.close:hover { opacity: 1; }
@@ -121,12 +134,17 @@
* NAMED, deliberately — an unnamed @container binds to the nearest ancestor that happens to
* have a container-type, so giving container-type to any new component would silently
* re-target every unnamed query underneath it.
* fs-map this element specifically (a CBI form's content column)
* fs-view the overview's grid (pages/20-overview.css, @container 800)
* fs-content "whatever content column I am in" — carried by BOTH .cbi-map and #view, so a
* query resolves to the NEAREST of the two. That is the point of sharing it.
*
* ONLY NAMES A QUERY ASKS FOR. `.cbi-map` also carried `fs-map`, for queries that were never
* written — and a name in this list reads as an established seam, so the next rule that wants
* the form's column would have been written against a container the sheet does not really
* publish. The containment on `.cbi-map` stays: it is what makes `fs-content` resolve to the
* form's own column rather than to `#view`.
* ================================================================== */
.cbi-map { container-type: inline-size; container-name: fs-map fs-content; }
.cbi-map { container-type: inline-size; container-name: fs-content; }
#view { container-type: inline-size; container-name: fs-view fs-content; }
/* A modal is NOT inside #view — ui.js appends #modal_overlay to <body> — so every
@@ -250,9 +250,35 @@
* that has to HOLD an icon, which is what --fs-density-box is defined for. */
--box: calc(16px * var(--fs-density-box));
--mark-inset: 3px;
/* ---- THE DRAWING IS 16px; THE TARGET IS 24px ----
*
* WCAG 2.2 SC 2.5.8 lets an undersized target pass on SPACING — 24px centre to centre — and
* that is what a 16px tick box has always relied on. It holds while the theme decides the
* layout and stops holding the moment a third-party app lays out its own rows: CI's
* luci-app-filemanager listing put this checkbox and a file link inside the 24px, and the
* page gate reported both. A target that passes only because of what someone else's markup
* happens to do is not a target that passes.
*
* So the ELEMENT is 24px and the drawing stays 16px, centred inside it — the two pseudo
* elements below are already absolutely positioned, so centring them costs `inset: 0;
* margin: auto` and nothing else. Nothing moves visually: the box, the tick and the focus
* ring are all drawn by `::before`/`::after` at `--box`, and the element itself has neither
* background nor border. What grows is the area a finger may land on.
*
* `max()`, not a flat 24px: the Large density scales `--box` to 18.4px, and a target must
* never SHRINK when the user asks for a bigger UI. */
--hit: max(24px, var(--box));
appearance: none;
width: var(--box);
height: var(--box);
width: var(--hit);
height: var(--hit);
/* …AND THE TARGET DOES NOT COST A ROW ITS HEIGHT. The element is 24px, but it occupies its
* old `--box` in layout: the negative margin pulls the extra area back out of the flow, so
* the box overflows itself symmetrically and everything around it sits exactly where it did.
* Measured on luci-app-filemanager's listing, which is where CI reported the finding: with
* the target simply grown, its rows went 42px -> 51px, i.e. a third-party page redesigned by
* a theme's accessibility fix; with the margin, 43px against 42px and the target still
* answers to a hit test at its centre. */
margin: calc((var(--box) - var(--hit)) / 2);
color: var(--fg-color);
position: relative;
display: inline-block;
@@ -272,8 +298,12 @@
}
input[type="checkbox"]::before,
input[type="radio"]::before {
top: 0;
inset-inline-start: 0;
/* 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
* RTL, where an `inset-inline-start: 50%` + `translate: -50%` pair would push the drawing the
* wrong way. */
inset: 0;
margin: auto;
width: var(--box);
height: var(--box);
background: linear-gradient(var(--fs-panel), var(--fs-panel2));
@@ -302,8 +332,10 @@
}
input[type="checkbox"]::after,
input[type="radio"]::after {
top: var(--mark-inset);
inset-inline-start: var(--mark-inset);
/* 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 */
inset: 0;
margin: auto;
width: calc(var(--box) - var(--mark-inset) * 2);
height: calc(var(--box) - var(--mark-inset) * 2);
}
@@ -3,7 +3,7 @@
* Print. A LuCI page ends up on paper/PDF as documentation — a firewall ruleset in a
* change ticket, an overview in a handover — and without this block it printed the dark
* chrome, the sticky bar, and (in the sidebar layout) ONE page: the shell is
* height:100dvh + overflow:hidden there, so everything past the first viewport was cut.
* height:100svh + overflow:hidden there, so everything past the first viewport was cut.
*
* No `!important` (tools/audit.py allows the flag only in its listed files, and nothing
* here fights an inline style). Where a rule must beat a same-layer chrome rule it wins
+2 -2
View File
@@ -1,12 +1,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=mhz
PKG_RELEASE:=2
PKG_RELEASE:=3
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL=https://github.com/wtarreau/mhz.git
PKG_SOURCE_DATE:=2025-07-26
PKG_SOURCE_VERSION:=null
PKG_SOURCE_VERSION:=512f63021f6f0cec8e5e13be744dba0b4704cfae
PKG_MIRROR_HASH:=skip
PKG_MAINTAINER:=Robert Marko <robimarko@gmail.com>
+2 -2
View File
@@ -10,12 +10,12 @@ include $(INCLUDE_DIR)/kernel.mk
PKG_NAME:=natflow
PKG_VERSION:=20260531
PKG_RELEASE:=72
PKG_RELEASE:=73
PKG_SOURCE:=$(PKG_VERSION).tar.xz
PKG_SOURCE_URL:=https://github.com/ptpt52/natflow.git
PKG_SOURCE_PROTO:=git
PKG_SOURCE_VERSION:=fb8848ba93fa22b264e6351865d7d76c846f656b
PKG_SOURCE_VERSION:=8fa0d6022ff4f25fb123dfe8b3fc7337fc38e3fa
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_MAINTAINER:=Chen Minqiang <ptpt52@gmail.com>
PKG_LICENSE:=GPL-2.0
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=nexttrace
PKG_VERSION:=1.6.2
PKG_RELEASE:=14
PKG_VERSION:=1.7.2
PKG_RELEASE:=15
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/nxtrace/NTrace-core/tar.gz/v$(PKG_VERSION)?
+2 -2
View File
@@ -8,8 +8,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=ooniprobe
PKG_VERSION:=3.16.5
PKG_RELEASE:=7
PKG_VERSION:=3.30.0
PKG_RELEASE:=8
PKG_SOURCE:=probe-cli-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/ooni/probe-cli/tar.gz/v$(PKG_VERSION)?