mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-26 21:12:51 +08:00
🛸 Sync 2026-08-18 08:57:58
This commit is contained in:
1 parent
f3ba5f7b5d
commit
3e76c2c768
56 files changed
+8850
-2138
No files matched your search
+2
-2
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)?
|
||||
|
||||
@@ -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)/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 "今週"
|
||||
|
||||
|
||||
@@ -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ń"
|
||||
|
||||
|
||||
@@ -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 "На этой неделе"
|
||||
|
||||
|
||||
@@ -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 "本周"
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
+1048
-295
File diff suppressed because it is too large.
Load diff
+1051
-286
File diff suppressed because it is too large.
Load diff
+1035
-287
File diff suppressed because it is too large.
Load diff
+984
-154
File diff suppressed because it is too large.
Load diff
+999
-157
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1067
-313
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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)?
|
||||
|
||||
Reference in new issue
Block a user