🎉 Sync 2026-10-14 03:01:37

This commit is contained in:
github-actions[bot] committed 2026-10-14 03:01:37 +08:00
1 parent 57ac656612
commit ef45574e85
78 files changed
+2683 -3911

No files matched your search

+3 -3
View File
@@ -5,10 +5,10 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=dae PKG_NAME:=dae
PKG_VERSION:=2026.09.24 PKG_VERSION:=2026.09.30
PKG_RELEASE:=49 PKG_RELEASE:=50
PKG_SOURCE:=dae-src-2026.09.24-17135da7c881.tar.gz PKG_SOURCE:=dae-src-2026.09.30-30538527b146.tar.gz
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/dae-src PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/dae-src
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_HASH:=skip PKG_HASH:=skip
+3 -3
View File
@@ -5,10 +5,10 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=daed PKG_NAME:=daed
PKG_VERSION:=2026.09.24 PKG_VERSION:=2026.09.30
PKG_RELEASE:=60 PKG_RELEASE:=61
PKG_SOURCE:=daed-src-2026.09.24-527bb166b1ae.tar.gz PKG_SOURCE:=daed-src-2026.09.30-84f171fef875.tar.gz
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_HASH:=skip PKG_HASH:=skip
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=filebrowser-q PKG_NAME:=filebrowser-q
PKG_VERSION:=2.0.0-stable PKG_VERSION:=2.0.1-stable
PKG_RELEASE:=9 PKG_RELEASE:=10
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/gtsteffaniak/filebrowser/tar.gz/v$(PKG_VERSION)? PKG_SOURCE_URL:=https://codeload.github.com/gtsteffaniak/filebrowser/tar.gz/v$(PKG_VERSION)?
+2 -2
View File
@@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=grpcurl PKG_NAME:=grpcurl
PKG_VERSION:=1.9.4 PKG_VERSION:=1.9.4
PKG_RELEASE:=7 PKG_RELEASE:=8
PKG_SOURCE:=v$(PKG_VERSION).tar.gz PKG_SOURCE:=v$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/fullstorydev/grpcurl/archive/refs/tags/ PKG_SOURCE_URL:=https://github.com/fullstorydev/grpcurl/archive/refs/tags/
@@ -21,7 +21,7 @@ PKG_MAINTAINER:=Yannick Chabanois <ycarus@zugaina.org>
PKG_BUILD_DEPENDS:=golang/host PKG_BUILD_DEPENDS:=golang/host
PKG_BUILD_PARALLEL:=1 PKG_BUILD_PARALLEL:=1
PKG_USE_MIPS16:=0 PKG_BUILD_FLAGS:=no-mips16
GO_PKG:=github.com/fullstorydev/grpcurl GO_PKG:=github.com/fullstorydev/grpcurl
GO_PKG_BUILD_PKG:=github.com/fullstorydev/grpcurl/cmd/grpcurl GO_PKG_BUILD_PKG:=github.com/fullstorydev/grpcurl/cmd/grpcurl
+2 -2
View File
@@ -6,8 +6,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=headscale PKG_NAME:=headscale
PKG_VERSION:=0.29.4 PKG_VERSION:=0.29.5
PKG_RELEASE:=9 PKG_RELEASE:=10
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/juanfont/headscale/tar.gz/v$(PKG_VERSION)? PKG_SOURCE_URL:=https://codeload.github.com/juanfont/headscale/tar.gz/v$(PKG_VERSION)?
+1 -1
View File
@@ -1 +1 @@
snapshot both
@@ -105,7 +105,6 @@ querylog:
- orirainbow.cc^ - orirainbow.cc^
- orirainwe.edu.kg^ - orirainwe.edu.kg^
- coriseay.edu.kg^ - coriseay.edu.kg^
- xzbzcoriseay.win^
- google.com^ - google.com^
interval: 7d interval: 7d
size_memory: 1000 size_memory: 1000
@@ -118,7 +117,6 @@ statistics:
- orirainbow.cc^ - orirainbow.cc^
- orirainwe.edu.kg^ - orirainwe.edu.kg^
- coriseay.edu.kg^ - coriseay.edu.kg^
- xzbzcoriseay.win^
- google.com^ - google.com^
interval: 7d interval: 7d
enabled: true enabled: true
@@ -137,6 +135,8 @@ user_rules:
- '@@||ntfy.sh^' - '@@||ntfy.sh^'
- '@@||pubnub.com^' - '@@||pubnub.com^'
- '@@||clarity.ms^$important' - '@@||clarity.ms^$important'
- '@@||sh-gsg.com^$important'
- '@@||sh-mtgc.com^$important'
- '@@||taobao.com^$important' - '@@||taobao.com^$important'
- '@@||tmall.com^$important' - '@@||tmall.com^$important'
- '@@||jd.com^important' - '@@||jd.com^important'
@@ -164,7 +164,6 @@ user_rules:
- '@@||miwifi.com^' - '@@||miwifi.com^'
- '@@||tracker.ai.xiaomi.com^$important' - '@@||tracker.ai.xiaomi.com^$important'
- '@@||tracking.miui.com^$important' - '@@||tracking.miui.com^$important'
- '@@||xiaomiyoupin.com^'
- '#解除 Apple 及其所有子域名的封锁;' - '#解除 Apple 及其所有子域名的封锁;'
- '@@||apple.com^' - '@@||apple.com^'
- '@@||apple.com.cn^' - '@@||apple.com.cn^'
@@ -196,6 +195,9 @@ user_rules:
- '@@||app-measurement.com^$important' - '@@||app-measurement.com^$important'
- '@@||tmdb.org^' - '@@||tmdb.org^'
- '@@||themoviedb.org^' - '@@||themoviedb.org^'
- /googleads.$~script,domain=~googleads.github.io
- /pagead/lvz?
- '||google.com/pagead/'
- '||static.doubleclick.net^$domain=youtube.com' - '||static.doubleclick.net^$domain=youtube.com'
- '||youtube.com/get_midroll_' - '||youtube.com/get_midroll_'
- '||5hz.org^' - '||5hz.org^'
@@ -230,27 +232,7 @@ user_rules:
- '@@||1ptba.com^' - '@@||1ptba.com^'
- '@@||ptcafe.club^' - '@@||ptcafe.club^'
- '||nbpicture.xyz^' - '||nbpicture.xyz^'
- '! ===== Gemini & NotebookLM & Google Labs 核心 API 放行 =====' - ""
- '@@||notebooklm.google.com^'
- '@@||labs.google.com^'
- '@@||gemini.google.com^'
- '@@||alkalimakersuite-pa.googleapis.com^'
- '@@||generativelanguage.googleapis.com^'
- '! ===== Google 基础前端脚本与 API 通讯保障 ====='
- '@@||googleapis.com^'
- '@@||gstatic.com^'
- '@@||googleusercontent.com^'
- '@@||apis.google.com^'
- '@@||clients1.google.com^'
- '@@||clients2.google.com^'
- '@@||clients3.google.com^'
- '@@||clients4.google.com^'
- '@@||clients5.google.com^'
- '@@||clients6.google.com^'
- '! ===== 仅对 Gemini 域名排除以下广告拦截/过滤规则的影响 ====='
- /googleads.$~script,domain=~googleads.github.io&~gemini.google.com
- /pagead/lvz?$domain=~gemini.google.com
- '||google.com/pagead/^$domain=~gemini.google.com'
dhcp: dhcp:
enabled: false enabled: false
interface_name: "" interface_name: ""
+1 -2
View File
@@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-daede PKG_NAME:=luci-app-daede
PKG_VERSION:=1.15 PKG_VERSION:=1.15
PKG_RELEASE:=50 PKG_RELEASE:=51
PKG_MAINTAINER:=kenzok8 PKG_MAINTAINER:=kenzok8
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
@@ -35,7 +35,6 @@ define Package/$(PKG_NAME)
TITLE:=LuCI app for daede dashboard TITLE:=LuCI app for daede dashboard
DEPENDS:=+luci-base +PACKAGE_$(PKG_NAME)_dae:dae +PACKAGE_$(PKG_NAME)_daed:daed DEPENDS:=+luci-base +PACKAGE_$(PKG_NAME)_dae:dae +PACKAGE_$(PKG_NAME)_daed:daed
PKGARCH:=all PKGARCH:=all
MAINTAINER:=kenzok8
endef endef
define Package/$(PKG_NAME)/description define Package/$(PKG_NAME)/description
+4
View File
@@ -10,6 +10,10 @@ LUCI_DEPENDS:=+iperf3-ssl
PKG_LICENSE:=GPLv3 PKG_LICENSE:=GPLv3
define Package/luci-app-iperf/conffiles
/etc/config/iperf
endef
#include $(TOPDIR)/feeds/luci/luci.mk #include $(TOPDIR)/feeds/luci/luci.mk
include $(TOPDIR)/feeds/luci/luci.mk include $(TOPDIR)/feeds/luci/luci.mk
+6 -2
View File
@@ -28,8 +28,12 @@ function run_test(server,proto,mode,updown,omit,parallel,transmit,bitrate)
parallel = tostring(parallel or "1"):match("^%d+$") or "1" parallel = tostring(parallel or "1"):match("^%d+$") or "1"
omit = tostring(omit or "0"):match("^%d+$") or "0" omit = tostring(omit or "0"):match("^%d+$") or "0"
transmit = tostring(transmit or "10"):match("^%d+$") or "10" transmit = tostring(transmit or "10"):match("^%d+$") or "10"
bitrate = tostring(bitrate or "1M") -- The page asks for Mbit/s and sends a bare number, which iperf3
if not bitrate:match("^[0-9]+[KMG]?$") then -- reads as bit/s
bitrate = tostring(bitrate or "")
if bitrate:match("^%d+$") or bitrate:match("^%d+%.%d+$") then
bitrate = bitrate .. "M"
elseif not bitrate:match("^%d+[KMG]$") then
bitrate = "1M" bitrate = "1M"
end end
-- With credentials omr-iperf runs the test: it writes the key to its own -- With credentials omr-iperf runs the test: it writes the key to its own
+4
View File
@@ -10,6 +10,10 @@ LUCI_DEPENDS:=+msmtp-mta
PKG_LICENSE:=GPLv3 PKG_LICENSE:=GPLv3
define Package/luci-app-mail/conffiles
/etc/config/mail
endef
#include $(TOPDIR)/feeds/luci/luci.mk #include $(TOPDIR)/feeds/luci/luci.mk
include $(TOPDIR)/feeds/luci/luci.mk include $(TOPDIR)/feeds/luci/luci.mk
+2 -3
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-modem-manager-next PKG_NAME:=luci-app-modem-manager-next
PKG_VERSION:=1.0.0 PKG_VERSION:=1.0.0
PKG_RELEASE:=1 PKG_RELEASE:=2
PKG_LICENSE:=MIT PKG_LICENSE:=MIT
PKG_MAINTAINER:=Dante <dante@seckv.com> PKG_MAINTAINER:=Dante <dante@seckv.com>
@@ -12,8 +12,7 @@ LUCI_DESCRIPTION:=Provides a modern LuCI Web management interface for 4G/5G cell
PKG_DESCRIPTION:=Provides a modern LuCI Web management interface for 4G/5G cellular modems. PKG_DESCRIPTION:=Provides a modern LuCI Web management interface for 4G/5G cellular modems.
LUCI_DEPENDS:=+ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-uci \ LUCI_DEPENDS:=+ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-uci \
+kmod-usb-net +kmod-usb-serial +kmod-usb-serial-option \ +kmod-usb-net +kmod-usb-serial +kmod-usb-serial-option \
+kmod-usb-net-cdc-ncm +kmod-usb-net-cdc-mbim +kmod-usb-net-cdc-ether +kmod-usb-net-qmi-wwan \ +kmod-usb-net-cdc-ncm +kmod-usb-net-cdc-ether +sms-tool
+umbim +uqmi +sms-tool
LUCI_PKGARCH:=all LUCI_PKGARCH:=all
include $(TOPDIR)/feeds/luci/luci.mk include $(TOPDIR)/feeds/luci/luci.mk
@@ -58,18 +58,25 @@ return view.extend({
const self = this; const self = this;
if (!self.selectedPort) { if (!self.selectedPort) {
ui.addNotification(null, E('p', {}, _('Please select a valid modem port first.')), 'error'); ui.addNotification(null, E('p', {}, _('Please select a valid modem port first.')), 'error');
return; return Promise.resolve();
} }
ui.showModal(_('Executing Action'), [ ui.showModal(_('Executing Action'), [
E('p', { 'class': 'spinning' }, _('Sending action to cellular module, please wait...')) E('p', { 'class': 'spinning' }, _('Sending action to cellular module, please wait...'))
]); ]);
callDeviceAction(self.selectedPort, action).then(function(res) { return callDeviceAction(self.selectedPort, action).then(function(res) {
ui.hideModal(); ui.hideModal();
if (res && res.success) { if (res && res.success) {
ui.addNotification(null, E('p', {}, _('Action executed successfully.')), 'info'); if (action === 'reboot') {
self.forceRefresh(); ui.addNotification(null, E('p', {}, _('Modem reboot signal sent successfully. The module is rebooting, cellular network will reinitialize in 15-30 seconds.')), 'info');
window.setTimeout(function() {
self.forceRefresh();
}, 20000);
} else {
ui.addNotification(null, E('p', {}, _('Action executed successfully.')), 'info');
self.forceRefresh();
}
} else { } else {
ui.addNotification(null, E('p', {}, _('Action failed: ') + (res ? res.output : _('Unknown error'))), 'error'); ui.addNotification(null, E('p', {}, _('Action failed: ') + (res ? res.output : _('Unknown error'))), 'error');
} }
@@ -81,18 +88,33 @@ return view.extend({
forceRefresh: function() { forceRefresh: function() {
const self = this; const self = this;
if (!self.selectedPort) return; return callGetDevices().then(function(devRes) {
return callRefreshStatus(self.selectedPort, self.selectedNet).then(function(res) { const devs = Array.isArray(devRes) ? devRes : ((devRes && Array.isArray(devRes.devices)) ? devRes.devices : []);
self.applyStatusDOM(res); if (devs.length > 0) {
self.selectedPort = devs[0].primary_port;
self.selectedNet = devs[0].primary_net;
}
if (!self.selectedPort) return;
return callRefreshStatus(self.selectedPort, self.selectedNet).then(function(res) {
if (res && res.port && res.port !== self.selectedPort) {
self.selectedPort = res.port;
}
self.applyStatusDOM(res);
});
}).catch(function(err) {
if (!self.selectedPort) return;
return callRefreshStatus(self.selectedPort, self.selectedNet).then(function(res) {
self.applyStatusDOM(res);
});
}); });
}, },
updateStatus: function() { updateStatus: function() {
const self = this; const self = this;
if (!self.selectedPort)
return Promise.resolve();
return callGetStatus(self.selectedPort, self.selectedNet).then(function(res) { return callGetStatus(self.selectedPort, self.selectedNet).then(function(res) {
if (res && res.port && res.port !== self.selectedPort) {
self.selectedPort = res.port;
}
self.applyStatusDOM(res); self.applyStatusDOM(res);
}).catch(function(e) {}); }).catch(function(e) {});
}, },
@@ -21,6 +21,12 @@ const callSaveConfig = rpc.declare({
params: [ 'port', 'baud' ] params: [ 'port', 'baud' ]
}); });
const callDeviceAction = rpc.declare({
object: 'luci.modem',
method: 'device_action',
params: [ 'port', 'action' ]
});
const callSetMode = rpc.declare({ const callSetMode = rpc.declare({
object: 'luci.modem', object: 'luci.modem',
method: 'set_mode', method: 'set_mode',
@@ -151,13 +157,20 @@ return view.extend({
} }
} }
const modeChanged = (self.currentDriverMode && modeVal !== self.currentDriverMode);
ui.showModal(_('Applying Configuration'), [ ui.showModal(_('Applying Configuration'), [
E('p', { 'class': 'spinning' }, _('Saving port settings and sending configuration commands, please wait...')) E('p', { 'class': 'spinning' }, _('Saving port settings and sending configuration commands, please wait...'))
]); ]);
callSaveConfig(targetPort, baudVal) const portToSave = modeChanged ? '' : targetPort;
callSaveConfig(portToSave, baudVal)
.then(function() { .then(function() {
return callSetMode(targetPort, modeVal); if (modeChanged) {
return callSetMode(targetPort, modeVal);
}
return Promise.resolve({ success: true });
}) })
.then(function() { .then(function() {
return callSetPreferences(targetPort, ratVal, bandsLte, bandsNr); return callSetPreferences(targetPort, ratVal, bandsLte, bandsNr);
@@ -169,11 +182,22 @@ return view.extend({
return callSetCellLock(targetPort, cellLockMode, cellLockEarfcn, cellLockPci); return callSetCellLock(targetPort, cellLockMode, cellLockEarfcn, cellLockPci);
}) })
.then(function(lockRes) { .then(function(lockRes) {
ui.hideModal(); if (modeChanged) {
if (lockRes && lockRes.success === false) { ui.showModal(_('Switching Operation Mode'), [
ui.addNotification(null, E('p', {}, _('Settings saved, but cell lock failed: ') + (lockRes.error || lockRes.output || '')), 'warning'); E('p', { 'class': 'spinning' }, _('Operation mode updated. The module is rebooting, please wait 20 seconds for bus re-enumeration and port re-scan...'))
]);
callDeviceAction(targetPort, 'reboot').finally(function() {
window.setTimeout(function() {
location.reload();
}, 20000);
});
} else { } else {
ui.addNotification(null, E('p', {}, _('Modem settings successfully applied.')), 'info'); ui.hideModal();
if (lockRes && lockRes.success === false) {
ui.addNotification(null, E('p', {}, _('Settings saved, but cell lock failed: ') + (lockRes.error || lockRes.output || '')), 'warning');
} else {
ui.addNotification(null, E('p', {}, _('Modem settings successfully applied.')), 'info');
}
} }
}) })
.catch(function(err) { .catch(function(err) {
@@ -314,29 +338,45 @@ return view.extend({
container.appendChild(portSection); container.appendChild(portSection);
// Protocol Mode Switching Section // Protocol Mode Switching Section
let currentDriverMode = 'ncm';
if (dev.driver === 'cdc_mbim') currentDriverMode = 'mbim';
else if (dev.driver === 'qmi_wwan') currentDriverMode = 'qmi';
else if (dev.driver === 'cdc_ether') currentDriverMode = 'ecm';
else if (dev.driver === 'rndis_host') currentDriverMode = 'rndis';
else if (dev.driver === 'cdc_ncm') currentDriverMode = 'ncm';
self.currentDriverMode = currentDriverMode;
const supportedModes = ['ncm', 'ecm'];
const modeLabels = {
'ncm': _('NCM Mode (Recommended)'),
'ecm': _('ECM Mode')
};
const modeRadioNodes = [];
supportedModes.forEach(function(m) {
const isChecked = (currentDriverMode === m);
modeRadioNodes.push(E('label', { 'style': 'margin-right: 25px; font-weight: ' + (m === 'ncm' ? 'bold' : 'normal') + ';' }, [
E('input', {
'type': 'radio',
'name': 'modem_mode',
'value': m,
'checked': isChecked ? 'checked' : null
}), ' ',
modeLabels[m] || m.toUpperCase()
]));
});
const modeSection = E('fieldset', { 'class': 'cbi-section' }, [ const modeSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Driver Protocol and Operation Mode')), E('legend', {}, _('Driver Protocol and Operation Mode')),
E('div', { 'class': 'cbi-section-node' }, [ E('div', { 'class': 'cbi-section-node' }, [
E('div', { 'class': 'cbi-value' }, [ E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Driver Mode')), E('label', { 'class': 'cbi-value-title' }, _('Driver Mode')),
E('div', { 'class': 'cbi-value-field' }, [ E('div', { 'class': 'cbi-value-field' }, [
E('label', { 'style': 'margin-right: 20px;' }, [ E('div', { 'style': 'display: flex; flex-wrap: wrap; gap: 15px; align-items: center;' }, modeRadioNodes),
E('input', { 'type': 'radio', 'name': 'modem_mode', 'value': 'ncm', 'checked': 'checked' }), ' ', E('div', { 'class': 'cbi-value-description', 'style': 'margin-top: 8px;' },
_('NCM Mode') _('Switching the operation mode requires restarting the modem to take effect. NCM provides high-speed throughput with low CPU overhead, while ECM provides standard compatibility.')
]), )
E('label', { 'style': 'margin-right: 20px;' }, [
E('input', { 'type': 'radio', 'name': 'modem_mode', 'value': 'mbim' }), ' ',
_('MBIM Mode')
]),
E('label', { 'style': 'margin-right: 20px;' }, [
E('input', { 'type': 'radio', 'name': 'modem_mode', 'value': 'qmi' }), ' ',
_('QMI Mode')
]),
E('label', {}, [
E('input', { 'type': 'radio', 'name': 'modem_mode', 'value': 'ecm' }), ' ',
_('ECM Mode')
]),
E('div', { 'class': 'cbi-value-description' }, _('Switching the operation mode requires restarting the modem to take effect.'))
]) ])
]) ])
]) ])
@@ -156,7 +156,7 @@ return view.extend({
}, [ }, [
E('div', { 'style': 'display: flex; align-items: center; gap: 20px;' }, [ E('div', { 'style': 'display: flex; align-items: center; gap: 20px;' }, [
E('div', {}, [ E('div', {}, [
E('span', { 'style': 'color: #333; font-size: 13px;' }, _('SIM Card Storage Capacity: ')), E('span', { 'style': 'color: #333; font-size: 13px;' }, _('SIM Card Storage Capacity') + ': '),
E('strong', { 'style': 'color: #333; font-size: 14px;' }, E('strong', { 'style': 'color: #333; font-size: 14px;' },
self.smsCapacity.total > 0 ? (self.smsCapacity.used + ' / ' + self.smsCapacity.total + ' ' + _('Messages')) : (self.smsList.length + ' ' + _('Messages'))) self.smsCapacity.total > 0 ? (self.smsCapacity.used + ' / ' + self.smsCapacity.total + ' ' + _('Messages')) : (self.smsList.length + ' ' + _('Messages')))
]), ]),
@@ -193,6 +193,9 @@ msgstr "正在向模组发送操作指令,请稍候..."
msgid "Action executed successfully." msgid "Action executed successfully."
msgstr "动作执行成功。" msgstr "动作执行成功。"
msgid "Modem reboot signal sent successfully. The module is rebooting, cellular network will reinitialize in 15-30 seconds."
msgstr "模组重启指令已成功下发。模组正在重启,蜂窝网络将在 15-30 秒内重新初始化。"
msgid "Action failed: " msgid "Action failed: "
msgstr "动作执行失败: " msgstr "动作执行失败: "
@@ -238,17 +241,20 @@ msgstr "MBIM模式"
msgid "NCM Mode" msgid "NCM Mode"
msgstr "NCM模式" msgstr "NCM模式"
msgid "QMI Mode" msgid "NCM Mode (Recommended)"
msgstr "QMI模式" msgstr "推荐NCM模式"
msgid "ECM Mode" msgid "ECM Mode"
msgstr "ECM模式" msgstr "ECM模式"
msgid "RNDIS Mode" msgid "Switching the operation mode requires restarting the modem to take effect. NCM provides high-speed throughput with low CPU overhead, while ECM provides standard compatibility."
msgstr "RNDIS模式" msgstr "切换工作模式需要重启模组生效。NCM模式具备低CPU负载的高速聚合吞吐,ECM模式提供通用以太网兼容性。"
msgid "Switching the operation mode requires restarting the modem to take effect." msgid "Switching Operation Mode"
msgstr "切换工作模式后需要重新复位模组方能使新驱动生效。" msgstr "切换工作模式"
msgid "Operation mode updated. The module is rebooting, please wait 20 seconds for bus re-enumeration and port re-scan..."
msgstr "工作模式已更新。模组正在重启,请等待20秒以完成总线重枚举与端口重新扫描..."
msgid "Sync OpenWrt Network" msgid "Sync OpenWrt Network"
msgstr "同步网络配置" msgstr "同步网络配置"
@@ -490,6 +496,9 @@ msgstr "停用功能"
msgid "SIM Card Storage Capacity: " msgid "SIM Card Storage Capacity: "
msgstr "SIM卡存储容量: " msgstr "SIM卡存储容量: "
msgid "SIM Card Storage Capacity"
msgstr "SIM卡存储容量"
msgid "Messages" msgid "Messages"
msgstr "条短信" msgstr "条短信"
@@ -193,6 +193,9 @@ msgstr "正在向模组发送操作指令,请稍候..."
msgid "Action executed successfully." msgid "Action executed successfully."
msgstr "动作执行成功。" msgstr "动作执行成功。"
msgid "Modem reboot signal sent successfully. The module is rebooting, cellular network will reinitialize in 15-30 seconds."
msgstr "模组重启指令已成功下发。模组正在重启,蜂窝网络将在 15-30 秒内重新初始化。"
msgid "Action failed: " msgid "Action failed: "
msgstr "动作执行失败: " msgstr "动作执行失败: "
@@ -238,17 +241,20 @@ msgstr "MBIM模式"
msgid "NCM Mode" msgid "NCM Mode"
msgstr "NCM模式" msgstr "NCM模式"
msgid "QMI Mode" msgid "NCM Mode (Recommended)"
msgstr "QMI模式" msgstr "推荐NCM模式"
msgid "ECM Mode" msgid "ECM Mode"
msgstr "ECM模式" msgstr "ECM模式"
msgid "RNDIS Mode" msgid "Switching the operation mode requires restarting the modem to take effect. NCM provides high-speed throughput with low CPU overhead, while ECM provides standard compatibility."
msgstr "RNDIS模式" msgstr "切换工作模式需要重启模组生效。NCM模式具备低CPU负载的高速聚合吞吐,ECM模式提供通用以太网兼容性。"
msgid "Switching the operation mode requires restarting the modem to take effect." msgid "Switching Operation Mode"
msgstr "切换工作模式后需要重新复位模组方能使新驱动生效。" msgstr "切换工作模式"
msgid "Operation mode updated. The module is rebooting, please wait 20 seconds for bus re-enumeration and port re-scan..."
msgstr "工作模式已更新。模组正在重启,请等待20秒以完成总线重枚举与端口重新扫描..."
msgid "Sync OpenWrt Network" msgid "Sync OpenWrt Network"
msgstr "同步网络配置" msgstr "同步网络配置"
@@ -490,6 +496,9 @@ msgstr "停用功能"
msgid "SIM Card Storage Capacity: " msgid "SIM Card Storage Capacity: "
msgstr "SIM卡存储容量: " msgstr "SIM卡存储容量: "
msgid "SIM Card Storage Capacity"
msgstr "SIM卡存储容量"
msgid "Messages" msgid "Messages"
msgstr "条短信" msgstr "条短信"
@@ -79,13 +79,43 @@ fi
# Fast path: Prioritize native sms_tool engine if installed (zero echo loop, robust termios) # Fast path: Prioritize native sms_tool engine if installed (zero echo loop, robust termios)
if command -v sms_tool >/dev/null 2>&1; then if command -v sms_tool >/dev/null 2>&1; then
if [ -n "$SMS_TO" ]; then TMP_OUT="/tmp/at_sms_$$.tmp"
OUT=$(sms_tool -d "$DEVICE" -b "$BAUDRATE" send "$SMS_TO" "$SMS_MSG" 2>&1) TMP_DONE="/tmp/at_sms_$$.done"
RC=$? rm -f "$TMP_OUT" "$TMP_DONE"
(
if [ -n "$SMS_TO" ]; then
sms_tool -d "$DEVICE" -b "$BAUDRATE" send "$SMS_TO" "$SMS_MSG" > "$TMP_OUT" 2>&1
else
sms_tool -d "$DEVICE" -b "$BAUDRATE" at "$COMMAND" > "$TMP_OUT" 2>&1
fi
echo $? > "$TMP_DONE"
) &
CHILD_PID=$!
elapsed=0
while [ $elapsed -lt "$TIMEOUT" ]; do
if [ -f "$TMP_DONE" ] || ! kill -0 "$CHILD_PID" 2>/dev/null; then
break
fi
sleep 1
elapsed=$((elapsed + 1))
done
if [ ! -f "$TMP_DONE" ] && kill -0 "$CHILD_PID" 2>/dev/null; then
kill -9 "$CHILD_PID" 2>/dev/null || true
for p in $(pgrep -f "sms_tool.*$DEVICE" 2>/dev/null); do
kill -9 "$p" 2>/dev/null || true
done
RC=124
OUT="ERROR: Command timed out after ${TIMEOUT}s"
else else
OUT=$(sms_tool -d "$DEVICE" -b "$BAUDRATE" at "$COMMAND" 2>&1) wait "$CHILD_PID" 2>/dev/null
RC=$? RC=$(cat "$TMP_DONE" 2>/dev/null || echo 0)
OUT=$(cat "$TMP_OUT" 2>/dev/null)
fi fi
rm -f "$TMP_OUT" "$TMP_DONE"
flock -u 9 2>/dev/null || true flock -u 9 2>/dev/null || true
CLEAN_OUT=$(echo "$OUT" | tr -d "\r") CLEAN_OUT=$(echo "$OUT" | tr -d "\r")
if [ $RC -eq 0 ] && ! echo "$CLEAN_OUT" | grep -qE "ERROR|\+CME ERROR|\+CMS ERROR"; then if [ $RC -eq 0 ] && ! echo "$CLEAN_OUT" | grep -qE "ERROR|\+CME ERROR|\+CMS ERROR"; then
@@ -100,6 +130,14 @@ if command -v sms_tool >/dev/null 2>&1; then
fi fi
exit 0 exit 0
else else
case "$COMMAND" in
*CFUN=1,1*|*CFUN=16*|*QPOWD*)
if ! echo "$CLEAN_OUT" | grep -qE "ERROR|\+CME ERROR|\+CMS ERROR"; then
[ -z "$CLEAN_OUT" ] && echo "OK" || echo "$CLEAN_OUT"
exit 0
fi
;;
esac
echo "$CLEAN_OUT" echo "$CLEAN_OUT"
exit ${RC:-1} exit ${RC:-1}
fi fi
@@ -39,11 +39,14 @@ collect_single() {
let devs_data = plugin.get_devices.call(); let devs_data = plugin.get_devices.call();
let dev = (devs_data?.devices && length(devs_data.devices) > 0) ? devs_data.devices[0] : null; let dev = (devs_data?.devices && length(devs_data.devices) > 0) ? devs_data.devices[0] : null;
let user_port = "'"$user_port"'"; let user_port = "'"$user_port"'";
let target_port = (user_port && user_port != "") ? user_port : (dev?.primary_port || null); let target_port = dev?.primary_port || user_port || null;
if (dev?.driver !== "cdc_mbim" && user_port && user_port != "") {
target_port = user_port;
}
if (target_port) { if (target_port) {
let status = plugin.collect_raw_status.call({ args: { port: target_port, primary_net: dev?.primary_net || "usb0" } }); let status = plugin.collect_raw_status.call({ args: { port: target_port, primary_net: dev?.primary_net || "usb0" } });
if (status) { if (status && status.success) {
let tmp = "/tmp/run/modem-manager/status.json.tmp"; let tmp = "/tmp/run/modem-manager/status.json.tmp";
fs.writefile(tmp, sprintf("%J\n", status)); fs.writefile(tmp, sprintf("%J\n", status));
fs.rename(tmp, "/tmp/run/modem-manager/status.json"); fs.rename(tmp, "/tmp/run/modem-manager/status.json");
@@ -116,6 +116,19 @@ detect_all() {
local PROD=$(get_sys_attr "$dev/product") local PROD=$(get_sys_attr "$dev/product")
local SER=$(get_sys_attr "$dev/serial") local SER=$(get_sys_attr "$dev/serial")
local DRIVER="option"
for net_iface in $NETS; do
if [ -e "/sys/class/net/$net_iface/device/driver" ]; then
local drv_target=$(basename "$(readlink -f "/sys/class/net/$net_iface/device/driver" 2>/dev/null)")
case "$drv_target" in
cdc_ncm|cdc_ether|cdc_mbim|qmi_wwan)
DRIVER="$drv_target"
break
;;
esac
fi
done
local PRIMARY_TTY="" local PRIMARY_TTY=""
for t in $TTYS; do for t in $TTYS; do
case "$t" in case "$t" in
@@ -125,20 +138,19 @@ detect_all() {
;; ;;
esac esac
done done
if [ -z "$PRIMARY_TTY" ]; then
for t in $TTYS; do
case "$t" in
*ttyUSB3)
PRIMARY_TTY="$t"
break
;;
esac
done
fi
[ -z "$PRIMARY_TTY" ] && PRIMARY_TTY=$(echo "$TTYS" | awk '{print $1}') [ -z "$PRIMARY_TTY" ] && PRIMARY_TTY=$(echo "$TTYS" | awk '{print $1}')
local PRIMARY_NET=$(echo "$NETS" | awk '{print $1}') local PRIMARY_NET=$(echo "$NETS" | awk '{print $1}')
local DRIVER="option"
for drv in $(find "$dev" -name "driver" 2>/dev/null); do
local drv_target=$(basename "$(readlink -f "$drv" 2>/dev/null)")
case "$drv_target" in
cdc_mbim|cdc_ncm|qmi_wwan|cdc_ether|rndis_host)
DRIVER="$drv_target"
break
;;
esac
done
[ $FIRST -eq 1 ] || echo "," [ $FIRST -eq 1 ] || echo ","
FIRST=0 FIRST=0
@@ -154,6 +166,8 @@ detect_all() {
NET_JSON="$NET_JSON\"$n\"" NET_JSON="$NET_JSON\"$n\""
done done
local MODES_JSON="\"ncm\", \"ecm\""
cat <<EOF cat <<EOF
{ {
"id": "$DEVNAME", "id": "$DEVNAME",
@@ -165,6 +179,7 @@ detect_all() {
"driver": "$DRIVER", "driver": "$DRIVER",
"primary_port": "$PRIMARY_TTY", "primary_port": "$PRIMARY_TTY",
"primary_net": "$PRIMARY_NET", "primary_net": "$PRIMARY_NET",
"supported_modes": [ $MODES_JSON ],
"ports": [ $TTY_JSON ], "ports": [ $TTY_JSON ],
"nets": [ $NET_JSON ] "nets": [ $NET_JSON ]
} }
@@ -30,16 +30,54 @@ function touch_terminal_active() {
fs.writefile(TERMINAL_LOCK, 'active\n'); fs.writefile(TERMINAL_LOCK, 'active\n');
} }
function safe_exec_at(port, cmd, timeout) { function is_terminal_active() {
if (!port || !match(port, /^\/dev\/tty(USB|ACM)[0-9]+$/)) let st = fs.stat(TERMINAL_LOCK);
return { success: false, output: 'Invalid port parameter' }; if (st && st.mtime) {
let now = time();
if ((now - st.mtime) <= 15) {
return true;
}
}
return false;
}
function get_effective_port(specified_port) {
let dev_content = fs.readfile(CACHE_DEVICES);
let data = dev_content ? json(dev_content) : null;
let dev = (data?.devices && length(data.devices) > 0) ? data.devices[0] : null;
if (specified_port && match(specified_port, /^\/dev\/tty(USB|ACM)[0-9]+$/)) {
if (!dev || !dev.ports || index(dev.ports, specified_port) >= 0)
return specified_port;
}
let c = cursor();
if (c) {
c.load('modem-manager');
let p = c.get('modem-manager', 'global', 'port');
if (p && match(p, /^\/dev\/tty(USB|ACM)[0-9]+$/)) {
if (!dev || !dev.ports || index(dev.ports, p) >= 0)
return p;
}
}
if (dev && dev.primary_port)
return dev.primary_port;
return specified_port || '/dev/ttyUSB2';
}
function safe_exec_at(port, cmd, timeout) {
if (!cmd) if (!cmd)
return { success: false, output: 'Empty command' }; return { success: false, output: 'Empty command' };
let effective_port = get_effective_port(port);
if (!effective_port || !match(effective_port, /^\/dev\/tty(USB|ACM)[0-9]+$/))
return { success: false, output: 'Invalid port parameter' };
let to = (timeout && timeout > 0 && timeout <= 30) ? int(timeout) : 3; let to = (timeout && timeout > 0 && timeout <= 30) ? int(timeout) : 3;
let clean_cmd = replace(cmd, /'/g, "'\\''"); let clean_cmd = replace(cmd, /'/g, "'\\''");
let p = fs.popen(`${EXECUTOR_BIN} -d '${port}' -c '${clean_cmd}' -t ${to} 2>&1`, 'r'); let p = fs.popen(`${EXECUTOR_BIN} -d '${effective_port}' -c '${clean_cmd}' -t ${to} 2>&1`, 'r');
if (!p) if (!p)
return { success: false, output: 'Failed to execute command helper' }; return { success: false, output: 'Failed to execute command helper' };
@@ -51,25 +89,6 @@ function safe_exec_at(port, cmd, timeout) {
return { success: success, output: trim(output) }; return { success: success, output: trim(output) };
} }
function get_effective_port(specified_port) {
if (specified_port && match(specified_port, /^\/dev\/tty(USB|ACM)[0-9]+$/))
return specified_port;
let c = cursor();
if (c) {
c.load('modem-manager');
let p = c.get('modem-manager', 'global', 'port');
if (p && match(p, /^\/dev\/tty(USB|ACM)[0-9]+$/))
return p;
}
let dev_content = fs.readfile(CACHE_DEVICES);
if (dev_content) {
let data = json(dev_content);
if (data?.devices && length(data.devices) > 0 && data.devices[0].primary_port)
return data.devices[0].primary_port;
}
return null;
}
function decode_ucs2_if_needed(text) { function decode_ucs2_if_needed(text) {
if (!text || length(text) < 4 || (length(text) % 4) != 0) if (!text || length(text) < 4 || (length(text) % 4) != 0)
return text; return text;
@@ -206,11 +225,24 @@ function parse_serving_cell(raw_output) {
} }
function do_collect_status(port, net_dev) { function do_collect_status(port, net_dev) {
let get_fallback_or_yield = function() {
let cached = fs.readfile(CACHE_STATUS);
return cached ? json(cached) : { success: false, yielded: true };
};
if (is_terminal_active())
return get_fallback_or_yield();
let info_res = safe_exec_at(port, 'ATI', 2); let info_res = safe_exec_at(port, 'ATI', 2);
if (is_terminal_active()) return get_fallback_or_yield();
let cpin_res = safe_exec_at(port, 'AT+CPIN?', 2); let cpin_res = safe_exec_at(port, 'AT+CPIN?', 2);
if (is_terminal_active()) return get_fallback_or_yield();
let csq_res = safe_exec_at(port, 'AT+CSQ', 2); let csq_res = safe_exec_at(port, 'AT+CSQ', 2);
if (is_terminal_active()) return get_fallback_or_yield();
let cops_res = safe_exec_at(port, 'AT+COPS?', 2); let cops_res = safe_exec_at(port, 'AT+COPS?', 2);
if (is_terminal_active()) return get_fallback_or_yield();
let qeng_res = safe_exec_at(port, 'AT+QENG="servingcell"', 3); let qeng_res = safe_exec_at(port, 'AT+QENG="servingcell"', 3);
if (is_terminal_active()) return get_fallback_or_yield();
let temp_res = safe_exec_at(port, 'AT+QTEMP', 2); let temp_res = safe_exec_at(port, 'AT+QTEMP', 2);
let csq_data = parse_csq(csq_res.output); let csq_data = parse_csq(csq_res.output);
@@ -388,7 +420,7 @@ function do_collect_status(port, net_dev) {
temperature: temp, temperature: temp,
network: net_info, network: net_info,
raw_info: info_res.output, raw_info: info_res.output,
updated_at: fs.popen("date +%s", 'r')?.read('all')?.trim() || 0 updated_at: time()
}; };
} }
@@ -434,7 +466,9 @@ const methods = {
let user_port = c.get('modem-manager', 'global', 'port'); let user_port = c.get('modem-manager', 'global', 'port');
if (user_port && user_port != '' && res.devices && length(res.devices) > 0) { if (user_port && user_port != '' && res.devices && length(res.devices) > 0) {
res.devices[0].user_configured_port = user_port; res.devices[0].user_configured_port = user_port;
res.devices[0].primary_port = user_port; if (res.devices[0].driver !== 'cdc_mbim' || user_port === res.devices[0].primary_port) {
res.devices[0].primary_port = user_port;
}
} }
} }
@@ -464,7 +498,7 @@ const methods = {
collect_raw_status: { collect_raw_status: {
args: { port: '', primary_net: '' }, args: { port: '', primary_net: '' },
call: function(req) { call: function(req) {
let port = req.args?.port || '/dev/ttyUSB2'; let port = get_effective_port(req.args?.port);
let net_dev = req.args?.primary_net || 'usb0'; let net_dev = req.args?.primary_net || 'usb0';
return do_collect_status(port, net_dev); return do_collect_status(port, net_dev);
} }
@@ -473,7 +507,7 @@ const methods = {
get_status: { get_status: {
args: { port: '', primary_net: '' }, args: { port: '', primary_net: '' },
call: function(req) { call: function(req) {
let port = req.args?.port || '/dev/ttyUSB2'; let port = get_effective_port(req.args?.port);
let net_dev = req.args?.primary_net || 'usb0'; let net_dev = req.args?.primary_net || 'usb0';
// Non-blocking fast path: read decoupled background status cache directly // Non-blocking fast path: read decoupled background status cache directly
@@ -490,8 +524,10 @@ const methods = {
// Cold start fallback: synchronously collect once and write cache // Cold start fallback: synchronously collect once and write cache
let status = do_collect_status(port, net_dev); let status = do_collect_status(port, net_dev);
fs.mkdir(RUN_DIR); if (status && status.success) {
fs.writefile(CACHE_STATUS, sprintf("%J\n", status)); fs.mkdir(RUN_DIR);
fs.writefile(CACHE_STATUS, sprintf("%J\n", status));
}
return status; return status;
} }
}, },
@@ -499,11 +535,13 @@ const methods = {
refresh_status: { refresh_status: {
args: { port: '', primary_net: '' }, args: { port: '', primary_net: '' },
call: function(req) { call: function(req) {
let port = req.args?.port || '/dev/ttyUSB2'; let port = get_effective_port(req.args?.port);
let net_dev = req.args?.primary_net || 'usb0'; let net_dev = req.args?.primary_net || 'usb0';
let status = do_collect_status(port, net_dev); let status = do_collect_status(port, net_dev);
fs.mkdir(RUN_DIR); if (status && status.success) {
fs.writefile(CACHE_STATUS, sprintf("%J\n", status)); fs.mkdir(RUN_DIR);
fs.writefile(CACHE_STATUS, sprintf("%J\n", status));
}
return status; return status;
} }
}, },
@@ -522,10 +560,10 @@ const methods = {
touch_terminal_active(); touch_terminal_active();
log_message(`[AT] >>> ${cmd}`); log_message(`[AT] >>> ${cmd}`);
let t0 = fs.popen("date +%s%3N 2>/dev/null || date +%s", 'r')?.read('all')?.trim() || 0; let t0 = time();
let res = safe_exec_at(port, cmd, timeout); let res = safe_exec_at(port, cmd, timeout);
let t1 = fs.popen("date +%s%3N 2>/dev/null || date +%s", 'r')?.read('all')?.trim() || 0; let t1 = time();
let duration_ms = (t1 > 0 && t0 > 0 && t1 >= t0) ? (int(t1) - int(t0)) : 0; let duration_ms = (t1 >= t0) ? ((t1 - t0) * 1000) : 0;
log_message(`[AT] <<< ${res.output}`); log_message(`[AT] <<< ${res.output}`);
return { return {
@@ -558,11 +596,8 @@ const methods = {
touch_terminal_active(); touch_terminal_active();
let mode_map = { let mode_map = {
'qmi': '0', 'ncm': '5',
'ecm': '1', 'ecm': '1'
'mbim': '2',
'rndis': '3',
'ncm': '5'
}; };
let val = mode_map[mode] || '5'; let val = mode_map[mode] || '5';
let cmd = `AT+QCFG="usbnet",${val}`; let cmd = `AT+QCFG="usbnet",${val}`;
@@ -760,8 +795,10 @@ const methods = {
touch_terminal_active(); touch_terminal_active();
let cmd = 'AT'; let cmd = 'AT';
let timeout = 5;
if (action === 'reboot') { if (action === 'reboot') {
cmd = 'AT+CFUN=1,1'; cmd = 'AT+CFUN=1,1';
timeout = 10;
} else if (action === 'airplane_on') { } else if (action === 'airplane_on') {
cmd = 'AT+CFUN=4'; cmd = 'AT+CFUN=4';
} else if (action === 'airplane_off') { } else if (action === 'airplane_off') {
@@ -772,7 +809,14 @@ const methods = {
} }
log_message(`[ACTION] Executing ${action} via ${cmd}`); log_message(`[ACTION] Executing ${action} via ${cmd}`);
let res = safe_exec_at(port, cmd, 5); let res = safe_exec_at(port, cmd, timeout);
if (action === 'reboot') {
let is_err = (res.output && match(res.output, /\bERROR\b|\+CME ERROR|\+CMS ERROR/i) != null);
if (!is_err) {
return { success: true, output: res.output || 'OK' };
}
}
return { success: res.success, output: res.output }; return { success: res.success, output: res.output };
} }
+1
View File
@@ -6,6 +6,7 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI Support for MPTCP LUCI_TITLE:=LuCI Support for MPTCP
LUCI_DEPENDS:=+mptcp +ip-full +ss +nstat +tracebox +omr-sockdiag
PKG_LICENSE:=GPLv3 PKG_LICENSE:=GPLv3
#include $(TOPDIR)/feeds/luci/luci.mk #include $(TOPDIR)/feeds/luci/luci.mk
@@ -2,7 +2,7 @@
"luci-app-mptcp": { "luci-app-mptcp": {
"description": "Grant UCI access for luci-app-mptcp", "description": "Grant UCI access for luci-app-mptcp",
"read": { "read": {
"uci": [ "network", "mqvpn" ], "uci": [ "network" ],
"file": { "file": {
"/usr/lib/mptcpd": [ "list" ], "/usr/lib/mptcpd": [ "list" ],
"/usr/share/bpf/scheduler": [ "list" ], "/usr/share/bpf/scheduler": [ "list" ],
-16
View File
@@ -1,16 +0,0 @@
#
# Copyright (C) 2017 Dan Luedtke <mail@danrl.com>
#
# This is free software, licensed under the GNU General Public License v2.
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI support for the MWAN3 MultiWAN Manager
LUCI_DEPENDS:=+luci-base +mwan3-nft
PKG_LICENSE:=GPL-2.0
PKG_MAINTAINER:=Florian Eckert <fe@dev.tdt.de>
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -1,8 +0,0 @@
#mwan3-service-status > .alert-message {
display: inline-block;
margin: 1rem;
padding: 1rem;
width: 15rem;
height: 6rem;
vertical-align: middle;
}
@@ -1,48 +0,0 @@
'use strict';
'require form';
'require view';
return view.extend({
render: function () {
let m, s, o;
m = new form.Map('mwan3', _('MultiWAN Manager - Globals'));
s = m.section(form.NamedSection, 'globals', 'globals');
o = s.option(form.Value, 'mmx_mask', _('Firewall mask'),
_('Enter value in hex, starting with <code>0x</code>'));
o.datatype = 'hex(4)';
o.default = '0x3F00';
o = s.option(form.Flag, 'logging', _('Logging'),
_('Enables global firewall logging'));
o = s.option(form.ListValue, 'loglevel', _('Loglevel'),
_('Firewall loglevel'));
o.default = 'notice';
o.value('emerg', _('Emergency'));
o.value('alert', _('Alert'));
o.value('crit', _('Critical'));
o.value('error', _('Error'));
o.value('warning', _('Warning'));
o.value('notice', _('Notice'));
o.value('info', _('Info'));
o.value('debug', _('Debug'));
o.depends('logging', '1');
o = s.option(form.DynamicList, 'rt_table_lookup',
_('Routing table lookup'),
_('Also scan this Routing table for connected networks'));
o.datatype = 'uinteger';
o.value('220', _('Routing table %d').format('220'));
o = s.option(form.Flag, 'show_overview', _('Show on overview'),
_('Show MultiWAN status section on the overview page'));
o.default = o.enabled;
o.rmempty = false;
return m.render();
}
})
@@ -1,324 +0,0 @@
'use strict';
'require form';
'require fs';
'require view';
'require uci';
'require ui';
return view.extend({
load: function() {
return Promise.all([
L.resolveDefault(fs.stat('/usr/bin/httping'), {}),
L.resolveDefault(fs.stat('/usr/bin/nping'), {}),
L.resolveDefault(fs.stat('/usr/bin/arping'), {}),
uci.load('network')
]);
},
render: function (stats) {
let m, s, o;
m = new form.Map('mwan3', _('MultiWAN Manager - Interfaces'),
_('Mwan3 requires that all interfaces have a unique metric configured in /etc/config/network.') + '<br />' +
_('Names must match the interface name found in /etc/config/network.') + '<br />' +
_('Names may contain characters A-Z, a-z, 0-9, _ and no spaces-') + '<br />' +
_('Interfaces may not share the same name as configured members, policies or rules.'));
s = m.section(form.GridSection, 'interface');
s.addremove = true;
s.anonymous = false;
s.nodescriptions = true;
s.renderSectionAdd = function(extra_class) {
var usedNames = [
...uci.sections('mwan3', 'interface'),
...uci.sections('mwan3', 'member'),
...uci.sections('mwan3', 'policy'),
...uci.sections('mwan3', 'rule')
].map(function(sec) { return sec['.name']; });
var available = uci.sections('network', 'interface')
.map(function(sec) { return sec['.name']; })
.filter(function(name) {
return name !== 'loopback' &&
name.length <= 15 &&
usedNames.indexOf(name) === -1;
});
var el = E('div', { 'class': 'cbi-section-create' + (extra_class ? ' ' + extra_class : '') }, [
E('div', { 'class': 'cbi-section-create-name-container' }, [
E('select', {
'class': 'cbi-section-create-name',
'style': 'min-width: 12em'
}, available.length
? available.map(function(name) {
return E('option', { 'value': name }, name);
})
: [E('option', { 'value': '', 'disabled': true, 'selected': true },
_('No available interfaces'))]
),
E('button', {
'class': 'cbi-button cbi-button-add',
'title': _('Add'),
'disabled': available.length === 0 || null,
'click': ui.createHandlerFn(this, function(ev) {
var select = ev.target.closest('.cbi-section-create')
.querySelector('select.cbi-section-create-name');
var name = select ? select.value : '';
if (!name) return;
return this.handleAdd(ev, name);
})
}, _('Add'))
])
]);
return el;
};
o = s.option(form.Flag, 'enabled', _('Enabled'));
o.default = false;
o = s.option(form.ListValue, 'initial_state', _('Initial state'),
_('Expect interface state on up event'));
o.default = 'online';
o.value('online', _('Online'));
o.value('offline', _('Offline'));
o.modalonly = true;
o = s.option(form.ListValue, 'family', _('Internet Protocol'));
o.default = 'ipv4';
o.value('ipv4', _('IPv4'));
o.value('ipv6', _('IPv6'));
o.modalonly = true;
o = s.option(form.DynamicList, 'track_ip', _('Tracking hostname or IP address'),
_('This hostname or IP address will be pinged to determine if the link is up or down. Leave blank to assume interface is always online'));
o.datatype = 'host';
o.modalonly = true;
o = s.option(form.ListValue, 'track_method', _('Tracking method'));
o.default = 'ping';
o.value('ping');
if (stats[0].type === 'file') {
o.value('httping');
}
if (stats[1].type === 'file') {
o.value('nping-tcp');
o.value('nping-udp');
o.value('nping-icmp');
o.value('nping-arp');
}
if (stats[2].type === 'file') {
o.value('arping');
}
o = s.option(form.Flag, 'httping_ssl', _('Enable ssl tracking'),
_('Enables https tracking on ssl port 443'));
o.depends('track_method', 'httping');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'reliability', _('Tracking reliability'),
_('Acceptable values: 1-100. This many Tracking IP addresses must respond for the link to be deemed up'));
o.datatype = 'range(1, 100)';
o.default = '1';
o = s.option(form.ListValue, 'count', _('Ping count'));
o.default = '1';
o.value('1');
o.value('2');
o.value('3');
o.value('4');
o.value('5');
o.modalonly = true;
o = s.option(form.Value, 'size', _('Ping size'));
o.default = '56';
o.depends('track_method', 'ping');
o.value('8');
o.value('24');
o.value('56');
o.value('120');
o.value('248');
o.value('504');
o.value('1016');
o.value('1472');
o.value('2040');
o.datatype = 'range(1, 65507)';
o.modalonly = true;
o =s.option(form.Value, 'max_ttl', _('Max TTL'));
o.default = '60';
o.depends('track_method', 'ping');
o.value('10');
o.value('20');
o.value('30');
o.value('40');
o.value('50');
o.value('60');
o.value('70');
o.datatype = 'range(1, 255)';
o.modalonly = true;
o = s.option(form.Flag, 'check_quality', _('Check link quality'));
o.depends('track_method', 'ping');
o.default = false;
o.modalonly = true;
o = s.option(form.Value, 'failure_latency', _('Failure latency [ms]'));
o.depends('check_quality', '1');
o.default = '1000';
o.value('25');
o.value('50');
o.value('75');
o.value('100');
o.value('150');
o.value('200');
o.value('250');
o.value('300');
o.modalonly = true;
o = s.option(form.Value, 'failure_loss', _('Failure packet loss [%]'));
o.depends('check_quality', '1');
o.default = '40';
o.value('2');
o.value('5');
o.value('10');
o.value('20');
o.value('25');
o.modalonly = true;
o = s.option(form.Value, 'recovery_latency', _('Recovery latency [ms]'));
o.depends('check_quality', '1');
o.default = '500';
o.value('25');
o.value('50');
o.value('75');
o.value('100');
o.value('150');
o.value('200');
o.value('250');
o.value('300');
o.modalonly = true;
o = s.option(form.Value, 'recovery_loss', _('Recovery packet loss [%]'));
o.depends('check_quality', '1');
o.default = '10';
o.value('2');
o.value('5');
o.value('10');
o.value('20');
o.value('25');
o.modalonly = true;
o = s.option(form.ListValue, "timeout", _("Ping timeout"));
o.default = '4';
o.value('1', _('%d second').format('1'));
for (var i = 2; i <= 10; i++)
o.value(String(i), _('%d seconds').format(i));
o.modalonly = true;
o = s.option(form.ListValue, 'interval', _('Ping interval'));
o.default = '10';
o.value('1', _('%d second').format('1'));
o.value('3', _('%d seconds').format('3'));
o.value('5', _('%d seconds').format('5'));
o.value('10', _('%d seconds').format('10'));
o.value('20', _('%d seconds').format('20'));
o.value('30', _('%d seconds').format('30'));
o.value('60', _('%d minute').format('1'));
o.value('300', _('%d minutes').format('5'));
o.value('600', _('%d minutes').format('10'));
o.value('900', _('%d minutes').format('15'));
o.value('1800', _('%d minutes').format('30'));
o.value('3600', _('%d hour').format('1'));
o = s.option(form.Value, 'failure_interval', _('Failure interval'),
_('Ping interval during failure detection'));
o.default = '5';
o.value('1', _('%d second').format('1'));
o.value('3', _('%d seconds').format('3'));
o.value('5', _('%d seconds').format('5'));
o.value('10', _('%d seconds').format('10'));
o.value('20', _('%d seconds').format('20'));
o.value('30', _('%d seconds').format('30'));
o.value('60', _('%d minute').format('1'));
o.value('300', _('%d minutes').format('5'));
o.value('600', _('%d minutes').format('10'));
o.value('900', _('%d minutes').format('15'));
o.value('1800', _('%d minutes').format('30'));
o.value('3600', _('%d hour').format('1'));
o.modalonly = true;
o = s.option(form.Flag, 'keep_failure_interval', _('Keep failure interval'),
_('Keep ping failure interval during failure state'));
o.default = false;
o.modalonly = true;
o = s.option(form.Value, 'recovery_interval', _('Recovery interval'),
_('Ping interval during failure recovering'));
o.default = '5';
o.value('1', _('%d second').format('1'));
o.value('3', _('%d seconds').format('3'));
o.value('5', _('%d seconds').format('5'));
o.value('10', _('%d seconds').format('10'));
o.value('20', _('%d seconds').format('20'));
o.value('30', _('%d seconds').format('30'));
o.value('60', _('%d minute').format('1'));
o.value('300', _('%d minutes').format('5'));
o.value('600', _('%d minutes').format('10'));
o.value('900', _('%d minutes').format('15'));
o.value('1800', _('%d minutes').format('30'));
o.value('3600', _('%d hour').format('1'));
o.modalonly = true;
o = s.option(form.ListValue, 'down', _('Interface down'),
_('Interface will be deemed down after this many failed ping tests'));
o.default = '5';
o.value('1');
o.value('2');
o.value('3');
o.value('4');
o.value('5');
o.value('6');
o.value('7');
o.value('8');
o.value('9');
o.value('10');
o = s.option(form.ListValue, 'up', _('Interface up'),
_('Downed interface will be deemed up after this many successful ping tests'));
o.default = "5";
o.value('1');
o.value('2');
o.value('3');
o.value('4');
o.value('5');
o.value('6');
o.value('7');
o.value('8');
o.value('9');
o.value('10');
o = s.option(form.DynamicList, 'flush_conntrack', _('Flush conntrack table'),
_('Flush global firewall conntrack table on interface events'));
o.value('ifup', _('ifup (netifd)'));
o.value('ifdown', _('ifdown (netifd)'));
o.value('connected', _('connected (mwan3)'));
o.value('disconnected', _('disconnected (mwan3)'));
o.modalonly = true;
o = s.option(form.DummyValue, 'metric', _('Metric'),
_('This displays the metric assigned to this interface in /etc/config/network'));
o.rawhtml = true;
o.cfgvalue = function(s) {
var metric = uci.get('network', s, 'metric')
if (metric)
return metric;
else
return _('No interface metric set!');
}
return m.render();
}
})
@@ -1,67 +0,0 @@
'use strict';
'require form';
'require view';
'require uci';
'require ui';
return view.extend({
load: function() {
return Promise.all([
uci.load('mwan3')
]);
},
render: function () {
let m, s, o;
m = new form.Map('mwan3', _('MultiWAN Manager - Members'),
_('Members are profiles attaching a metric and weight to an MWAN interface.') + '<br />' +
_('Names may contain characters A-Z, a-z, 0-9, _ and no spaces.') + '<br />' +
_('Members may not share the same name as configured interfaces, policies or rules.'));
s = m.section(form.GridSection, 'member');
s.addremove = true;
s.anonymous = false;
s.nodescriptions = true;
/* This name length error check can likely be removed when mwan3 migrates to nftables */
s.renderSectionAdd = function(extra_class) {
var el = form.GridSection.prototype.renderSectionAdd.apply(this, arguments),
nameEl = el.querySelector('.cbi-section-create-name');
ui.addValidator(nameEl, 'uciname', true, function(v) {
let sections = [
...uci.sections('mwan3', 'interface'),
...uci.sections('mwan3', 'member'),
...uci.sections('mwan3', 'policy'),
...uci.sections('mwan3', 'rule')
];
for (let j = 0; j < sections.length; j++) {
if (sections[j]['.name'] == v) {
return _('Members may not share the same name as configured interfaces, policies or rules.');
}
}
if (v.length > 15) return _('Name length shall not exceed 15 characters');
return true;
}, 'blur', 'keyup');
return el;
};
o = s.option(form.ListValue, 'interface', _('Interface'));
var options = uci.sections('mwan3', 'interface')
for (var i = 0; i < options.length; i++) {
var value = options[i]['.name'];
o.value(value);
}
o = s.option(form.Value, 'metric', _('Metric'),
_('Acceptable values: 1-256. Defaults to 1 if not set'));
o.datatype = 'range(1, 256)';
o = s.option(form.Value, 'weight', _('Weight'),
_('Acceptable values: 1-1000. Defaults to 1 if not set'));
o.datatype = 'range(1, 1000)';
return m.render();
}
})
@@ -1,52 +0,0 @@
'use strict';
'require view';
'require fs';
'require ui';
const isReadonlyView = !L.hasViewPermission() || null;
return view.extend({
load() {
return L.resolveDefault(fs.read('/etc/mwan3.user'), '');
},
handleSave(ev) {
const value = (document.querySelector('textarea').value || '').trim().replace(/\r\n/g, '\n') + '\n';
return fs.write('/etc/mwan3.user', value).then(function(rc) {
document.querySelector('textarea').value = value;
ui.addNotification(null, E('p', _('Contents have been saved.')), 'info');
}).catch(function(e) {
ui.addNotification(null, E('p', _('Unable to save contents: %s').format(e.message)));
});
},
render(mwan3user) {
return E([
E('h2', _('MultiWAN Manager - Notify')),
E('p', { 'class': 'cbi-section-descr' },
_('This section allows you to modify the content of "/etc/mwan3.user".') + '<br/>' +
_('The file is also preserved during sysupgrade.') + '<br/>' +
'<br />' +
_('Notes:') + '<br />' +
_('This file is interpreted as a shell script.') + '<br />' +
_('The first line of the script must be &#34;#!/bin/sh&#34; without quotes.') + '<br />' +
_('Lines beginning with # are comments and are not executed.') + '<br />' +
_('Put your custom mwan3 action here, they will be executed with each netifd hotplug interface event on interfaces for which mwan3 is enabled.') + '<br />' +
'<br />' +
_('There are three main environment variables that are passed to this script.') + '<br />' +
'<br />' +
_('%s: Name of the action that triggered this event').format('$ACTION') + '<br />' +
_('* %s: Is called by netifd and mwan3track').format('ifup') + '<br />' +
_('* %s: Is called by netifd and mwan3track').format('ifdown') + '<br />' +
_('* %s: Is only called by mwan3track if tracking was successful').format('connected') + '<br />' +
_('* %s: Is only called by mwan3track if tracking has failed').format('disonnected') + '<br />' +
_('%s: Name of the interface which went up or down (e.g. "wan" or "wwan")').format('$INTERFACE') + '<br />' +
_('%s: Name of Physical device which interface went up or down (e.g. "eth0" or "wwan0")').format('$DEVICE') + '<br />'),
E('p', {}, E('textarea', { 'style': 'width:100%', 'rows': 10, 'disabled': isReadonlyView }, [ mwan3user != null ? mwan3user : '' ]))
]);
},
handleSaveApply: null,
handleReset: null
});
@@ -1,70 +0,0 @@
'use strict';
'require form';
'require view';
'require uci';
'require ui';
return view.extend({
load: function() {
return Promise.all([
uci.load('mwan3')
]);
},
render: function () {
let m, s, o;
m = new form.Map('mwan3', _('MultiWAN Manager - Policies'),
_('Policies are profiles grouping one or more members controlling how Mwan3 distributes traffic.') + '<br />' +
_('Member interfaces with lower metrics are used first.') + '<br />' +
_('Member interfaces with the same metric will be load-balanced.') + '<br />' +
_('Load-balanced member interfaces distribute more traffic out those with higher weights.') + '<br />' +
_('Names may contain characters A-Z, a-z, 0-9, _ and no spaces.') + '<br />' +
_('Names must be 15 characters or less.') + '<br />' +
_('Policies may not share the same name as configured interfaces, members or rules'));
s = m.section(form.GridSection, 'policy');
s.addremove = true;
s.anonymous = false;
s.nodescriptions = true;
/* This name length error check can likely be removed when mwan3 migrates to nftables */
s.renderSectionAdd = function(extra_class) {
var el = form.GridSection.prototype.renderSectionAdd.apply(this, arguments),
nameEl = el.querySelector('.cbi-section-create-name');
ui.addValidator(nameEl, 'uciname', true, function(v) {
let sections = [
...uci.sections('mwan3', 'interface'),
...uci.sections('mwan3', 'member'),
...uci.sections('mwan3', 'policy'),
...uci.sections('mwan3', 'rule')
];
for (let j = 0; j < sections.length; j++) {
if (sections[j]['.name'] == v) {
return _('Policies may not share the same name as configured interfaces, members or rules');
}
}
if (v.length > 15) return _('Name length shall not exceed 15 characters');
return true;
}, 'blur', 'keyup');
return el;
};
o = s.option(form.DynamicList, 'use_member', _('Member used'));
var options = uci.sections('mwan3', 'member')
for (var i = 0; i < options.length; i++) {
var value = options[i]['.name'];
o.value(value);
}
o = s.option(form.ListValue, 'last_resort', _('Last resort'),
_('When all policy members are offline use this behavior for matched traffic'));
o.default = 'unreachable';
o.value('unreachable', _('unreachable (reject)'));
o.value('blackhole', _('blackhole (drop)'));
o.value('default', _('default (use main routing table)'));
return m.render();
}
})
@@ -1,132 +0,0 @@
'use strict';
'require form';
'require fs';
'require view';
'require uci';
'require ui';
return view.extend({
load: function() {
return Promise.all([
fs.exec_direct('/usr/libexec/luci-mwan3', ['nftset', 'dump']),
uci.load('mwan3')
]);
},
render: function (data) {
let m, s, o;
m = new form.Map('mwan3', _('MultiWAN Manager - Rules'),
_('Rules specify which traffic will use a particular MWAN policy.') + '<br />' +
_('Rules are based on IP address, port or protocol.') + '<br />' +
_('Rules are matched from top to bottom.') + '<br />' +
_('Rules below a matching rule are ignored.') + '<br />' +
_('Traffic not matching any rule is routed using the main routing table.') + '<br />' +
_('Traffic destined for known (other than default) networks is handled by the main routing table.') + '<br />' +
_('Traffic matching a rule, but all WAN interfaces for that policy are down will be blackholed.') + '<br />' +
_('Names may contain characters A-Z, a-z, 0-9, _ and no spaces.') + '<br />' +
_('Rules may not share the same name as configured interfaces, members or policies.'));
s = m.section(form.GridSection, 'rule');
s.addremove = true;
s.anonymous = false;
s.nodescriptions = true;
s.sortable = true;
/* This name length error check can likely be removed when mwan3 migrates to nftables */
s.renderSectionAdd = function(extra_class) {
const el = form.GridSection.prototype.renderSectionAdd.apply(this, arguments);
const nameEl = el.querySelector('.cbi-section-create-name');
ui.addValidator(nameEl, 'uciname', true, function(v) {
let sections = [
...uci.sections('mwan3', 'interface'),
...uci.sections('mwan3', 'member'),
...uci.sections('mwan3', 'policy'),
...uci.sections('mwan3', 'rule')
];
for (let j = 0; j < sections.length; j++) {
if (sections[j]['.name'] == v) {
return _('Rules may not share the same name as configured interfaces, members or policies.');
}
}
if (v.length > 15) return _('Name length shall not exceed 15 characters');
return true;
}, 'blur', 'keyup');
return el;
};
o = s.option(form.ListValue, 'family', _('Internet Protocol'));
o.default = '';
o.value('', _('IPv4 and IPv6'));
o.value('ipv4', _('IPv4 only'));
o.value('ipv6', _('IPv6 only'));
o.modalonly = true;
o = s.option(form.Value, 'proto', _('Protocol'),
_('View the content of /etc/protocols for protocol description'));
o.default = 'all';
o.rmempty = false;
o.value('all');
o.value('tcp');
o.value('udp');
o.value('icmp');
o.value('esp');
o = s.option(form.Value, 'src_ip', _('Source address'),
_('Supports CIDR notation (eg "192.168.100.0/24") without quotes'));
o.datatype = 'ipaddr';
o = s.option(form.Value, 'src_port', _('Source port'),
_('May be entered as a single or multiple port(s) (eg "22" or "80,443") or as a portrange (eg "1024:2048") without quotes'));
o.depends('proto', 'tcp');
o.depends('proto', 'udp');
o = s.option(form.Value, 'dest_ip', _('Destination address'),
_('Supports CIDR notation (eg "192.168.100.0/24") without quotes'));
o.datatype = 'ipaddr';
o = s.option(form.Value, 'dest_port', _('Destination port'),
_('May be entered as a single or multiple port(s) (eg "22" or "80,443") or as a portrange (eg "1024:2048") without quotes'));
o.depends('proto', 'tcp');
o.depends('proto', 'udp');
o = s.option(form.ListValue, 'sticky', _('Sticky'),
_('Traffic from the same source IP address that previously matched this rule within the sticky timeout period will use the same WAN interface'));
o.default = '0';
o.value('1', _('Yes'));
o.value('0', _('No'));
o.modalonly = true;
o = s.option(form.Value, 'timeout', _('Sticky timeout'),
_('Seconds. Acceptable values: 1-1000000. Defaults to 600 if not set'));
o.datatype = 'range(1, 1000000)';
o.modalonly = true;
o.depends('sticky', '1');
o = s.option(form.Value, 'ipset', _('IPset'),
_('Name of IPset rule. Requires IPset rule in /etc/dnsmasq.conf (eg "ipset=/youtube.com/youtube")'));
o.value('', _('-- Please choose --'));
let ipsets = data[0].split(/\n/);
for (let ips of ipsets) {
if (ips.length > 0)
o.value(ips);
}
o.modalonly = true;
o = s.option(form.Flag, 'logging', _('Logging'),
_('Enables firewall rule logging (global mwan3 logging must also be enabled)'));
o.modalonly = true;
o = s.option(form.ListValue, 'use_policy', _('Policy assigned'));
let options = uci.sections('mwan3', 'policy')
for (let opt of options) {
o.value(opt['.name']);
}
o.value('unreachable', _('unreachable (reject)'));
o.value('blackhole', _('blackhole (drop)'));
o.value('default', _('default (use main routing table)'));
return m.render();
}
})
@@ -1,22 +0,0 @@
'use strict';
'require fs';
'require view';
return view.extend({
load: function() {
return L.resolveDefault(fs.exec_direct('/usr/sbin/mwan3', [ 'status' ]),'');
},
render: function (report) {
return E('div', { 'class': 'cbi-map', 'id': 'map' }, [
E('h2', _('MultiWAN Manager - Status')),
E('div', { 'class': 'cbi-section' }, [
E('pre', [ report ])
]),
])
},
handleSaveApply: null,
handleSave: null,
handleReset: null
})
@@ -1,116 +0,0 @@
'use strict';
'require fs';
'require uci';
'require dom';
'require ui';
'require view';
return view.extend({
handleCommand: function(exec, args) {
var buttons = document.querySelectorAll('.cbi-button');
for (var i = 0; i < buttons.length; i++)
buttons[i].setAttribute('disabled', 'true');
return fs.exec(exec, args).then(function(res) {
var out = document.querySelector('.command-output');
out.style.display = '';
dom.content(out, [ res.stdout || '', res.stderr || '' ]);
}).catch(function(err) {
ui.addNotification(null, E('p', [ err ]))
}).finally(function() {
for (var i = 0; i < buttons.length; i++)
buttons[i].removeAttribute('disabled');
});
},
handleAction: function(ev) {
var iface = document.getElementById('iface');
var task = document.getElementById('task');
switch (task.value) {
case 'gateway':
return this.handleCommand('/usr/libexec/luci-mwan3',
[ 'diag', 'gateway', iface.value ]);
case 'tracking':
return this.handleCommand('/usr/libexec/luci-mwan3',
[ 'diag', 'tracking', iface.value ]);
case 'rules':
return this.handleCommand('/usr/libexec/luci-mwan3',
[ 'diag', 'rules', iface.value ]);
case 'routes':
return this.handleCommand('/usr/libexec/luci-mwan3',
[ 'diag', 'routes', iface.value ]);
case 'ifup':
return this.handleCommand('/usr/sbin/mwan3',
[ 'ifup', iface.value]);
case 'ifdown':
return this.handleCommand('/usr/sbin/mwan3',
[ 'ifdown', iface.value]);
}
},
load: function() {
return Promise.all([
uci.load('mwan3')
]);
},
render: function () {
var taskSel = [
E('option', { 'value': 'gateway' }, [ _('Ping default gateway') ]),
E('option', { 'value': 'tracking' }, [ _('Ping tracking IP') ]),
E('option', { 'value': 'rules' }, [ _('Check IP rules') ]),
E('option', { 'value': 'routes' }, [ _('Check routing table') ]),
E('option', { 'value': 'ifup' }, [ _('Hotplug ifup') ]),
E('option', { 'value': 'ifdown' }, [ _('Hotplug ifdown') ])
];
var ifaceSel = [E('option', { value: '' }, [_('-- Interface Selection --')])];
var options = uci.sections('mwan3', 'interface')
for (var i = 0; i < options.length; i++) {
ifaceSel.push(E('option', { 'value': options[i]['.name'] }, options[i]['.name']));
}
return E('div', { 'class': 'cbi-map', 'id': 'map' }, [
E('h2', {}, [ _('MultiWAN Manager - Diagnostics') ]),
E('div', { 'class': 'cbi-section' }, [
E('div', { 'class': 'cbi-section-node' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, [ _('Interface') ]),
E('div', { 'class': 'cbi-value-field' }, [
E('select', {'class': 'cbi-input-select', 'id': 'iface'},
ifaceSel
)
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, [ _('Task') ]),
E('div', { 'class': 'cbi-value-field' }, [
E('select', { 'class': 'cbi-input-select', 'id': 'task' },
taskSel
)
])
])
])
]),
'\xa0',
E('pre', { 'class': 'command-output', 'style': 'display:none' }),
'\xa0',
E('div', { 'class': 'right' }, [
E('button', {
'class': 'cbi-button cbi-button-apply',
'id': 'execute',
'click': ui.createHandlerFn(this, 'handleAction')
}, [ _('Execute') ]),
]),
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
})
@@ -1,109 +0,0 @@
'use strict';
'require poll';
'require view';
'require rpc';
const callMwan3Status = rpc.declare({
object: 'mwan3',
method: 'status',
params: ['section'],
expect: { },
});
function getStatusBackgroundClass(status) {
switch (status) {
case 'online':
return 'label success';
case 'offline':
return 'label warning';
case 'disabled':
return 'label';
case 'notracking':
return 'label';
default:
return 'label';
}
}
function renderMwan3Status(status) {
if (!status.interfaces)
return '<strong>%h</strong>'.format(_('No MWAN interfaces found'));
var tableHtml = '<table class="table cbi-section-table" style="width: 100%;">';
tableHtml += '<thead><tr class="tr cbi-section-table-titles">';
tableHtml += '<th class="th left">' + _('Interface') + '</th>';
tableHtml += '<th class="th left">' + _('Status') + '</th>';
tableHtml += '<th class="th left">' + _('Uptime') + '</th>';
tableHtml += '</tr></thead><tbody>';
for (var iface in status.interfaces) {
var state = '';
var time = '';
var statusType = status.interfaces[iface].status;
switch (statusType) {
case 'online':
state = _('Online');
time = '%t'.format(status.interfaces[iface].online);
break;
case 'offline':
state = _('Offline');
time = '%t'.format(status.interfaces[iface].offline);
break;
case 'notracking':
state = _('No Tracking');
if ((status.interfaces[iface].uptime) > 0) {
time = '%t'.format(status.interfaces[iface].uptime);
} else {
time = '';
}
break;
default:
state = _('Disabled');
time = '';
break;
}
var statusClass = getStatusBackgroundClass(statusType);
tableHtml += '<tr class="tr cbi-section-table-row">';
tableHtml += '<td class="td left"><strong>' + iface + '</strong></td>';
tableHtml += '<td class="td left"><span class="' + statusClass + '">' + state + '</span></td>';
tableHtml += '<td class="td left">' + (time || '') + '</td>';
tableHtml += '</tr>';
}
tableHtml += '</tbody></table>';
return tableHtml;
}
return view.extend({
load: function() {
return Promise.all([
callMwan3Status("interfaces"),
]);
},
render: function (data) {
poll.add(function() {
return callMwan3Status("interfaces").then(function(result) {
var view = document.getElementById('mwan3-service-status');
view.innerHTML = renderMwan3Status(result);
});
});
return E('div', { class: 'cbi-map' }, [
E('h2', [ _('MultiWAN Manager - Overview') ]),
E('div', { class: 'cbi-section' }, [
E('div', { 'id': 'mwan3-service-status' }, [
E('em', { 'class': 'spinning' }, [ _('Collecting data ...') ])
])
])
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -1,22 +0,0 @@
'use strict';
'require fs';
'require view';
return view.extend({
load: function() {
return L.resolveDefault(fs.exec_direct('/usr/sbin/mwan3', [ 'internal', 'ipv4' ]),'');
},
render: function (report) {
return E('div', { 'class': 'cbi-map', 'id': 'map' }, [
E('h2', _('MultiWAN Manager - Troubleshooting')),
E('div', { 'class': 'cbi-section' }, [
E('pre', [ report ])
]),
])
},
handleSaveApply: null,
handleSave: null,
handleReset: null
})
@@ -1,117 +0,0 @@
'use strict';
'require baseclass';
'require rpc';
'require uci';
const callMwan3Status = rpc.declare({
object: 'mwan3',
method: 'status',
params: ['section'],
expect: { },
});
function getStatusBackgroundClass(status) {
switch (status) {
case 'online':
return 'label success';
case 'offline':
return 'label warning';
case 'disabled':
return 'label';
case 'notracking':
return 'label';
default:
return 'label';
}
}
return baseclass.extend({
title: _('MultiWAN Manager'),
load: function() {
return uci.load('mwan3').then(function() {
var globals = uci.sections('mwan3', 'globals');
if (!globals || !globals[0] || globals[0].show_overview !== '1')
return null;
return Promise.all([
callMwan3Status("interfaces"),
]);
});
},
render: function (result) {
if (!result)
return null;
if (!result[0]?.interfaces || Object.keys(result[0].interfaces).length === 0)
return null;
// Основной контейнер
var container = E('div', { 'class': 'cbi-section' });
// Таблица с системными классами LuCI
var table = E('table', { 'class': 'table cbi-section-table' });
// Заголовок
var thead = E('tr', { 'class': 'tr cbi-section-table-titles' });
thead.appendChild(E('th', { 'class': 'th left' }, [ _('Interface') ]));
thead.appendChild(E('th', { 'class': 'th left' }, [ _('Status') ]));
thead.appendChild(E('th', { 'class': 'th left' }, [ _('Uptime') ]));
table.appendChild(thead);
// Тело таблицы
var tbody = E('tbody');
for (var iface in result[0].interfaces) {
var state = '';
var time = '';
var statusType = result[0].interfaces[iface].status;
switch (statusType) {
case 'online':
state = _('Online');
time = '%t'.format(result[0].interfaces[iface].online);
break;
case 'offline':
state = _('Offline');
time = '%t'.format(result[0].interfaces[iface].offline);
break;
case 'notracking':
state = _('No Tracking');
if ((result[0].interfaces[iface].uptime) > 0) {
time = '%t'.format(result[0].interfaces[iface].uptime);
} else {
time = '';
}
break;
default:
state = _('Disabled');
time = '';
break;
}
var tr = E('tr', { 'class': 'tr cbi-section-table-row' });
// Ячейка интерфейса
tr.appendChild(E('td', { 'class': 'td left' }, [
E('strong', {}, [ iface ])
]));
// Ячейка статуса с цветным фоном через системные классы label
var statusClass = getStatusBackgroundClass(statusType);
tr.appendChild(E('td', { 'class': 'td left' }, [
E('span', { 'class': statusClass }, [ state ])
]));
// Ячейка времени
tr.appendChild(E('td', { 'class': 'td left' }, [ time || '' ]));
tbody.appendChild(tr);
}
table.appendChild(tbody);
container.appendChild(table);
return container;
}
});
-886
View File
@@ -1,886 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: LuCI: mwan3\n"
"POT-Creation-Date: 2017-09-14 16:00+0300\n"
"PO-Revision-Date: 2026-05-02 12:03+0000\n"
"Last-Translator: SnIPeRSnIPeR "
"<snipersniper@users.noreply.hosted.weblate.org>\n"
"Language-Team: Russian <https://hosted.weblate.org/projects/openwrt/"
"luciapplicationsmwan3/ru/>\n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
"X-Generator: Weblate 5.17.1\n"
"Project-Info: Это технический перевод, не дословный. Главное-удобный русский "
"интерфейс, все проверялось в графическом режиме, совместим с другими apps\n"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:211
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:227
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:249
msgid "%d hour"
msgstr "%d час"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:206
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:222
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:244
msgid "%d minute"
msgstr "%d минута"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:207
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:208
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:209
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:210
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:223
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:224
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:225
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:226
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:245
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:246
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:247
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:248
msgid "%d minutes"
msgstr "%d минут"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:193
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:200
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:216
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:238
msgid "%d second"
msgstr "%d секунда"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:195
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:201
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:202
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:203
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:204
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:205
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:217
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:218
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:219
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:220
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:221
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:239
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:240
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:241
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:242
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:243
msgid "%d seconds"
msgstr "%d секунд"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:45
msgid ""
"%s: Name of Physical device which interface went up or down (e.g. \"eth0\" "
"or \"wwan0\")"
msgstr ""
"%s: Название физического устройства, интерфейс которого поднялся или упал "
"(прим. \"eth0\" или \"wwan0\")"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:39
msgid "%s: Name of the action that triggered this event"
msgstr "%s: Название действия вызвавшего это событие"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:44
msgid ""
"%s: Name of the interface which went up or down (e.g. \"wan\" or \"wwan\")"
msgstr ""
"%s: Название интерфейса который поднялся или упал (прим. \"wan\" или "
"\"wwan\")"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:40
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:41
msgid "* %s: Is called by netifd and mwan3track"
msgstr "* %s: Вызывается netifd и mwan3track"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:43
msgid "* %s: Is only called by mwan3track if tracking has failed"
msgstr ""
"* %s: Вызывается mwan3track только в том случае, если отслеживание не удалось"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:42
msgid "* %s: Is only called by mwan3track if tracking was successful"
msgstr "* %s: Вызывается mwan3track только в случае успешного отслеживания"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/diagnostics.js:71
msgid "-- Interface Selection --"
msgstr "-- Выбор интерфейса --"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:109
msgid "-- Please choose --"
msgstr "-- Выберите --"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:99
msgid ""
"Acceptable values: 1-100. This many Tracking IP addresses must respond for "
"the link to be deemed up"
msgstr ""
"Допустимые значения: 1–100. Количество IP-адресов, которое должно ответить "
"чтобы соединение было признано активным"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/member.js:62
msgid "Acceptable values: 1-1000. Defaults to 1 if not set"
msgstr "Допустимые значения: 1–1000. По умолчанию 1, если значение не задано"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/member.js:58
msgid "Acceptable values: 1-256. Defaults to 1 if not set"
msgstr "Допустимые значения: 1–256 По умолчанию 1, если значение не задано"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:26
msgid "Alert"
msgstr "Тревога"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:37
msgid "Also scan this Routing table for connected networks"
msgstr ""
"Также сканировать эту таблицу маршрутизации на предмет подключённых сетей"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/diagnostics.js:65
msgid "Check IP rules"
msgstr "Проверить правила IP"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:140
msgid "Check link quality"
msgstr "Проверка качества подключения"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/diagnostics.js:66
msgid "Check routing table"
msgstr "Проверить таблицу маршрутизации"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/overview.js:96
msgid "Collecting data ..."
msgstr "Сбор данных ..."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:18
msgid "Contents have been saved."
msgstr "Содержимое сохранено."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:27
msgid "Critical"
msgstr "Критический"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:32
msgid "Debug"
msgstr "Отладка"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:85
msgid "Destination address"
msgstr "Адрес назначения"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:89
msgid "Destination port"
msgstr "Порт назначения"
#: applications/luci-app-mwan3/root/usr/share/luci/menu.d/luci-app-mwan3.json:31
msgid "Diagnostics"
msgstr "Диагностика"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/overview.js:57
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/status/include/90_mwan3.js:66
msgid "Disabled"
msgstr "Отключено"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:267
msgid ""
"Downed interface will be deemed up after this many successful ping tests"
msgstr ""
"Отключённый интерфейс будет считаться активным после указанного количество "
"успешных ping проверок"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/overview.js:41
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/status/include/90_mwan3.js:49
msgid "Downtime"
msgstr "Время простоя"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:25
msgid "Emergency"
msgstr "Аварийный"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:92
msgid "Enable ssl tracking"
msgstr "Включить отслеживание SSL"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:55
msgid "Enabled"
msgstr "Включено"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:118
msgid ""
"Enables firewall rule logging (global mwan3 logging must also be enabled)"
msgstr ""
"Включает журналирование правил брандмауэра (также должно быть включено "
"глобальное журналирование mwan3)"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:20
msgid "Enables global firewall logging"
msgstr "Включает глобальное журналирование правил брандмауэера"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:93
msgid "Enables https tracking on ssl port 443"
msgstr "Включает отслеживание HTTPS на SSL порту 443"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:15
msgid "Enter value in hex, starting with <code>0x</code>"
msgstr "Введите значение в шестнадцатеричной форме, начиная с <code>0x</code>"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:28
msgid "Error"
msgstr "Ошибка"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/diagnostics.js:108
msgid "Execute"
msgstr "Выполнить"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:59
msgid "Expect interface state on up event"
msgstr "Ожидать состояние интерфейса при событии подключения интерейса"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:213
msgid "Failure interval"
msgstr "Интервал отказа"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:145
msgid "Failure latency [ms]"
msgstr "Задержка при отказе [мс]"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:158
msgid "Failure packet loss [%]"
msgstr "Потери пакетов при отказе [%]"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:23
msgid "Firewall loglevel"
msgstr "Уровень журналирования брандмауэра"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:14
msgid "Firewall mask"
msgstr "Маска межсетевого экрана"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:280
msgid "Flush conntrack table"
msgstr "Сбрасывать conntrack таблицу"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:281
msgid "Flush global firewall conntrack table on interface events"
msgstr ""
"Сбрасывать глобальную таблицу conntrack межсетевого экрана при событиях "
"интерфейса"
#: applications/luci-app-mwan3/root/usr/share/luci/menu.d/luci-app-mwan3.json:59
msgid "Globals"
msgstr "Глобальные"
#: applications/luci-app-mwan3/root/usr/share/rpcd/acl.d/luci-app-mwan3.json:51
msgid "Grant access for luci-app-mwan3 configuration"
msgstr "Предоставить доступ для настройки luci-app-mwan3"
#: applications/luci-app-mwan3/root/usr/share/rpcd/acl.d/luci-app-mwan3.json:3
msgid "Grant access for luci-app-mwan3 status information"
msgstr "Предоставить доступ к информации о состоянии luci-app-mwan3"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/diagnostics.js:68
msgid "Hotplug ifdown"
msgstr "Hotplug ifdown"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/diagnostics.js:67
msgid "Hotplug ifup"
msgstr "Hotplug ifup"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:107
msgid "IPset"
msgstr "IPset"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:67
msgid "IPv4"
msgstr "IPv4"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:61
msgid "IPv4 and IPv6"
msgstr "IPv4 и IPv6"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:62
msgid "IPv4 only"
msgstr "Только IPv4"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:68
msgid "IPv6"
msgstr "IPv6"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:63
msgid "IPv6 only"
msgstr "Только IPv6"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:31
msgid "Info"
msgstr "Информация"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:58
msgid "Initial state"
msgstr "Исходное состояние"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/member.js:50
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/diagnostics.js:83
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/overview.js:65
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/status/include/90_mwan3.js:77
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/status/include/90_mwan3.js:101
#: applications/luci-app-mwan3/root/usr/share/luci/menu.d/luci-app-mwan3.json:67
msgid "Interface"
msgstr "Интерфейс"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:252
msgid "Interface down"
msgstr "Отключение интерфейса"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:266
msgid "Interface up"
msgstr "Включение интерфейса"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:253
msgid "Interface will be deemed down after this many failed ping tests"
msgstr ""
"Интерфейс будет считаться неработающим после заданного количество неудачных "
"пинг-запросов"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:25
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:46
msgid ""
"Interfaces may not share the same name as configured members, policies or "
"rules."
msgstr ""
"Интерфейсы не могут иметь то же имя, что и настроенные элементы, политики "
"или правила."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:65
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:59
msgid "Internet Protocol"
msgstr "Протокол Интернета (IP)"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:230
msgid "Keep failure interval"
msgstr "Сохранять интервал отказа"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:231
msgid "Keep ping failure interval during failure state"
msgstr "Сохранять интервал отказа пинг-запроса во время состояния отказа"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/policy.js:61
msgid "Last resort"
msgstr "Крайнее средство"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:34
msgid "Lines beginning with # are comments and are not executed."
msgstr "Строки, начинающиеся с #, являются комментариями и не выполняются."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/policy.js:21
msgid ""
"Load-balanced member interfaces distribute more traffic out those with "
"higher weights."
msgstr ""
"Интерфейсы с балансировкой нагрузки распределяют больше трафика между "
"интерфейсами с большим весом."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:19
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:117
msgid "Logging"
msgstr "Журналирование"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:22
msgid "Loglevel"
msgstr "Уровень журналирования"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:127
msgid "Max TTL"
msgstr "Максимальное TTL"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:81
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:90
msgid ""
"May be entered as a single or multiple port(s) (eg \"22\" or \"80,443\") or "
"as a portrange (eg \"1024:2048\") without quotes"
msgstr ""
"Может быть введено как один или несколько портов (например, «22» или "
"«80,443») или как диапазон портов (например, «1024:2048») без кавычек"
#: applications/luci-app-mwan3/root/usr/share/luci/menu.d/luci-app-mwan3.json:75
msgid "Member"
msgstr "Узел"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/policy.js:19
msgid "Member interfaces with lower metrics are used first."
msgstr "Интерфейсы с более низкими метриками используются в первую очередь."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/policy.js:20
msgid "Member interfaces with the same metric will be load-balanced."
msgstr "Интерфейсы с одинаковой метрикой будут сбалансированы по нагрузке."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/policy.js:54
msgid "Member used"
msgstr "Используемый узел"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/member.js:18
msgid ""
"Members are profiles attaching a metric and weight to an MWAN interface."
msgstr "Элементы - это профили, прикрепляющие метрику и вес к интерфейсу MWAN."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/member.js:20
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/member.js:41
msgid ""
"Members may not share the same name as configured interfaces, policies or "
"rules."
msgstr ""
"Элементы не могут иметь одинаковые имена с настроенными интерфейсами, "
"политиками или правилами."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:288
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/member.js:57
msgid "Metric"
msgstr "Метрика"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/status/include/90_mwan3.js:19
#: applications/luci-app-mwan3/root/usr/share/luci/menu.d/luci-app-mwan3.json:3
#: applications/luci-app-mwan3/root/usr/share/luci/menu.d/luci-app-mwan3.json:47
msgid "MultiWAN Manager"
msgstr "Менеджер MultiWAN"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/diagnostics.js:79
msgid "MultiWAN Manager - Diagnostics"
msgstr "Менеджер MultiWAN - Диагностика"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:10
msgid "MultiWAN Manager - Globals"
msgstr "Менеджер MultiWAN - Глобальные"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:21
msgid "MultiWAN Manager - Interfaces"
msgstr "Менеджер MultiWAN - Интерфейсы"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/member.js:17
msgid "MultiWAN Manager - Members"
msgstr "Менеджер MultiWAN - Элементы"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:26
msgid "MultiWAN Manager - Notify"
msgstr "Менеджер MultiWAN - Уведомления"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/overview.js:93
msgid "MultiWAN Manager - Overview"
msgstr "Менеджер MultiWAN - Обзор"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/policy.js:17
msgid "MultiWAN Manager - Policies"
msgstr "Менеджер MultiWAN - Политики"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:19
msgid "MultiWAN Manager - Rules"
msgstr "Менеджер MultiWAN - Правила"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/detail.js:12
msgid "MultiWAN Manager - Status"
msgstr "Менеджер MultiWAN - Статус"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/troubleshooting.js:12
msgid "MultiWAN Manager - Troubleshooting"
msgstr "Менеджер MultiWAN - Устранение неполадок"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:22
msgid ""
"Mwan3 requires that all interfaces have a unique metric configured in /etc/"
"config/network."
msgstr ""
"Mwan3 требует, чтобы все интерфейсы имели уникальную метрику, настроенную в /"
"etc/config/network."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:49
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/member.js:44
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/policy.js:48
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:53
msgid "Name length shall not exceed 15 characters"
msgstr "Длина имени не должна превышать 15 символов"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:108
msgid ""
"Name of IPset rule. Requires IPset rule in /etc/dnsmasq.conf (eg \"ipset=/"
"youtube.com/youtube\")"
msgstr ""
"Имя IPset правила. Требуется правило IPset в /etc/dnsmasq.conf (например "
"«ipset=/youtube.com/youtube»)"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:24
msgid "Names may contain characters A-Z, a-z, 0-9, _ and no spaces-"
msgstr "Имена могут содержать символы A-Z, a-z, 0-9, _ и без пробелов и -"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/member.js:19
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/policy.js:22
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:27
msgid "Names may contain characters A-Z, a-z, 0-9, _ and no spaces."
msgstr "Имена могут содержать символы A-Z, a-z, 0-9, _ и без пробелов."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/policy.js:23
msgid "Names must be 15 characters or less."
msgstr "Имена должны состоять из 15 символов или менее."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:23
msgid "Names must match the interface name found in /etc/config/network."
msgstr "Имена должны совпадать с именем интерфейса из /etc/config/network."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:98
msgid "No"
msgstr "Нет"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/overview.js:21
msgid "No MWAN interfaces found"
msgstr "Интерфейсы MWAN не найдены"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/overview.js:44
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/status/include/90_mwan3.js:52
msgid "No Tracking"
msgstr "Нет отслеживания"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:296
msgid "No interface metric set!"
msgstr "Не задана метрика интерфейса!"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:31
msgid "Notes:"
msgstr "Примечания:"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:30
msgid "Notice"
msgstr "Уведомление"
#: applications/luci-app-mwan3/root/usr/share/luci/menu.d/luci-app-mwan3.json:99
msgid "Notify"
msgstr "Уведомить"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:62
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/overview.js:38
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/status/include/90_mwan3.js:46
msgid "Offline"
msgstr "Отключён"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:61
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/overview.js:31
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/status/include/90_mwan3.js:40
msgid "Online"
msgstr "Онлайн"
#: applications/luci-app-mwan3/root/usr/share/luci/menu.d/luci-app-mwan3.json:15
msgid "Overview"
msgstr "Обзор"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:103
msgid "Ping count"
msgstr "Количество пинг-запросов"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/diagnostics.js:63
msgid "Ping default gateway"
msgstr "Пинг-запрос шлюза по умолчанию"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:198
msgid "Ping interval"
msgstr "Интервал пинг-запроса"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:214
msgid "Ping interval during failure detection"
msgstr "Интервал пинг-запросов во время обнаруженного отказа"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:236
msgid "Ping interval during failure recovering"
msgstr "Интервал пинг-запросов при восстановлении отказа"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:112
msgid "Ping size"
msgstr "Размер пакета пинг-запроса"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:191
msgid "Ping timeout"
msgstr "Время ожидания ответа пинг-запроса"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/diagnostics.js:64
msgid "Ping tracking IP"
msgstr "Пинг-запрос отслеживаемого IP"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/policy.js:18
msgid ""
"Policies are profiles grouping one or more members controlling how Mwan3 "
"distributes traffic."
msgstr ""
"Политики - это профили, объединяющие один или несколько элементов, "
"управляющих распределением трафика в Mwan3."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/policy.js:24
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/policy.js:45
msgid ""
"Policies may not share the same name as configured interfaces, members or "
"rules"
msgstr ""
"Политики не могут иметь одинаковые имена с настроенными интерфейсами, "
"элементами или правилами"
#: applications/luci-app-mwan3/root/usr/share/luci/menu.d/luci-app-mwan3.json:83
msgid "Policy"
msgstr "Политика"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:121
msgid "Policy assigned"
msgstr "Назначенная политика"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:66
msgid "Protocol"
msgstr "Протокол"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:35
msgid ""
"Put your custom mwan3 action here, they will be executed with each netifd "
"hotplug interface event on interfaces for which mwan3 is enabled."
msgstr ""
"Поместите сюда ваши пользовательские действия mwan3, они будут выполняться "
"при каждом событии интерфейса netifd hotplug на интерфейсах, для которых "
"включён mwan3."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:235
msgid "Recovery interval"
msgstr "Интервал восстановления"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:168
msgid "Recovery latency [ms]"
msgstr "Задержка восстановления [мс]"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:181
msgid "Recovery packet loss [%]"
msgstr "Потери пакетов для восстановления [%]"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:39
msgid "Routing table %d"
msgstr "Таблица маршрутизации %d"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:36
msgid "Routing table lookup"
msgstr "Поиск таблицы маршрутизации"
#: applications/luci-app-mwan3/root/usr/share/luci/menu.d/luci-app-mwan3.json:91
msgid "Rule"
msgstr "Правило"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:21
msgid "Rules are based on IP address, port or protocol."
msgstr "Правила основаны на IP-адресе, порте или протоколе."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:22
msgid "Rules are matched from top to bottom."
msgstr "Правила сопоставляются сверху вниз."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:23
msgid "Rules below a matching rule are ignored."
msgstr "Правила, находящиеся ниже соответствующего правила, игнорируются."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:28
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:50
msgid ""
"Rules may not share the same name as configured interfaces, members or "
"policies."
msgstr ""
"Правила не могут иметь одинаковые имена с настроенными интерфейсами, "
"элементами или политиками."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:20
msgid "Rules specify which traffic will use a particular MWAN policy."
msgstr ""
"Правила определяют, какой трафик будет использовать определённую политику "
"MWAN."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:102
msgid "Seconds. Acceptable values: 1-1000000. Defaults to 600 if not set"
msgstr ""
"Секунды. Допустимые значения: 1–1000000. По умолчанию 600, если значение не "
"установлено"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:76
msgid "Source address"
msgstr "Адрес источника"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:80
msgid "Source port"
msgstr "Порт источника"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/overview.js:66
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/status/include/90_mwan3.js:83
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/status/include/90_mwan3.js:107
#: applications/luci-app-mwan3/root/usr/share/luci/menu.d/luci-app-mwan3.json:23
msgid "Status"
msgstr "Статус"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:94
msgid "Sticky"
msgstr "Режим Sticky"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:101
msgid "Sticky timeout"
msgstr "Время ожидания режима Sticky"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:77
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:86
msgid "Supports CIDR notation (eg \"192.168.100.0/24\") without quotes"
msgstr "Поддерживает CIDR нотацию (например «192.168.100.0/24») без кавычек"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/diagnostics.js:91
msgid "Task"
msgstr "Задача"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:29
msgid "The file is also preserved during sysupgrade."
msgstr "Этот файл также сохраняется при обновлении системы."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:33
msgid ""
"The first line of the script must be &#34;#!/bin/sh&#34; without quotes."
msgstr "Первая строка скрипта должна быть &#34;#!/bin/sh&#34; без кавычек."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:37
msgid ""
"There are three main environment variables that are passed to this script."
msgstr "В этот сценарий передаются три основные переменные среды окружения."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:289
msgid ""
"This displays the metric assigned to this interface in /etc/config/network"
msgstr ""
"Здесь отображается метрика, назначенная этому интерфейсу в файле /etc/config/"
"network"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:32
msgid "This file is interpreted as a shell script."
msgstr "Этот файл интерпретируется как shell-скрипт."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:72
msgid ""
"This hostname or IP address will be pinged to determine if the link is up or "
"down. Leave blank to assume interface is always online"
msgstr ""
"Имя хоста или IP-адрес для пинг-запроса, чтобы определить активно соединение "
"или нет. Оставьте пустым, чтобы указать, что интерфейс всегда в сети"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:28
msgid "This section allows you to modify the content of \"/etc/mwan3.user\"."
msgstr "Этот раздел позволяет изменять содержимое файла \"/etc/mwan3.user\"."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:71
msgid "Tracking hostname or IP address"
msgstr "Отслеживамое имя хоста или IP-адрес"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:76
msgid "Tracking method"
msgstr "Метод отслеживания"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:98
msgid "Tracking reliability"
msgstr "Надёжность отслеживания"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:25
msgid ""
"Traffic destined for known (other than default) networks is handled by the "
"main routing table."
msgstr ""
"Трафик, предназначенный для известных (кроме стандартных) сетей, "
"обрабатывается основной таблицей маршрутизации."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:95
msgid ""
"Traffic from the same source IP address that previously matched this rule "
"within the sticky timeout period will use the same WAN interface"
msgstr ""
"Трафик с того же IP-адреса источника, который ранее соответствовал этому "
"правилу в период времени ожидания режима Sticky, будет использовать тот же "
"интерфейс WAN"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:26
msgid ""
"Traffic matching a rule, but all WAN interfaces for that policy are down "
"will be blackholed."
msgstr ""
"Трафик, соответствующий правилу, но все интерфейсы WAN для этой политики не "
"работают, будет заблокирован."
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:24
msgid "Traffic not matching any rule is routed using the main routing table."
msgstr ""
"Трафик, не соответствующий какому-либо правилу, направляется по основной "
"таблице маршрутизации."
#: applications/luci-app-mwan3/root/usr/share/luci/menu.d/luci-app-mwan3.json:39
msgid "Troubleshooting"
msgstr "Устранение неполадок"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/notify.js:20
msgid "Unable to save contents: %s"
msgstr "Не удалось сохранить содержимое: %s"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/overview.js:34
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/status/overview.js:48
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/status/include/90_mwan3.js:43
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/status/include/90_mwan3.js:56
msgid "Uptime"
msgstr "Время работы"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:67
msgid "View the content of /etc/protocols for protocol description"
msgstr "Просмотр содержимого файла /etc/protocols для описания протокола"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/globals.js:29
msgid "Warning"
msgstr "Предупреждение"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/member.js:61
msgid "Weight"
msgstr "Вес"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/policy.js:62
msgid ""
"When all policy members are offline use this behavior for matched traffic"
msgstr ""
"Когда все члены политики находятся в автономном режиме, используйте это "
"поведение для сопоставляемого трафика"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:97
msgid "Yes"
msgstr "Да"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/policy.js:65
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:127
msgid "blackhole (drop)"
msgstr "blackhole (отбросить)"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:284
msgid "connected (mwan3)"
msgstr "подключено (connected, mwan3)"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/policy.js:66
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:128
msgid "default (use main routing table)"
msgstr "default (использовать основную таблицу маршрутизации)"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:285
msgid "disconnected (mwan3)"
msgstr "отключено (disconnected, mwan3)"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:283
msgid "ifdown (netifd)"
msgstr "отключено (ifdown, netifd)"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/interface.js:282
msgid "ifup (netifd)"
msgstr "подключено (ifup, netifd)"
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/policy.js:64
#: applications/luci-app-mwan3/htdocs/luci-static/resources/view/mwan3/network/rule.js:126
msgid "unreachable (reject)"
msgstr "unreachable (отклонить)"
msgid "Show on overview"
msgstr "Показать на главной"
msgid "Show MultiWAN status section on the overview page"
msgstr "Показывать секцию состояния MultiWan на главной странице"
msgid "No available interfaces"
msgstr "Нет доступных интерфейсов"
@@ -1,10 +0,0 @@
#!/bin/sh
uci -q get mwan3.globals >/dev/null || {
uci -q add mwan3 globals >/dev/null
uci -q rename mwan3.@globals[-1]="globals" >/dev/null
uci -q set mwan3.globals.local_source="none" >/dev/null
uci commit mwan3
}
exit 0
@@ -1,199 +0,0 @@
#!/bin/sh
#
# Copyright (C) 2021 TDT AG <development@tdt.de>
#
# This is free software, licensed under the GNU General Public License v2.
# See https://www.gnu.org/licenses/gpl-2.0.txt for more information.
#
. /lib/functions.sh
. /lib/functions/network.sh
. /usr/share/libubox/jshn.sh
IIF=1000
FWMARK=2000
ID=0
usage() {
local status="$1"
local msg="$2"
if [ -n "$msg" ]; then
echo "$msg"
echo ""
fi
echo "Usage: $(basename "$0") <command>"
echo "command:"
echo " diag: diagnostic commands"
echo " nftset: nft set commands"
echo ""
echo "diag <command> <iface>"
echo "command:"
echo " gateway <iface>: ping interface gateway"
echo " tracking <iface>: ping interface tracking targets"
echo " rules <iface>: check interface routing rules"
echo " routes <iface>: check interface routing tables"
echo ""
echo "nftset <command>"
echo "command:"
echo " dump: show all configured nft set names"
exit "$status"
}
diag_gateway() {
local iface="$1"
local gw
network_get_gateway gw "${iface}"
[ -z "$gw" ] && network_get_gateway gw "${iface}_4"
[ -z "$gw" ] && {
echo "No gateway for interface ${iface} found."
exit 2
}
mwan3 use "$iface" "ping" "-c" "5" "-W" "1" "$gw"
}
diag_tracking() {
local iface="$1"
checkips() {
local ip="$1"
local iface="$2"
mwan3 use "$iface" "ping" "-c" "5" "-W" "1" "$ip"
}
config_load mwan3
config_list_foreach "$iface" "track_ip" checkips "$iface"
}
iface_number() {
local cfg="$1"
local iface="$2"
let number++
[ "$cfg" = "$iface" ] && {
ID="$number"
}
}
diag_rules() {
local iface="$1"
local number=0
local iif=0
local fwmark=0
local iif_rule iif_result
local fwmark_rule fwmark_result
config_load mwan3
config_foreach iface_number 'interface' "$iface"
[ "$ID" = "0" ] && {
echo "Unable to get mwan3 interface number for \"$iface\"."
exit 2
}
let "iif=$IIF+$ID"
let "fwmark=$FWMARK+$ID"
iif_rule="$(ip rule | grep ${iif})"
iif_result="$?"
fwmark_rule="$(ip rule | grep ${fwmark})"
fwmark_result="$?"
if [ "$fwmark_result" = 0 ] && [ "$iif_result" = 0 ]; then
echo "All required IP rules for interface \"$iface\" found"
echo "$fwmark_rule"
echo "$iif_rule"
elif [ "$fwmark_result" = 1 ] && [ "$iif_result" = 0 ]; then
echo "Only iif IP rule for interface \"$iface\" found"
echo "$iif_rule"
elif [ "$fwmark_result" = 0 ] && [ "$iif_result" = 1 ]; then
echo "Only fwmark IP rule for interface \"$iface\" found"
echo "$fwmark_rule"
else
echo "Missing fwmark and iif IP rule for interface \"$iface\""
fi
}
diag_routes() {
local iface="$1"
local table table_result
config_load mwan3
config_foreach iface_number 'interface' "$iface"
[ "$ID" = "0" ] && {
echo "Unable to get mwan3 interface number for \"$iface\"."
exit 2
}
table="$(ip route list table $ID)"
table_result="$?"
if [ "$table_result" = 0 ]; then
echo "Routing table \"$ID\" for interface \"$iface\" found"
echo "$table"
else
echo "Routing table \"$ID\" for interface \"$iface\" not found"
fi
}
diag_cmd() {
case "$1" in
gateway)
diag_gateway "$2"
;;
tracking)
diag_tracking "$2"
;;
rules)
diag_rules "$2"
;;
routes)
diag_routes "$2"
;;
*)
usage "1" "Command not supported"
;;
esac
}
nftset_dump() {
nft -a list table inet mwan3 2>/dev/null | awk '/^\tset / && !/^\tset mwan3_/ {print $2}' | sort -u
}
nftset_cmd() {
case "$1" in
dump)
nftset_dump
;;
*)
usage "1" "Command not supported"
;;
esac
}
main () {
case "$1" in
diag)
diag_cmd "$2" "$3"
;;
nftset)
nftset_cmd "$2"
;;
*)
usage "1" "Command not supported"
;;
esac
}
main "$@"
@@ -1,106 +0,0 @@
{
"admin/status/mwan3": {
"title": "MultiWAN Manager",
"order": "600",
"action": {
"type": "firstchild"
},
"depends": {
"acl": [
"luci-app-mwan3-status"
]
}
},
"admin/status/mwan3/overview": {
"title": "Overview",
"order": 10,
"action": {
"type": "view",
"path": "mwan3/status/overview"
}
},
"admin/status/mwan3/detail": {
"title": "Status",
"order": 20,
"action": {
"type": "view",
"path": "mwan3/status/detail"
}
},
"admin/status/mwan3/diagnostics": {
"title": "Diagnostics",
"order": 30,
"action": {
"type": "view",
"path": "mwan3/status/diagnostics"
}
},
"admin/status/mwan3/troubleshooting": {
"title": "Troubleshooting",
"order": 40,
"action": {
"type": "view",
"path": "mwan3/status/troubleshooting"
}
},
"admin/network/mwan3": {
"title": "MultiWAN Manager",
"order": "600",
"action": {
"type": "firstchild"
},
"depends": {
"acl": [
"luci-app-mwan3"
]
}
},
"admin/network/mwan3/globals": {
"title": "Globals",
"order": 10,
"action": {
"type": "view",
"path": "mwan3/network/globals"
}
},
"admin/network/mwan3/interface": {
"title": "Interface",
"order": 20,
"action": {
"type": "view",
"path": "mwan3/network/interface"
}
},
"admin/network/mwan3/member": {
"title": "Member",
"order": 30,
"action": {
"type": "view",
"path": "mwan3/network/member"
}
},
"admin/network/mwan3/policy": {
"title": "Policy",
"order": 40,
"action": {
"type": "view",
"path": "mwan3/network/policy"
}
},
"admin/network/mwan3/rule": {
"title": "Rule",
"order": 50,
"action": {
"type": "view",
"path": "mwan3/network/rule"
}
},
"admin/network/mwan3/notify": {
"title": "Notify",
"order": 60,
"action": {
"type": "view",
"path": "mwan3/network/notify"
}
}
}
@@ -1,94 +0,0 @@
{
"luci-app-mwan3-status": {
"description": "Grant access for luci-app-mwan3 status information",
"read": {
"cgi-io": [
"exec"
],
"file": {
"/usr/sbin/mwan3 status": [
"exec"
]
},
"ubus": {
"mwan3": [
"status"
]
}
},
"write": {
"file": {
"/usr/libexec/luci-mwan3 diag gateway *": [
"exec"
],
"/usr/libexec/luci-mwan3 diag tracking *": [
"exec"
],
"/usr/libexec/luci-mwan3 diag rules *": [
"exec"
],
"/usr/libexec/luci-mwan3 diag routes *": [
"exec"
],
"/usr/sbin/mwan3 internal ipv4": [
"exec"
],
"/usr/sbin/mwan3 ifup *": [
"exec"
],
"/usr/sbin/mwan3 ifdown *": [
"exec"
]
},
"ubus": {
"file": [
"exec"
]
}
}
},
"luci-app-mwan3": {
"description": "Grant access for luci-app-mwan3 configuration",
"read": {
"cgi-io": [
"exec"
],
"file": {
"/etc/mwan3.user": [
"read"
],
"/usr/bin/httping": [
"list"
],
"/usr/bin/nping": [
"list"
],
"/usr/bin/arping": [
"list"
],
"/usr/libexec/luci-mwan3 nftset dump": [
"exec"
]
},
"ubus": {
"mwan3": [
"status"
]
},
"uci": [
"mwan3",
"network"
]
},
"write": {
"file": {
"/etc/mwan3.user": [
"write"
]
},
"uci": [
"mwan3"
]
}
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-open-box PKG_NAME:=luci-app-open-box
PKG_VERSION:=0.1.320 PKG_VERSION:=0.1.326
PKG_RELEASE:=28 PKG_RELEASE:=29
PKG_MAINTAINER:=liandu2024 PKG_MAINTAINER:=liandu2024
PKG_LICENSE:=GPL-3.0-or-later PKG_LICENSE:=GPL-3.0-or-later
+4
View File
@@ -9,6 +9,10 @@ LUCI_DEPENDS:=+luci-mod-admin-full +tcpdump +coreutils +coreutils-timeout
PKG_MAINTAINER:=Wojciech Jowsa <wojciech.jowsa@gmail.com> PKG_MAINTAINER:=Wojciech Jowsa <wojciech.jowsa@gmail.com>
PKG_LICENSE:=Apache-2.0 PKG_LICENSE:=Apache-2.0
define Package/luci-app-packet-capture/conffiles
/etc/config/packet_capture
endef
include $(TOPDIR)/feeds/luci/luci.mk include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signatureet # call BuildPackage - OpenWrt buildroot signatureet
+2 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2 PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.10.1 PKG_VERSION:=26.10.1
PKG_RELEASE:=141 PKG_RELEASE:=142
PKG_PO_VERSION:=$(PKG_VERSION) PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \ PKG_CONFIG_DEPENDS:= \
@@ -80,6 +80,7 @@ config PACKAGE_$(PKG_NAME)_Iptables_Transparent_Proxy
select PACKAGE_iptables-nft select PACKAGE_iptables-nft
select PACKAGE_iptables-zz-legacy select PACKAGE_iptables-zz-legacy
select PACKAGE_iptables-mod-conntrack-extra select PACKAGE_iptables-mod-conntrack-extra
select PACKAGE_iptables-mod-extra
select PACKAGE_iptables-mod-iprange select PACKAGE_iptables-mod-iprange
select PACKAGE_iptables-mod-socket select PACKAGE_iptables-mod-socket
select PACKAGE_iptables-mod-tproxy select PACKAGE_iptables-mod-tproxy
@@ -755,12 +755,14 @@ end
function com_update(comname) function com_update(comname)
local json = nil local json = nil
local task = http.formvalue("task") local task = http.formvalue("task")
if task == "extract" then if task == "progress" then
json = api.to_download_progress(comname, http.formvalue("id"), http.formvalue("total_size"))
elseif task == "extract" then
json = api.to_extract(comname, http.formvalue("file"), http.formvalue("subfix")) json = api.to_extract(comname, http.formvalue("file"), http.formvalue("subfix"))
elseif task == "move" then elseif task == "move" then
json = api.to_move(comname, http.formvalue("file")) json = api.to_move(comname, http.formvalue("file"))
else else
json = api.to_download(comname, http.formvalue("url"), http.formvalue("size")) json = api.to_download(comname, http.formvalue("url"), http.formvalue("size"), http.formvalue("id"))
end end
http_write_json(json) http_write_json(json)
+122 -42
View File
@@ -7,6 +7,7 @@ c_config = "passwall2"
s_config = "passwall2_server" s_config = "passwall2_server"
local com = require "luci.passwall2.com" local com = require "luci.passwall2.com"
nixio = require "nixio" nixio = require "nixio"
require "nixio.util"
fs = require "nixio.fs" fs = require "nixio.fs"
sys = require "luci.sys" sys = require "luci.sys"
uci = require"luci.model.uci".cursor() uci = require"luci.model.uci".cursor()
@@ -17,6 +18,7 @@ i18n = require "luci.i18n"
curl_args = { "-skfL", "--connect-timeout 3", "--retry 3", "-H 'Accept: */*'" } curl_args = { "-skfL", "--connect-timeout 3", "--retry 3", "-H 'Accept: */*'" }
command_timeout = 300 command_timeout = 300
DIRECT_GID = 9912
OPENWRT_ARCH = nil OPENWRT_ARCH = nil
DISTRIB_ARCH = nil DISTRIB_ARCH = nil
@@ -197,7 +199,7 @@ function del_cache_var(key)
end end
function set_cache_var(key, val) function set_cache_var(key, val)
sys.call(string.format('. /usr/share/passwall2/utils.sh ; set_cache_var "%s" "%s"', key, val)) sys.call(string.format('. /usr/share/passwall2/utils.sh ; set_cache_var %s %s', util.shellquote(tostring(key)), util.shellquote(tostring(val))))
end end
function get_cache_var(key) function get_cache_var(key)
@@ -226,6 +228,30 @@ function exec_call(cmd)
return tonumber(rc), trim(out) return tonumber(rc), trim(out)
end end
function exec_call_as_gid(cmd, gid)
local reader, writer = nixio.pipe()
if not reader then return 255, "Unable to create command pipe" end
local pid = nixio.fork()
if not pid then
reader:close()
writer:close()
return 255, "Unable to fork command"
elseif pid == 0 then
reader:close()
nixio.dup(writer, nixio.stdout)
nixio.dup(writer, nixio.stderr)
writer:close()
if not nixio.setgid(gid) then os.exit(1) end
nixio.exec("/bin/sh", "-c", cmd)
os.exit(127)
end
writer:close()
local out = reader:readall() or ""
reader:close()
local _, status, code = nixio.waitpid(pid)
return status == "exited" and code or 255, trim(out)
end
function base64Decode(text) function base64Decode(text)
if type(text) ~= "string" then return "" end if type(text) ~= "string" then return "" end
local encoded = text:gsub("%z", ""):gsub("%c", ""):gsub("_", "/"):gsub("-", "+"):gsub("=+$", "") local encoded = text:gsub("%z", ""):gsub("%c", ""):gsub("_", "/"):gsub("-", "+"):gsub("=+$", "")
@@ -274,22 +300,38 @@ function get_domain_port_from_url(url)
return domain, port return domain, port
end end
-- Domain resolution function resolve_domain(domain, dns)
function domainToIPv4(domain, dns) dns = dns or get_cache_var("DIRECT_DNS") or "223.5.5.5"
local Dns = dns or "223.5.5.5" local address, port = dns:match("^(.-)#(%d+)$")
local IPs = luci.sys.exec('nslookup %s %s | awk \'/^Name:/{getline; if ($1 == "Address:") print $2}\'' % { domain, Dns }) if not address then address, port = dns:match("^%[(.-)%]:(%d+)$") end
for IP in string.gmatch(IPs, "%S+") do if not address then address, port = dns:match("^([^:]+):(%d+)$") end
if datatypes.ipaddr(IP) and not datatypes.ip6addr(IP) then return IP end address = (address or dns):gsub("^%[(.-)%]$", "%1")
port = port or "53"
local server = datatypes.ip6addr(address) and ("[" .. address .. "]") or address
local rc, result
for _, dns_server in ipairs({server .. ":" .. port, address .. "#" .. port, "223.5.5.5"}) do
rc, result = exec_call_as_gid("LC_ALL=C nslookup " .. util.shellquote(domain) .. " " .. util.shellquote(dns_server), DIRECT_GID)
local ips, seen, answer = {}, {}, false
for line in result:gmatch("[^\r\n]+") do
if line:match("^%s*Name:") then answer = true end
local ip = answer and line:match("^%s*Address%s*%d*:%s*(%S+)")
if ip and (datatypes.ip4addr(ip) or datatypes.ip6addr(ip)) and not seen[ip] then
ips[#ips + 1] = ip
seen[ip] = true
end
end
if #ips > 0 then return ips end
end end
return nil return nil, result, rc
end end
function curl_base(url, file, args) function curl_base(url, file, args, gid)
if not args then args = {} end if not args then args = {} end
if file then if file then
args[#args + 1] = "-o " .. file args[#args + 1] = "-o " .. file
end end
local cmd = string.format('curl %s "%s"', table_join(args), url) local cmd = string.format('curl %s "%s"', table_join(args), url)
if gid then return exec_call_as_gid(cmd, gid) end
return exec_call(cmd) return exec_call(cmd)
end end
@@ -311,36 +353,34 @@ end
function curl_logic(url, file, args) function curl_logic(url, file, args)
local return_code, result = curl_proxy(url, file, args) local return_code, result = curl_proxy(url, file, args)
if not return_code or return_code ~= 0 then if not return_code or return_code ~= 0 then
return_code, result = curl_base(url, file, args) return_code, result = curl_direct(url, file, args)
end end
return return_code, result return return_code, result
end end
function curl_direct(url, file, args) function curl_direct(url, file, args)
-- Direct access if get_cache_var("ENABLED_DEFAULT_ACL") ~= "1" and get_cache_var("ENABLED_ACLS") ~= "1" then
if not args then args = {} end return curl_base(url, file, args)
local tmp_args = clone(args) end
local tmp_args = clone(args or {})
tmp_args[#tmp_args + 1] = "--noproxy '*'"
local domain, port = get_domain_port_from_url(url) local domain, port = get_domain_port_from_url(url)
if domain then if domain then
local ip = domainToIPv4(domain) local ips, err = resolve_domain(domain)
if ip then if not ips then return 6, err end
tmp_args[#tmp_args + 1] = "--resolve " .. domain .. ":" .. port .. ":" .. ip for i, ip in ipairs(ips) do
if datatypes.ip6addr(ip) then ips[i] = "[" .. ip .. "]" end
end end
tmp_args[#tmp_args + 1] = "--resolve " .. util.shellquote(domain .. ":" .. port .. ":" .. table.concat(ips, ","))
end end
return curl_base(url, file, tmp_args) return curl_base(url, file, tmp_args, DIRECT_GID)
end end
function curl_auto(url, file, args) function curl_auto(url, file, args)
local localhost_proxy = uci_get_c("@global[0]", "localhost_proxy") or "1" if (uci_get_c("@global[0]", "localhost_proxy") or "1") == "1" then
if localhost_proxy == "1" then
return curl_base(url, file, args) return curl_base(url, file, args)
else
local return_code, result = curl_proxy(url, file, args)
if not return_code or return_code ~= 0 then
return_code, result = curl_direct(url, file, args)
end
return return_code, result
end end
return curl_logic(url, file, args)
end end
function url(...) function url(...)
@@ -853,19 +893,26 @@ function clone(org)
end end
local function get_bin_version_cache(file, cmd) local function get_bin_version_cache(file, cmd)
sys.call("mkdir -p " .. CACHE_PATH) if file and file ~= "" and fs.access(file) then
if fs.access(file) then
chmod_755(file) chmod_755(file)
local md5 = sys.exec("echo -n $(md5sum " .. file .. " | awk '{print $1}')") local stat = fs.stat(file)
if fs.access(CACHE_PATH .. "/" .. md5) then local cache_file
return sys.exec("echo -n $(cat %s)" % { CACHE_PATH .. "/" .. md5 }) if stat and stat.dev and stat.ino and stat.size and stat.mtime and stat.ctime then
else -- Use metadata to avoid hashing the entire binary on every lookup.
local version = sys.exec(string.format("echo -n $(%s %s)", file, cmd)) cache_file = CACHE_PATH .. "/binver_" .. table.concat({stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime}, "_")
if version and version ~= "" then local cached = fs.readfile(cache_file)
sys.call("echo '%s' > %s" % { version, CACHE_PATH .. "/" .. md5}) if cached and trim(cached) ~= "" then
return version return trim(cached)
end end
end end
local version = sys.exec(string.format("echo -n $(%s %s)", util.shellquote(file), cmd))
if version and version ~= "" then
if cache_file then
fs.mkdirr(CACHE_PATH)
fs.writefile(cache_file, version)
end
return version
end
end end
return "" return ""
end end
@@ -886,7 +933,7 @@ end
function is_file(path) function is_file(path)
if path and #path > 1 then if path and #path > 1 then
if sys.exec('[ -f "%s" ] && echo -n 1' % path) == "1" then if fs.stat(path, "type") == "reg" then
return true return true
end end
end end
@@ -895,7 +942,7 @@ end
function is_dir(path) function is_dir(path)
if path and #path > 1 then if path and #path > 1 then
if sys.exec('[ -d "%s" ] && echo -n 1' % path) == "1" then if fs.stat(path, "type") == "dir" then
return true return true
end end
end end
@@ -1123,7 +1170,7 @@ local default_file_tree = {
} }
function get_api_json(url) function get_api_json(url)
local return_code, content = curl_logic(url, nil, curl_args) local return_code, content = curl_auto(url, nil, curl_args)
if return_code ~= 0 or content == "" then return {} end if return_code ~= 0 or content == "" then return {} end
return jsonc.parse(content) or {} return jsonc.parse(content) or {}
end end
@@ -1216,7 +1263,7 @@ function to_check(arch, app_name)
} }
end end
function to_download(app_name, url, size) function to_download(app_name, url, size, task_id)
local result = check_path(app_name) local result = check_path(app_name)
if result.code ~= 0 then if result.code ~= 0 then
return result return result
@@ -1228,7 +1275,13 @@ function to_download(app_name, url, size)
sys.call("/bin/rm -f /tmp/".. app_name .."_download.*") sys.call("/bin/rm -f /tmp/".. app_name .."_download.*")
local tmp_file = trim(util.exec("mktemp -u -t ".. app_name .."_download.XXXXXX")) local tmp_file
if type(task_id) == "string" and task_id:match("^[%w_-]+$") then
tmp_file = "/tmp/" .. app_name .. "_download." .. task_id
exec("/bin/rm", {"-f", tmp_file})
else
tmp_file = trim(util.exec("mktemp -u -t " .. app_name .. "_download.XXXXXX"))
end
if size then if size then
local kb1 = get_free_space("/tmp") local kb1 = get_free_space("/tmp")
@@ -1240,7 +1293,7 @@ function to_download(app_name, url, size)
local _curl_args = clone(curl_args) local _curl_args = clone(curl_args)
table.insert(_curl_args, "--speed-limit 51200 --speed-time 15 --max-time 300") table.insert(_curl_args, "--speed-limit 51200 --speed-time 15 --max-time 300")
local return_code, result = curl_logic(url, tmp_file, _curl_args) local return_code, result = curl_auto(url, tmp_file, _curl_args)
result = return_code == 0 result = return_code == 0
if not result then if not result then
@@ -1254,6 +1307,23 @@ function to_download(app_name, url, size)
return {code = 0, file = tmp_file, zip = com[app_name].zipped } return {code = 0, file = tmp_file, zip = com[app_name].zipped }
end end
function to_download_progress(app_name, task_id, total_size)
if not com[app_name] or type(task_id) ~= "string" or not task_id:match("^[%w_-]+$") then
return {code = 1, error = i18n.translate("Invalid download task.")}
end
total_size = tonumber(total_size) or 0
local tmp_file = "/tmp/" .. app_name .. "_download." .. task_id
local downloaded = tonumber(fs.stat(tmp_file, "size")) or 0
local percent
if total_size > 0 then
-- Leave 100% for the download request's success callback.
percent = math.min(99, math.floor(downloaded * 100 / total_size))
end
return {code = 0, downloaded = downloaded, total = total_size, percent = percent}
end
function to_extract(app_name, file, subfix) function to_extract(app_name, file, subfix)
local result = check_path(app_name) local result = check_path(app_name)
if result.code ~= 0 then if result.code ~= 0 then
@@ -1446,7 +1516,7 @@ function cacheFileCompareToLogic(file, str)
end end
function is_js_luci() function is_js_luci()
return sys.call('[ -f "/www/luci-static/resources/uci.js" ]') == 0 return fs.stat("/www/luci-static/resources/uci.js", "type") == "reg"
end end
function set_default_cbi() function set_default_cbi()
@@ -1519,6 +1589,16 @@ function set_default_cbi()
return obj return obj
end end
end end
if true then
--Table
local Table = cbi.Table
local default_init = Table.__init__
function Table.__init__(self, form, ...)
default_init(self, form, ...)
-- The Table's map is an independent data source and needs to receive the API from its parent form.
self.map.api = form.api
end
end
if true then if true then
--TextValue --TextValue
local TextValue = cbi.TextValue local TextValue = cbi.TextValue
@@ -129,12 +129,54 @@ local com = self.com
var appUpdateUrl = '<%=api.url("update_")%>' + app; var appUpdateUrl = '<%=api.url("update_")%>' + app;
var appInfo = appInfoList[app]; var appInfo = appInfoList[app];
var downloadId = Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
var totalSize = appInfo && appInfo.data ? Number(appInfo.data.size) : 0;
if (!isFinite(totalSize) || totalSize <= 0) {
totalSize = 0;
}
var downloadProgressActive = true;
var downloadProgressPending = false;
var progressTimer = null;
function stopDownloadProgress() {
downloadProgressActive = false;
if (progressTimer) {
clearInterval(progressTimer);
progressTimer = null;
}
}
function updateDownloadProgress() {
if (!downloadProgressActive || downloadProgressPending || totalSize <= 0) {
return;
}
downloadProgressPending = true;
XHR.get(appUpdateUrl, {
token: tokenStr,
task: 'progress',
id: downloadId,
total_size: totalSize
}, function (x, json) {
downloadProgressPending = false;
if (downloadProgressActive && json && !json.code && typeof json.percent === 'number') {
btn.value = downloadingText + ' ' + json.percent + '%';
}
}, 300);
}
if (totalSize > 0) {
updateDownloadProgress();
progressTimer = setInterval(updateDownloadProgress, 1000);
}
// Download file // Download file
XHR.get(appUpdateUrl, { XHR.get(appUpdateUrl, {
token: tokenStr, token: tokenStr,
url: appInfo ? appInfo.data.browser_download_url : '', url: appInfo ? appInfo.data.browser_download_url : '',
size: appInfo ? appInfo.data.size / 1024 : null size: appInfo ? appInfo.data.size / 1024 : null,
id: downloadId
}, function (x, json) { }, function (x, json) {
stopDownloadProgress();
if (json.code) { if (json.code) {
removePageNotice(); removePageNotice();
onRequestError(btn, json.error); onRequestError(btn, json.error);
@@ -233,4 +275,4 @@ local com = self.com
}); });
//]]> //]]>
</script> </script>
</div> </div>
@@ -7,6 +7,7 @@
-- Description: Passwall(2) UI template -- Description: Passwall(2) UI template
local json = require "luci.jsonc" local json = require "luci.jsonc"
local api = self.map.api
-- 读取值(keylist/vallist/group) -- 读取值(keylist/vallist/group)
local values = {} local values = {}
@@ -48,6 +49,30 @@ for _, item in ipairs(values) do
end end
end end
-- 仅共享组名、节点 key/label 及顺序完全相同的分组,保留各控件的候选限制
local group_cache = self.map._lv_group_cache
if not group_cache then
group_cache = {}
self.map._lv_group_cache = group_cache
end
local group_refs = {}
local new_groups = {}
for i, g in ipairs(group_order) do
-- 使用有序数组生成完整签名,不依赖 JSON 对象的字段顺序或哈希值
local signature = {g}
for _, item in ipairs(groups[g]) do
signature[#signature + 1] = {item.key, item.label}
end
local cache_key = json.stringify(signature)
local ref = group_cache[cache_key]
if not ref then
ref = cbid .. ".group." .. i
group_cache[cache_key] = ref
new_groups[ref] = groups[g]
end
group_refs[i] = ref
end
-- 如果没有配置值,默认取第一个(优先无组第一项,否则第一组第一项) -- 如果没有配置值,默认取第一个(优先无组第一项,否则第一组第一项)
if not current_key then if not current_key then
if #ungrouped > 0 then if #ungrouped > 0 then
@@ -73,19 +98,28 @@ local function find_label_by_key(k)
end end
local current_label = find_label_by_key(current_key) or "" local current_label = find_label_by_key(current_key) or ""
-- 打包数据给 JS -- 控件只携带分组引用,重复的候选数据在页面中仅输出一次
local dropdown_data = { local dropdown_data = {
appname = api.appname,
current_key = current_key, current_key = current_key,
current_label = current_label, current_label = current_label,
ungrouped = ungrouped, ungrouped = ungrouped,
groups = groups, group_refs = group_refs,
group_order = group_order, group_order = group_order,
cbid = cbid, cbid = cbid,
} }
%> %>
<script> <script>
window.lv_dropdown_data = window.lv_dropdown_data || {}; (function() {
window.lv_dropdown_data["<%=cbid%>"] = <%=json.stringify(dropdown_data)%>; const appname = <%=json.stringify(api.appname)%>;
window.lv_groups_pool = window.lv_groups_pool || Object.create(null);
const pool = window.lv_groups_pool[appname] || (window.lv_groups_pool[appname] = Object.create(null));
<% if next(new_groups) then %>
Object.assign(pool, <%=json.stringify(new_groups)%>);
<% end %>
window.lv_dropdown_data = window.lv_dropdown_data || {};
window.lv_dropdown_data[<%=json.stringify(cbid)%>] = <%=json.stringify(dropdown_data)%>;
})();
</script> </script>
<div id="<%=cbid%>.main" class="lv-dropdown-container"> <div id="<%=cbid%>.main" class="lv-dropdown-container">
@@ -98,7 +132,7 @@ window.lv_dropdown_data["<%=cbid%>"] = <%=json.stringify(dropdown_data)%>;
<!-- 模拟 ListValue 控件外观 --> <!-- 模拟 ListValue 控件外观 -->
<div class="cbi-input-value cbi-input-select lv-dropdown-display" id="<%=cbid%>.display" tabindex="0"> <div class="cbi-input-value cbi-input-select lv-dropdown-display" id="<%=cbid%>.display" tabindex="0">
<span id="<%=cbid%>.label" class="lv-dropdown-label" title="<%=pcdata(current_label)%>"> <span id="<%=cbid%>.label" class="lv-dropdown-label" title="<%=pcdata(current_label)%>">
<%=pcdata("("..translate("Not set")..")")%> <%=pcdata("( "..translate("Not Set").." )")%>
</span> </span>
<span class="lv-arrow-down"></span> <span class="lv-arrow-down"></span>
</div> </div>
@@ -141,7 +175,7 @@ window.lv_dropdown_data["<%=cbid%>"] = <%=json.stringify(dropdown_data)%>;
}); });
lv_registerAdaptive(cbid); lv_registerAdaptive(cbid);
setTimeout(() => { setTimeout(() => {
labelSpan.textContent = lv_ellipsisByWidth(cbid, '<%=pcdata(current_label ~= "" and current_label or ("("..translate("Not set")..")"))%>'); labelSpan.textContent = lv_ellipsisByWidth(cbid, '<%=pcdata(current_label ~= "" and current_label or ("( "..translate("Not Set").." )"))%>');
}, 100); }, 100);
})(); })();
//]]> //]]>
@@ -207,7 +207,7 @@
if (!rgb) return 'unknown'; // Handle invalid colors if (!rgb) return 'unknown'; // Handle invalid colors
// Calculate YIQ brightness (human eye perception) // Calculate YIQ brightness (human eye perception)
const brightness = ((rgb.r * 299) + (rgb.g * 587) + (rgb.b * 114)) / 1000; const brightness = ((rgb.r * 299) + (rgb.g * 587) + (rgb.b * 114)) / 1000;
return brightness > 128 ? 'light' : 'dark'; return brightness > 128 ? 'light' : 'dark';
} }
@@ -538,7 +538,7 @@
"&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;" "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;"
}[c])); }[c]));
} }
function lv_change(cbid, listContainer, hiddenSelect, labelSpan, searchInput, new_key, new_text) { function lv_change(cbid, listContainer, hiddenSelect, labelSpan, searchInput, new_key, new_text) {
//改值 //改值
hiddenSelect.options[0].value = new_key; hiddenSelect.options[0].value = new_key;
@@ -549,7 +549,7 @@
lv_highlightSelectedItem(listContainer, hiddenSelect); lv_highlightSelectedItem(listContainer, hiddenSelect);
lv_updateGroupCounts(cbid, listContainer, hiddenSelect, searchInput); lv_updateGroupCounts(cbid, listContainer, hiddenSelect, searchInput);
} }
function lv_render_dropdown_list(cbid, panel, listContainer, hiddenSelect, labelSpan, searchInput, display) { function lv_render_dropdown_list(cbid, panel, listContainer, hiddenSelect, labelSpan, searchInput, display) {
if (window.lv_dropdown_rendered[cbid]) return; if (window.lv_dropdown_rendered[cbid]) return;
const data = window.lv_dropdown_data[cbid]; const data = window.lv_dropdown_data[cbid];
@@ -577,8 +577,10 @@
} }
// 分组项 // 分组项
data.group_order.forEach(gname => { const groupPool = window.lv_groups_pool && window.lv_groups_pool[data.appname];
const items = data.groups[gname]; data.group_order.forEach((gname, index) => {
const items = data.groups ? data.groups[gname] : groupPool && groupPool[data.group_refs[index]];
if (!Array.isArray(items)) return;
html += ` html += `
<div class="lv-group" data-group="${lv_idSafe(gname)}" style="margin-bottom:8px;"> <div class="lv-group" data-group="${lv_idSafe(gname)}" style="margin-bottom:8px;">
@@ -6,6 +6,7 @@
-- Description: Passwall(2) UI template -- Description: Passwall(2) UI template
local json = require "luci.jsonc" local json = require "luci.jsonc"
local api = self.map.api
-- 读取值(keylist/vallist/group) -- 读取值(keylist/vallist/group)
local values = {{key = "", label = translate("-- Please choose --")}} local values = {{key = "", label = translate("-- Please choose --")}}
@@ -56,6 +57,30 @@ if not current_key_added and current_key and current_key ~= "" then
table.insert(ungrouped, {key = current_key, label = current_key}) table.insert(ungrouped, {key = current_key, label = current_key})
end end
-- 仅共享候选分组,各控件的自定义地址仍保留在自己的 ungrouped 中
local group_cache = self.map._v_group_cache
if not group_cache then
group_cache = {}
self.map._v_group_cache = group_cache
end
local group_refs = {}
local new_groups = {}
for i, g in ipairs(group_order) do
-- 组名、节点 key/label 及顺序完全相同才复用,不与 ListValue 缓存混用
local signature = {g}
for _, item in ipairs(groups[g]) do
signature[#signature + 1] = {item.key, item.label}
end
local cache_key = json.stringify(signature)
local ref = group_cache[cache_key]
if not ref then
ref = cbid .. ".v-group." .. i
group_cache[cache_key] = ref
new_groups[ref] = groups[g]
end
group_refs[i] = ref
end
-- 如果没有配置值,默认取第一个(优先无组第一项,否则第一组第一项) -- 如果没有配置值,默认取第一个(优先无组第一项,否则第一组第一项)
if not current_key then if not current_key then
if #ungrouped > 0 then if #ungrouped > 0 then
@@ -81,19 +106,28 @@ local function find_label_by_key(k)
end end
local current_label = find_label_by_key(current_key) or "" local current_label = find_label_by_key(current_key) or ""
-- 打包数据给 JS -- 控件只携带分组引用,重复的候选数据在页面中仅输出一次
local dropdown_data = { local dropdown_data = {
appname = api.appname,
current_key = current_key, current_key = current_key,
current_label = current_label, current_label = current_label,
ungrouped = ungrouped, ungrouped = ungrouped,
groups = groups, group_refs = group_refs,
group_order = group_order, group_order = group_order,
cbid = cbid, cbid = cbid,
} }
%> %>
<script> <script>
window.v_dropdown_data = window.v_dropdown_data || {}; (function() {
window.v_dropdown_data["<%=cbid%>"] = <%=json.stringify(dropdown_data)%>; const appname = <%=json.stringify(api.appname)%>;
window.v_groups_pool = window.v_groups_pool || Object.create(null);
const pool = window.v_groups_pool[appname] || (window.v_groups_pool[appname] = Object.create(null));
<% if next(new_groups) then %>
Object.assign(pool, <%=json.stringify(new_groups)%>);
<% end %>
window.v_dropdown_data = window.v_dropdown_data || {};
window.v_dropdown_data[<%=json.stringify(cbid)%>] = <%=json.stringify(dropdown_data)%>;
})();
</script> </script>
<div id="<%=cbid%>.v-main" class="v-dropdown-container"> <div id="<%=cbid%>.v-main" class="v-dropdown-container">
@@ -202,7 +202,7 @@
if (!rgb) return 'unknown'; // Handle invalid colors if (!rgb) return 'unknown'; // Handle invalid colors
// Calculate YIQ brightness (human eye perception) // Calculate YIQ brightness (human eye perception)
const brightness = ((rgb.r * 299) + (rgb.g * 587) + (rgb.b * 114)) / 1000; const brightness = ((rgb.r * 299) + (rgb.g * 587) + (rgb.b * 114)) / 1000;
return brightness > 128 ? 'light' : 'dark'; return brightness > 128 ? 'light' : 'dark';
} }
@@ -603,8 +603,10 @@
} }
// 分组项 // 分组项
data.group_order.forEach(gname => { const groupPool = window.v_groups_pool && window.v_groups_pool[data.appname];
const items = data.groups[gname]; data.group_order.forEach((gname, index) => {
const items = data.groups ? data.groups[gname] : groupPool && groupPool[data.group_refs[index]];
if (!Array.isArray(items)) return;
html += ` html += `
<div class="v-group" data-group="${v_idSafe(gname)}" style="margin-bottom:8px;"> <div class="v-group" data-group="${v_idSafe(gname)}" style="margin-bottom:8px;">
@@ -47,7 +47,7 @@ check_run_environment() {
[ -d "/lib/apk/packages" ] && { file_path="/lib/apk/packages"; file_ext=".list"; } [ -d "/lib/apk/packages" ] && { file_path="/lib/apk/packages"; file_ext=".list"; }
if [ "$USE_TABLES" = "iptables" ]; then if [ "$USE_TABLES" = "iptables" ]; then
dep_list="iptables-mod-tproxy iptables-mod-socket iptables-mod-iprange iptables-mod-conntrack-extra kmod-ipt-nat" dep_list="iptables-mod-tproxy iptables-mod-socket iptables-mod-iprange iptables-mod-conntrack-extra iptables-mod-extra kmod-ipt-nat"
else else
dep_list="kmod-nft-socket kmod-nft-tproxy kmod-nft-nat" dep_list="kmod-nft-socket kmod-nft-tproxy kmod-nft-nat"
nftflag=1 nftflag=1
@@ -923,6 +923,8 @@ start() {
set_cache_var "USE_TABLES" "$USE_TABLES" set_cache_var "USE_TABLES" "$USE_TABLES"
fi fi
} }
set_cache_var "ENABLED_DEFAULT_ACL" "$ENABLED_DEFAULT_ACL"
set_cache_var "ENABLED_ACLS" "$ENABLED_ACLS"
if [ "$ENABLED_DEFAULT_ACL" == 1 ] || [ "$ENABLED_ACLS" == 1 ]; then if [ "$ENABLED_DEFAULT_ACL" == 1 ] || [ "$ENABLED_ACLS" == 1 ]; then
bridge_nf_ipt=$(sysctl -e -n net.bridge.bridge-nf-call-iptables) bridge_nf_ipt=$(sysctl -e -n net.bridge.bridge-nf-call-iptables)
set_cache_var "bak_bridge_nf_ipt" "$bridge_nf_ipt" set_cache_var "bak_bridge_nf_ipt" "$bridge_nf_ipt"
@@ -1059,6 +1061,12 @@ get_config() {
DIRECT_DNS_QUERY_STRATEGY=$(config_n_get @global[0] direct_dns_query_strategy UseIP) DIRECT_DNS_QUERY_STRATEGY=$(config_n_get @global[0] direct_dns_query_strategy UseIP)
get_direct_dns get_direct_dns
# nslookup uses UDP; TCP direct DNS remains configured for the proxy cores.
if [ "$DIRECT_DNS_PROTO" = "udp" ]; then
set_cache_var "DIRECT_DNS" "${DIRECT_DNS_SERVER}#${DIRECT_DNS_PORT}"
else
set_cache_var "DIRECT_DNS" "${AUTO_DNS%%,*}"
fi
DEFAULT_DNSMASQ_CONF_DIR=/tmp/dnsmasq.d DEFAULT_DNSMASQ_CONF_DIR=/tmp/dnsmasq.d
DNSMASQ_CONF_DIR=${DEFAULT_DNSMASQ_CONF_DIR} DNSMASQ_CONF_DIR=${DEFAULT_DNSMASQ_CONF_DIR}
@@ -21,7 +21,7 @@ local haproxy_dns = "127.0.0.1"
local cpu_thread = sys.exec('echo -n $(cat /proc/cpuinfo | grep "processor" | wc -l)') or "1" local cpu_thread = sys.exec('echo -n $(cat /proc/cpuinfo | grep "processor" | wc -l)') or "1"
local health_check_type = api.uci_get_c("@global_haproxy[0]", "health_check_type") or "tcp" local health_check_type = api.uci_get_c("@global_haproxy[0]", "health_check_type") or "tcp"
local health_check_inter = api.uci_get_c("@global_haproxy[0]", "health_check_inter") or "20" local health_check_inter = api.uci_get_c("@global_haproxy[0]", "health_check_inter") or "60"
local balancingStrategy = api.uci_get_c("@global_haproxy[0]", "balancingStrategy") or "roundrobin" local balancingStrategy = api.uci_get_c("@global_haproxy[0]", "balancingStrategy") or "roundrobin"
local console_port = api.uci_get_c("@global_haproxy[0]", "console_port") local console_port = api.uci_get_c("@global_haproxy[0]", "console_port")
local bind_local = api.uci_get_c("@global_haproxy[0]", "bind_local") or "0" local bind_local = api.uci_get_c("@global_haproxy[0]", "bind_local") or "0"
@@ -44,7 +44,7 @@ global
nbthread {{nbthread}} nbthread {{nbthread}}
external-check external-check
insecure-fork-wanted insecure-fork-wanted
spread-checks 50 spread-checks 20
tune.max-checks-per-thread 5 tune.max-checks-per-thread 5
defaults defaults
@@ -99,7 +99,7 @@ api.uci_foreach_c("haproxy_config", function(t)
local lbss = t.lbss local lbss = t.lbss
local listen_port = tonumber(t.haproxy_port) or 0 local listen_port = tonumber(t.haproxy_port) or 0
local server_node = api.uci_get_c(lbss) local server_node = api.uci_get_c(lbss)
local hop = (health_check_type == "script_logic") and (server_node.hysteria_hop or server_node.hysteria2_hop) or nil local hop = (health_check_type == "script_logic") and server_node and (server_node.hysteria_hop or server_node.hysteria2_hop) or nil
hop = hop and hop:gsub(":", "-") or nil hop = hop and hop:gsub(":", "-") or nil
if server_node and server_node.address and (server_node.port or hop) then if server_node and server_node.address and (server_node.port or hop) then
server_remark = server_node.address .. ":" .. (server_node.port or hop) server_remark = server_node.address .. ":" .. (server_node.port or hop)
@@ -153,9 +153,12 @@ api.uci_foreach_c("haproxy_config", function(t)
end end
end) end)
local inter = tonumber((health_check_inter or "20"):match("^%d+")) local inter = tonumber(health_check_inter:match("^%d+")) or 60
inter = (inter and inter >= 20) and inter or 20 inter = math.max(inter, 20)
local inter_arg = "inter " .. inter .. "s fastinter " .. inter / 2 .. "s downinter " .. inter * 1.5 .. "s" -- Bound transition checks so long normal intervals do not delay removal or recovery.
local fastinter = math.max(10, math.min(15, math.floor(inter / 2)))
local downinter = math.ceil(inter * 1.5)
local inter_arg = string.format("inter %ds fastinter %ds downinter %ds", inter, fastinter, downinter)
local sortTable = {} local sortTable = {}
for i in pairs(listens) do for i in pairs(listens) do
@@ -165,7 +168,28 @@ for i in pairs(listens) do
end end
table.sort(sortTable, function(a,b) return (a < b) end) table.sort(sortTable, function(a,b) return (a < b) end)
for i, port in pairs(sortTable) do local function server_endpoint(o)
local address = o.server_address:gsub("^%[(.*)%]$", "%1")
if address:find(":", 1, true) then address = "[" .. address .. "]" end
return address .. ":" .. o.server_port
end
local check_counts = {}
if health_check_type == "script_logic" then
for _, port in ipairs(sortTable) do
for _, o in ipairs(listens[port]) do
-- Domains can resolve to different IPs; share checks only for actual IP endpoints.
if api.is_ip(o.server_address) then
local endpoint = server_endpoint(o)
check_counts[endpoint] = (check_counts[endpoint] or 0) + 1
end
end
end
end
local shared_checks = {}
local shared_check_names = {}
for i, port in ipairs(sortTable) do
log(1, api.i18n.translatef("Entrance %s:%s", bind_address, port)) log(1, api.i18n.translatef("Entrance %s:%s", bind_address, port))
f_out:write("\n" .. string.format([[ f_out:write("\n" .. string.format([[
@@ -184,7 +208,7 @@ listen %s
local count_M, count_B = 1, 1 local count_M, count_B = 1, 1
for i, o in ipairs(listens[port]) do for i, o in ipairs(listens[port]) do
local remark = o.server_remark or "" local remark = (o.server_remark or ""):gsub("[%[%]]", "")
-- To prevent duplicate names from causing the program to fail to run. -- To prevent duplicate names from causing the program to fail to run.
if tostring(o.backup) ~= "1" then if tostring(o.backup) ~= "1" then
remark = "M" .. count_M .. "-" .. remark remark = "M" .. count_M .. "-" .. remark
@@ -193,8 +217,18 @@ listen %s
remark = "B" .. count_B .. "-" .. remark remark = "B" .. count_B .. "-" .. remark
count_B = count_B + 1 count_B = count_B + 1
end end
local server = o.server_address .. ":" .. o.server_port local server = server_endpoint(o)
local server_conf = "server {{remark}} {{server}} weight {{weight}} {{resolvers}} check {{inter_arg}} rise 2 fall 2 {{backup}}" local check_arg = "check " .. inter_arg .. " rise 2 fall 2"
if (check_counts[server] or 0) > 1 then
local name = shared_check_names[server]
if not name then
name = "check_" .. (#shared_checks + 1)
shared_check_names[server] = name
table.insert(shared_checks, {name = name, server = server})
end
check_arg = "track " .. appname .. "_health_checks/" .. name
end
local server_conf = "server {{remark}} {{server}} weight {{weight}} {{resolvers}} {{check_arg}} {{backup}}"
server_conf = server_conf:gsub("{{remark}}", remark) server_conf = server_conf:gsub("{{remark}}", remark)
server_conf = server_conf:gsub("{{server}}", server) server_conf = server_conf:gsub("{{server}}", server)
server_conf = server_conf:gsub("{{weight}}", o.lbweight) server_conf = server_conf:gsub("{{weight}}", o.lbweight)
@@ -203,7 +237,7 @@ listen %s
resolvers = "" resolvers = ""
end end
server_conf = server_conf:gsub("{{resolvers}}", resolvers) server_conf = server_conf:gsub("{{resolvers}}", resolvers)
server_conf = server_conf:gsub("{{inter_arg}}", inter_arg) server_conf = server_conf:gsub("{{check_arg}}", check_arg)
server_conf = server_conf:gsub("{{backup}}", tostring(o.backup) == "1" and "backup" or "") server_conf = server_conf:gsub("{{backup}}", tostring(o.backup) == "1" and "backup" or "")
f_out:write(" " .. api.trim(server_conf) .. "\n") f_out:write(" " .. api.trim(server_conf) .. "\n")
@@ -216,6 +250,20 @@ listen %s
end end
end end
-- A separate backend keeps manual maintenance on one listener from affecting others.
if #shared_checks > 0 then
f_out:write(string.format([[
backend %s_health_checks
mode tcp
option external-check
external-check command "/usr/share/%s/haproxy_check.sh"
]], appname, appname))
for _, check in ipairs(shared_checks) do
f_out:write(string.format("\tserver %s %s check %s rise 2 fall 2\n", check.name, check.server, inter_arg))
end
end
-- Console config -- Console config
local console_user = api.uci_get_c("@global_haproxy[0]", "console_user") local console_user = api.uci_get_c("@global_haproxy[0]", "console_user")
local console_password = api.uci_get_c("@global_haproxy[0]", "console_password") local console_password = api.uci_get_c("@global_haproxy[0]", "console_password")
@@ -234,8 +282,7 @@ f_out:close()
-- Built-in health check URL -- Built-in health check URL
if health_check_type == "script_logic" then if health_check_type == "script_logic" then
local probeUrl = api.uci_get_c("@global_haproxy[0]", "health_probe_url") or "https://www.google.com/generate_204" local probeUrl = api.trim(api.uci_get_c("@global_haproxy[0]", "health_probe_url") or "")
local f_url = io.open(haproxy_path .. "/Probe_URL", "w") if probeUrl == "" then probeUrl = "https://www.google.com/generate_204" end
f_url:write(probeUrl) api.set_cache_var("HAPROXY_PROBE_URL", probeUrl)
f_url:close()
end end
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
export PATH=/usr/sbin:/usr/bin:/sbin:/bin:/root/bin export PATH=/usr/sbin:/usr/bin:/sbin:/bin:/root/bin
CONFIG=passwall2 . /usr/share/passwall2/utils.sh
listen_address=$1 listen_address=$1
listen_port=$2 listen_port=$2
@@ -13,19 +13,16 @@ busybox pgrep -af "${CONFIG}/" | grep -E 'app\.sh.*(start|stop)|nftables\.sh|ipt
exit 0 exit 0
} }
probe_file="/tmp/etc/${CONFIG}/haproxy/Probe_URL" probeUrl=$(get_cache_var "HAPROXY_PROBE_URL")
probeUrl="https://www.google.com/generate_204" probeUrl="${probeUrl:-https://www.google.com/generate_204}"
if [ -f "$probe_file" ]; then
firstLine=$(head -n 1 "$probe_file" | tr -d ' \t\n')
[ -n "$firstLine" ] && probeUrl="$firstLine"
fi
extra_params="-x socks5h://${server_address}:${server_port}" case "$server_address" in
if /usr/bin/curl --help all | grep -q "\-\-retry-all-errors"; then \[*\]) ;;
extra_params="${extra_params} --retry-all-errors" *:*) server_address="[$server_address]" ;;
fi esac
status=$(/usr/bin/curl -I -o /dev/null -skL ${extra_params} --connect-timeout 3 --retry 1 --max-time 6 -w "%{http_code}" "${probeUrl}") # Probe once per round; HAProxy rise/fall handles consecutive successes and failures.
status=$(/usr/bin/curl -q -I -o /dev/null -sk --noproxy "" -x "socks5h://${server_address}:${server_port}" --connect-timeout 3 --max-time 6 -w "%{http_code}" "$probeUrl") || exit 1
case "$status" in case "$status" in
200|204) 200|204)
@@ -851,6 +851,11 @@ add_firewall_rule() {
done done
} }
local direct_ipt
for direct_ipt in "$ipt_n" "$ipt_m" "$ip6t_n" "$ip6t_m"; do
$direct_ipt -I OUTPUT $(comment "PSW2_DIRECT") -m owner --gid-owner $DIRECT_GID -j RETURN
done
log_i18n 0 "%s firewall rules load complete!" "iptables" log_i18n 0 "%s firewall rules load complete!" "iptables"
} }
@@ -896,7 +901,7 @@ gen_include() {
[ -z "${_ipt}" ] && return [ -z "${_ipt}" ] && return
echo "*$2" echo "*$2"
${_ipt}-save -t $2 | grep "PSW2" | grep -v "\-j PSW2$" | sed -e "s/^-A \(OUTPUT\|PREROUTING\)/-I \1 1/" ${_ipt}-save -t $2 | grep "PSW2" | grep -v "PSW2_DIRECT" | grep -v "\-j PSW2$" | sed -e "s/^-A \(OUTPUT\|PREROUTING\)/-I \1 1/"
echo 'COMMIT' echo 'COMMIT'
} }
local __ipt="" local __ipt=""
@@ -908,6 +913,8 @@ gen_include() {
$(extract_rules 4 nat) $(extract_rules 4 nat)
$(extract_rules 4 mangle) $(extract_rules 4 mangle)
EOT EOT
$ipt_n -I OUTPUT $(comment "PSW2_DIRECT") -m owner --gid-owner $DIRECT_GID -j RETURN
$ipt_m -I OUTPUT $(comment "PSW2_DIRECT") -m owner --gid-owner $DIRECT_GID -j RETURN
[ "$accept_icmp" = "1" ] && \$(${MY_PATH} insert_rule_after "$ipt_n" "PREROUTING" "prerouting_rule" "$(dst $IPSET_DIRECT !) -p icmp -j PSW2") [ "$accept_icmp" = "1" ] && \$(${MY_PATH} insert_rule_after "$ipt_n" "PREROUTING" "prerouting_rule" "$(dst $IPSET_DIRECT !) -p icmp -j PSW2")
[ -z "${is_tproxy}" ] && \$(${MY_PATH} insert_rule_after "$ipt_n" "PREROUTING" "prerouting_rule" "$(dst $IPSET_DIRECT !) -p tcp -j PSW2") [ -z "${is_tproxy}" ] && \$(${MY_PATH} insert_rule_after "$ipt_n" "PREROUTING" "prerouting_rule" "$(dst $IPSET_DIRECT !) -p tcp -j PSW2")
@@ -925,6 +932,8 @@ gen_include() {
$(extract_rules 6 nat) $(extract_rules 6 nat)
$(extract_rules 6 mangle) $(extract_rules 6 mangle)
EOT EOT
$ip6t_n -I OUTPUT $(comment "PSW2_DIRECT") -m owner --gid-owner $DIRECT_GID -j RETURN
$ip6t_m -I OUTPUT $(comment "PSW2_DIRECT") -m owner --gid-owner $DIRECT_GID -j RETURN
[ "$accept_icmpv6" = "1" ] && $ip6t_n -A PREROUTING $(dst $IPSET_DIRECT6 !) -p ipv6-icmp -j PSW2 [ "$accept_icmpv6" = "1" ] && $ip6t_n -A PREROUTING $(dst $IPSET_DIRECT6 !) -p ipv6-icmp -j PSW2
@@ -902,6 +902,9 @@ add_firewall_rule() {
filter_direct_node_list > /dev/null 2>&1 & filter_direct_node_list > /dev/null 2>&1 &
nft "insert rule $NFTABLE_NAME mangle_output meta skgid $DIRECT_GID counter return comment \"PSW2_DIRECT\""
nft "insert rule $NFTABLE_NAME nat_output meta skgid $DIRECT_GID counter return comment \"PSW2_DIRECT\""
log_i18n 0 "%s firewall rules load complete!" "nftables" log_i18n 0 "%s firewall rules load complete!" "nftables"
} }
@@ -3,6 +3,7 @@
# Copyright (C) 2026 Openwrt-Passwall Organization # Copyright (C) 2026 Openwrt-Passwall Organization
CONFIG=passwall2 CONFIG=passwall2
DIRECT_GID=9912
APP_PATH=/usr/share/${CONFIG} APP_PATH=/usr/share/${CONFIG}
TMP_PATH=/tmp/etc/${CONFIG} TMP_PATH=/tmp/etc/${CONFIG}
TMP_PATH2=${TMP_PATH}_tmp TMP_PATH2=${TMP_PATH}_tmp
@@ -268,14 +268,6 @@ return view.extend({
o.default = false; o.default = false;
o.depends("autorate","1"); o.depends("autorate","1");
o = s.taboption("tab_autorate", form.Value, "reflector_ping_interval_s", _("Reflector ping interval in seconds:"));
o.default = "0.2";
o.depends("autorate","1");
o = s.taboption("tab_autorate", form.Value, "no_pingers", _("Pingers numbers:"));
o.default = "4";
o.depends("autorate","1");
// cake-autorate types these by whether the value carries a decimal // cake-autorate types these by whether the value carries a decimal
// point (typeof float vs integer) and refuses to start an instance // point (typeof float vs integer) and refuses to start an instance
// whose value has the wrong type, so always persist a float here. // whose value has the wrong type, so always persist a float here.
@@ -285,8 +277,22 @@ return view.extend({
return form.Value.prototype.write.call(this, section_id, value); return form.Value.prototype.write.call(this, section_id, value);
}; };
// The defaults below are the ones config_template.sh applies to an
// unset option. The page showed others (25.0 ms against 250.0 ms
// for the delay threshold): a value typed in equal to the page's
// default was removed on save, and the backend's ran instead.
o = s.taboption("tab_autorate", form.Value, "reflector_ping_interval_s", _("Reflector ping interval in seconds:"));
o.default = "0.5";
o.datatype = 'ufloat';
o.write = writeFloat;
o.depends("autorate","1");
o = s.taboption("tab_autorate", form.Value, "no_pingers", _("Pingers numbers:"));
o.default = "4";
o.depends("autorate","1");
o = s.taboption("tab_autorate", form.Value, "delay_thr_ms",_("delay threshold in ms:")); o = s.taboption("tab_autorate", form.Value, "delay_thr_ms",_("delay threshold in ms:"));
o.default = "25.0"; o.default = "250.0";
o.datatype = 'ufloat'; o.datatype = 'ufloat';
o.write = writeFloat; o.write = writeFloat;
o.depends("autorate","1"); o.depends("autorate","1");
@@ -307,8 +313,9 @@ return view.extend({
o.depends("autorate","1"); o.depends("autorate","1");
o = s.taboption("tab_autorate", form.Value, "startup_wait_s",_("Number of seconds to wait on startup:")); o = s.taboption("tab_autorate", form.Value, "startup_wait_s",_("Number of seconds to wait on startup:"));
o.default = "60.0"; o.default = "0.0";
o.datatype = 'float' o.datatype = 'float';
o.write = writeFloat;
o.depends("autorate","1"); o.depends("autorate","1");
o = s.taboption("tab_autorate", form.Flag, "congestion_sync", _("Sync bufferbloat threshold with congestion score"), o = s.taboption("tab_autorate", form.Flag, "congestion_sync", _("Sync bufferbloat threshold with congestion score"),
@@ -351,6 +358,14 @@ return view.extend({
// off; without retain LuCI deletes it with the other hidden fields. // off; without retain LuCI deletes it with the other hidden fields.
o.retain = true; o.retain = true;
// Every option hidden by its dependency keeps its stored value: a
// save with autorate, the advanced qdisc options or the link layer
// off deleted delay_thr_ms (omrvpn's 300 ms from 2040-omr-sqm), the
// qdisc option strings and the overhead of every queue
s.children.forEach(function(opt) {
if (opt.deps && opt.deps.length)
opt.retain = true;
});
return m.render(); return m.render();
} }
+2 -2
View File
@@ -10,8 +10,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-vantage PKG_NAME:=luci-app-vantage
PKG_VERSION:=1.0.4 PKG_VERSION:=1.0.5
PKG_RELEASE:=10 PKG_RELEASE:=11
PKG_LICENSE:=GPL-3.0-or-later PKG_LICENSE:=GPL-3.0-or-later
LUCI_TITLE:=Vantage dashboard LUCI_TITLE:=Vantage dashboard
@@ -23,7 +23,7 @@
--vt-line-2: var(--v-line-2, #cbd0d6); --vt-line-2: var(--v-line-2, #cbd0d6);
--vt-text: var(--v-text, #15181c); --vt-text: var(--v-text, #15181c);
--vt-text-2: var(--v-text-2, #4b525b); --vt-text-2: var(--v-text-2, #4b525b);
--vt-text-3: var(--v-text-3, #6a717a); --vt-text-3: var(--v-text-3, #5f666f); /* 4.5:1 on --vt-surface-3 (tags) */
--vt-accent: var(--v-accent, #3563e9); --vt-accent: var(--v-accent, #3563e9);
--vt-accent-ink: var(--v-accent-ink, #ffffff); --vt-accent-ink: var(--v-accent-ink, #ffffff);
--vt-accent-soft: var(--v-accent-soft, rgba(53, 99, 233, .12)); --vt-accent-soft: var(--v-accent-soft, rgba(53, 99, 233, .12));
@@ -71,6 +71,7 @@
--vt-b5: var(--vt-chart-3); --vt-b5: var(--vt-chart-3);
--vt-b6: var(--vt-chart-4); --vt-b6: var(--vt-chart-4);
--vt-b60: var(--vt-text-2); --vt-b60: var(--vt-text-2);
--vt-band-ink: 60%; /* band colour in a chip's text: 4.5:1 on light surfaces */
--vt-series-up: var(--vt-chart-1); --vt-series-up: var(--vt-chart-1);
/* throughput directions: download keeps the uplink colour, upload /* throughput directions: download keeps the uplink colour, upload
takes one that no band uses */ takes one that no band uses */
@@ -123,6 +124,7 @@ html[data-darkmode="true"] .vt-app {
--vt-chart-6: var(--v-chart-6, #d9588c); --vt-chart-6: var(--v-chart-6, #d9588c);
--vt-chart-grid: var(--v-chart-grid, color-mix(in srgb, var(--vt-text) 7%, transparent)); --vt-chart-grid: var(--v-chart-grid, color-mix(in srgb, var(--vt-text) 7%, transparent));
--vt-fill-op: var(--v-chart-fill-opacity, .2); --vt-fill-op: var(--v-chart-fill-opacity, .2);
--vt-band-ink: 78%;
--vt-av-l: 24%; --vt-av-l: 24%;
--vt-av-s: 30%; --vt-av-s: 30%;
--vt-av-ink-l: 76%; --vt-av-ink-l: 76%;
@@ -224,7 +226,7 @@ html[data-darkmode="true"] .vt-app {
border-radius: 5px; font-size: var(--vt-fs-micro); font-weight: 700; letter-spacing: .01em; white-space: nowrap; border-radius: 5px; font-size: var(--vt-fs-micro); font-weight: 700; letter-spacing: .01em; white-space: nowrap;
--vt-bc: var(--vt-b60); --vt-bc: var(--vt-b60);
background: color-mix(in oklab, var(--vt-bc) 14%, transparent); background: color-mix(in oklab, var(--vt-bc) 14%, transparent);
color: color-mix(in oklab, var(--vt-bc) 78%, var(--vt-text)); color: color-mix(in oklab, var(--vt-bc) var(--vt-band-ink), var(--vt-text));
} }
.vt-app .vt-band-2g { --vt-bc: var(--vt-b2); } .vt-app .vt-band-2g { --vt-bc: var(--vt-b2); }
.vt-app .vt-band-5g { --vt-bc: var(--vt-b5); } .vt-app .vt-band-5g { --vt-bc: var(--vt-b5); }
@@ -339,6 +341,11 @@ html[data-darkmode="true"] .vt-app {
} }
.vt-app .vt-ov { display: none; } .vt-app .vt-ov { display: none; }
/* arranging: the handle bar takes the place of the tile's head (it names
the tile), and the content starts below it (--vt-ovh: the bar's height,
measured by the view) instead of under it */
.vt-app.vt-editing .vt-cell { padding-top: calc(var(--vt-ovh, 50px) + 16px); }
.vt-app.vt-editing .vt-cell > .vt-tile-head { display: none; }
.vt-app.vt-editing .vt-ov { .vt-app.vt-editing .vt-ov {
display: block; position: absolute; inset: 0; z-index: 4; display: block; position: absolute; inset: 0; z-index: 4;
border: 2px dashed var(--vt-accent-line); border-radius: var(--vt-r-lg); border: 2px dashed var(--vt-accent-line); border-radius: var(--vt-r-lg);
@@ -479,15 +486,26 @@ h2, h3{
.vt-app .vt-subline { color: var(--vt-text-3); font-size: var(--vt-fs-sm); margin-top: 2px; } .vt-app .vt-subline { color: var(--vt-text-3); font-size: var(--vt-fs-sm); margin-top: 2px; }
.vt-app .vt-livepill { .vt-app .vt-livepill {
flex: none; display: inline-flex; align-items: center; gap: 8px; flex: none; display: inline-flex; align-items: center; gap: 8px; isolation: isolate;
height: 30px; padding: 0 12px 0 10px; border-radius: 999px; height: 30px; padding: 0 12px 0 10px; border-radius: 999px;
border: 1px solid var(--vt-line); background: var(--vt-surface); border: 1px solid var(--vt-line); background: var(--vt-surface);
font-size: var(--vt-fs-sm); font-weight: 560; color: var(--vt-text-2); white-space: nowrap; font-size: var(--vt-fs-sm); font-weight: 560; color: var(--vt-text-2); white-space: nowrap;
} }
.vt-app .vt-live-beat { width: 8px; height: 8px; border-radius: 50%; background: var(--vt-ok); box-shadow: 0 0 0 0 color-mix(in oklab, var(--vt-ok) 50%, transparent); animation: vt-beat 2s infinite; } .vt-app .vt-live-beat { position: relative; width: 8px; height: 8px; border-radius: 50%; background: var(--vt-beat-c, var(--vt-ok)); }
.vt-app .vt-live-paused .vt-live-beat { background: var(--vt-text-3); animation: none; } /* the beat is a ring that scales and fades (compositor only; an animated
.vt-app .vt-live-err .vt-live-beat { background: var(--vt-warn); } box-shadow repainted every frame). It sits behind the dot (z-index -1 in
@keyframes vt-beat { 0% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--vt-ok) 45%, transparent); } 70% { box-shadow: 0 0 0 7px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } } the pill's stacking context) so the dot keeps its colour, and it is
transparent unless the animation runs: nothing shows without motion. */
.vt-app .vt-live-beat::after {
content: ""; position: absolute; inset: 0; z-index: -1; border-radius: 50%;
background: color-mix(in oklab, var(--vt-beat-c, var(--vt-ok)) 45%, transparent);
opacity: 0;
animation: vt-beat 2s ease-out infinite;
}
.vt-app .vt-live-paused .vt-live-beat { --vt-beat-c: var(--vt-text-3); }
.vt-app .vt-live-paused .vt-live-beat::after { animation: none; }
.vt-app .vt-live-err .vt-live-beat { --vt-beat-c: var(--vt-warn); }
@keyframes vt-beat { 0% { transform: scale(1); opacity: 1; } 70%, 100% { transform: scale(2.75); opacity: 0; } }
.vt-app .vt-health { .vt-app .vt-health {
display: grid; gap: 10px; margin-bottom: 14px; display: grid; gap: 10px; margin-bottom: 14px;
@@ -571,14 +589,20 @@ h2, h3{
.vt-app .vt-wire { fill: none; stroke: var(--vt-line-2); stroke-width: 1.5; transition: stroke .4s; } .vt-app .vt-wire { fill: none; stroke: var(--vt-line-2); stroke-width: 1.5; transition: stroke .4s; }
.vt-app .vt-wire-main { stroke-width: 2.5; } .vt-app .vt-wire-main { stroke-width: 2.5; }
.vt-app .vt-wire-on { stroke: color-mix(in oklab, var(--vt-accent-line) 40%, var(--vt-line-2)); } .vt-app .vt-wire-on { stroke: color-mix(in oklab, var(--vt-accent-line) 40%, var(--vt-line-2)); }
/* traffic on a wire: dots about 16 px apart moving along it (1.6, 1.05
or 0.6 s per 32 px by load), each a small element the view animates with
transform, so the compositor moves them without repainting the wires.
The SVG flow path is only the static dotted look under reduced motion. */
.vt-app .vt-flow { .vt-app .vt-flow {
fill: none; stroke: var(--vt-accent-line); stroke-width: 2.4; stroke-linecap: round; fill: none; stroke: var(--vt-accent-line); stroke-width: 2.4; stroke-linecap: round;
stroke-dasharray: 1.5 14.5; opacity: 0; transition: opacity .4s; stroke-dasharray: 1.5 6; opacity: 0;
} }
.vt-app .vt-flow-on { opacity: .95; animation: vt-flow 1.6s linear infinite; } .vt-app .vt-flows, .vt-app .vt-fwire { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 0; pointer-events: none; }
.vt-app .vt-flow-2.vt-flow-on { animation-duration: 1.05s; } .vt-app .vt-fwire { opacity: 0; transition: opacity .4s; }
.vt-app .vt-flow-3.vt-flow-on { animation-duration: .6s; stroke-width: 3; } .vt-app .vt-fwire-on { opacity: .95; }
@keyframes vt-flow { to { stroke-dashoffset: -32; } } /* a 1.5 px dash with round caps of the 2.4 px stroke, centred on its point */
.vt-app .vt-fdot { position: absolute; left: -1.95px; top: -1.2px; width: 3.9px; height: 2.4px; border-radius: 1.2px; background: var(--vt-accent-line); }
.vt-app .vt-fwire-3 .vt-fdot { left: -2.25px; top: -1.5px; width: 4.5px; height: 3px; border-radius: 1.5px; }
.vt-app .vt-node { .vt-app .vt-node {
position: relative; z-index: 1; position: relative; z-index: 1;
@@ -675,14 +699,18 @@ h2, h3{
.vt-app .vt-cores-box { display: flex; flex-direction: column; align-items: center; flex: none; cursor: help; } .vt-app .vt-cores-box { display: flex; flex-direction: column; align-items: center; flex: none; cursor: help; }
.vt-app .vt-cores { display: flex; gap: 3px; height: 38px; align-items: flex-end; } .vt-app .vt-cores { display: flex; gap: 3px; height: 38px; align-items: flex-end; }
.vt-app .vt-core { position: relative; width: 6px; height: 100%; border-radius: 3px; background: var(--vt-surface-3); overflow: hidden; } .vt-app .vt-core { position: relative; width: 6px; height: 100%; border-radius: 3px; background: var(--vt-surface-3); overflow: hidden; }
.vt-app .vt-core-fill { position: absolute; left: 0; right: 0; bottom: 0; border-radius: 3px; background: var(--vt-chart-5); transition: height .6s ease; } .vt-app .vt-core-fill { position: absolute; left: 0; right: 0; bottom: 0; border-radius: 3px; background: var(--vt-chart-5); }
.vt-app .vt-sys-mem { display: flex; flex-direction: column; gap: 7px; } .vt-app .vt-sys-mem { display: flex; flex-direction: column; gap: 7px; }
.vt-app .vt-mem-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; flex-wrap: wrap; } .vt-app .vt-mem-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
.vt-app .vt-mem-avail { font-size: var(--vt-fs-lg); font-weight: 680; } .vt-app .vt-mem-avail { font-size: var(--vt-fs-lg); font-weight: 680; }
.vt-app .vt-mem-of { font-size: var(--vt-fs-sm); color: var(--vt-text-3); } .vt-app .vt-mem-of { font-size: var(--vt-fs-sm); color: var(--vt-text-3); }
.vt-app .vt-seg { display: flex; height: 12px; border-radius: 6px; overflow: hidden; background: var(--vt-surface-3); gap: 2px; } .vt-app .vt-seg { display: flex; height: 12px; border-radius: 6px; overflow: hidden; background: var(--vt-surface-3); gap: 2px; }
.vt-app .vt-seg-part { height: 100%; min-width: 0; transition: width .7s cubic-bezier(.2, .7, .2, 1); } /* width animates only for a visible change (the view sets .vt-seg-move):
a width transition lays out every frame, and memory moves by fractions
of a percent on most polls */
.vt-app .vt-seg-part { height: 100%; min-width: 0; }
.vt-app .vt-seg-move .vt-seg-part { transition: width .7s cubic-bezier(.2, .7, .2, 1); }
.vt-app .vt-seg-used { background: var(--vt-mem); border-radius: 6px 2px 2px 6px; } .vt-app .vt-seg-used { background: var(--vt-mem); border-radius: 6px 2px 2px 6px; }
.vt-app .vt-seg-cache { .vt-app .vt-seg-cache {
background: background:
@@ -704,7 +732,7 @@ h2, h3{
.vt-app .vt-storage { display: flex; flex-direction: column; gap: 8px; } .vt-app .vt-storage { display: flex; flex-direction: column; gap: 8px; }
.vt-app .vt-mount-top { display: flex; justify-content: space-between; gap: 8px; font-size: var(--vt-fs-sm); margin-bottom: 4px; } .vt-app .vt-mount-top { display: flex; justify-content: space-between; gap: 8px; font-size: var(--vt-fs-sm); margin-bottom: 4px; }
.vt-app .vt-bar { height: 6px; border-radius: 3px; background: var(--vt-surface-3); overflow: hidden; } .vt-app .vt-bar { height: 6px; border-radius: 3px; background: var(--vt-surface-3); overflow: hidden; }
.vt-app .vt-bar-fill { display: block; height: 100%; border-radius: 3px; background: var(--vt-mem); transition: width .7s ease; } .vt-app .vt-bar-fill { display: block; height: 100%; border-radius: 3px; background: var(--vt-mem); }
.vt-app .vt-bg-warn { background: var(--vt-warn); } .vt-app .vt-bg-warn { background: var(--vt-warn); }
.vt-app .vt-bg-err { background: var(--vt-err); } .vt-app .vt-bg-err { background: var(--vt-err); }
.vt-app .vt-fw { display: flex; flex-direction: column; gap: 1px; margin-top: auto; padding-top: 10px; border-top: 1px dashed var(--vt-line); font-size: var(--vt-fs-sm); color: var(--vt-text-2); } .vt-app .vt-fw { display: flex; flex-direction: column; gap: 1px; margin-top: auto; padding-top: 10px; border-top: 1px dashed var(--vt-line); font-size: var(--vt-fs-sm); color: var(--vt-text-2); }
@@ -919,7 +947,7 @@ h2, h3{
.vt-app .vt-live-cell { display: grid; grid-template-columns: auto; gap: 3px; min-width: 96px; } .vt-app .vt-live-cell { display: grid; grid-template-columns: auto; gap: 3px; min-width: 96px; }
.vt-app .vt-live-total { font-weight: 650; } .vt-app .vt-live-total { font-weight: 650; }
.vt-app .vt-mini { display: block; height: 4px; border-radius: 2px; background: var(--vt-surface-3); overflow: hidden; } .vt-app .vt-mini { display: block; height: 4px; border-radius: 2px; background: var(--vt-surface-3); overflow: hidden; }
.vt-app .vt-mini-fill { display: block; height: 100%; background: var(--vt-info); border-radius: 2px; transition: width .6s ease; } .vt-app .vt-mini-fill { display: block; height: 100%; background: var(--vt-info); border-radius: 2px; }
.vt-app .vt-live-split { font-size: var(--vt-fs-micro); color: var(--vt-text-3); white-space: nowrap; } .vt-app .vt-live-split { font-size: var(--vt-fs-micro); color: var(--vt-text-3); white-space: nowrap; }
.vt-app .vt-exp { display: flex; align-items: center; gap: 9px; min-width: 0; } .vt-app .vt-exp { display: flex; align-items: center; gap: 9px; min-width: 0; }
.vt-app .vt-score { .vt-app .vt-score {
@@ -957,7 +985,7 @@ h2, h3{
.vt-app .vt-talk-share { font-size: var(--vt-fs-sm); font-weight: 650; color: var(--vt-text-2); white-space: nowrap; } .vt-app .vt-talk-share { font-size: var(--vt-fs-sm); font-weight: 650; color: var(--vt-text-2); white-space: nowrap; }
.vt-app .vt-talk-sub b { color: var(--vt-text); font-weight: 650; } .vt-app .vt-talk-sub b { color: var(--vt-text); font-weight: 650; }
.vt-app .vt-talk-bar { height: 5px; border-radius: 3px; background: var(--vt-surface-3); overflow: hidden; } .vt-app .vt-talk-bar { height: 5px; border-radius: 3px; background: var(--vt-surface-3); overflow: hidden; }
.vt-app .vt-talk-fill { display: block; height: 100%; border-radius: 3px; background: var(--vt-info); transition: width .6s ease; } .vt-app .vt-talk-fill { display: block; height: 100%; border-radius: 3px; background: var(--vt-info); }
.vt-app .vt-talk-sub { display: flex; gap: 8px; align-items: baseline; font-size: var(--vt-fs-micro); color: var(--vt-text-3); white-space: nowrap; overflow: hidden; } .vt-app .vt-talk-sub { display: flex; gap: 8px; align-items: baseline; font-size: var(--vt-fs-micro); color: var(--vt-text-3); white-space: nowrap; overflow: hidden; }
.vt-app .vt-ssids { display: flex; flex-direction: column; gap: 2px; } .vt-app .vt-ssids { display: flex; flex-direction: column; gap: 2px; }
@@ -1092,7 +1120,12 @@ h2, h3{
reconnecting still says so in words */ reconnecting still says so in words */
.vt-app .vt-live-ok { padding: 0 11px; } .vt-app .vt-live-ok { padding: 0 11px; }
.vt-app .vt-live-ok .vt-live-text { display: none; } .vt-app .vt-live-ok .vt-live-text { display: none; }
.vt-app .vt-livepill .vt-live-text { max-width: 110px; overflow: hidden; text-overflow: ellipsis; } /* "Paused while hidden" and the like get a row of their own under
the headline instead of being cut (the orb is 32 px + the 10 px gap) */
.vt-app .vt-head { flex-wrap: wrap; row-gap: 8px; }
.vt-app .vt-head-text { flex-basis: 160px; }
.vt-app .vt-livepill:not(.vt-live-ok) { order: 1; margin-left: 42px; min-width: 0; max-width: calc(100% - 42px); }
.vt-app .vt-livepill .vt-live-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.vt-app .vt-chart .vt-tile-extra { flex-basis: 100%; justify-content: space-between; } .vt-app .vt-chart .vt-tile-extra { flex-basis: 100%; justify-content: space-between; }
.vt-app .vt-health { grid-template-columns: 1fr 1fr; gap: 8px; } .vt-app .vt-health { grid-template-columns: 1fr 1fr; gap: 8px; }
.vt-app .vt-chip { padding: 9px 10px; gap: 8px; } .vt-app .vt-chip { padding: 9px 10px; gap: 8px; }
@@ -1163,5 +1196,7 @@ h2, h3{
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.vt-app *, .vt-app *::before, .vt-app *::after { animation: none !important; transition: none !important; } .vt-app *, .vt-app *::before, .vt-app *::after { animation: none !important; transition: none !important; }
.vt-app .vt-flow-on { opacity: .8; stroke-dasharray: 1.5 6; } .vt-app .vt-flow-on { opacity: .8; }
.vt-app .vt-flow-3.vt-flow-on { stroke-width: 3; }
.vt-app .vt-flows { display: none; }
} }
@@ -187,6 +187,19 @@ return baseclass.extend({
return { h: prev.h, since: since }; return { h: prev.h, since: since };
}, },
/* ms until the earliest held height may be released (0 when one is
already due), or null when nothing is held */
nextRelease: function(held, now) {
var next = null, self = this;
Object.keys(held || {}).forEach(function(id) {
var s = held[id];
if (!s || s.since == null) return;
var left = Math.max(0, s.since + self.HOLD_MS - now);
if (next == null || left < next) next = left;
});
return next;
},
/* where shown tiles go. items: [{ id, c, w, h }] in order, h measured; /* where shown tiles go. items: [{ id, c, w, h }] in order, h measured;
returns { rects: { id: { c, w, top, h } }, height }. A tile that returns { rects: { id: { c, w, top, h } }, height }. A tile that
ends a little above the next one in its columns stretches to meet ends a little above the next one in its columns stretches to meet
@@ -0,0 +1,95 @@
'use strict';
'require baseclass';
/* Geometry of the network path's wires and of the dots that flow along
them. The view measures the nodes; this module turns two boxes into a
path (`d` for the SVG wire) plus the same curve as points, and the
points into evenly spaced keyframes, so the dots can move with
transform (compositor only) instead of an animated stroke-dashoffset,
which repainted the whole wire layer every frame. Pure functions.
A box is { l, r, t, b, cy } relative to the path element. */
var STEPS = 32; /* points per curve before resampling */
var GAP = 16; /* px between dots, as the old dash pattern */
var STRIDE = 32; /* px a dot travels per period */
var PERIOD = { 1: 1600, 2: 1050, 3: 600 }; /* ms per STRIDE by speed */
function f1(v) { return v.toFixed(1); }
return baseclass.extend({
GAP: GAP,
/* bits per second -> 1 (idle trickle), 2, 3 (busy) */
speed: function(bps) { return bps > 100e6 ? 3 : bps > 5e6 ? 2 : 1; },
/* from box a to box b: side by side an S-curve between their facing
edges, stacked an elbow down a's left side into b */
curve: function(a, b) {
var x1, y1, x2, y2, pts = [], i;
if (b.l >= a.r - 2) {
x1 = a.r; y1 = a.cy; x2 = b.l; y2 = b.cy;
var mx = (x1 + x2) / 2;
for (i = 0; i <= STEPS; i++) {
var t = i / STEPS, u = 1 - t;
/* cubic Bézier with both control points at mx */
pts.push([ u * u * u * x1 + 3 * u * t * mx + t * t * t * x2, (u * u * u + 3 * u * u * t) * y1 + (3 * u * t * t + t * t * t) * y2 ]);
}
return { d: 'M' + f1(x1) + ' ' + f1(y1) + 'C' + f1(mx) + ' ' + f1(y1) + ' ' + f1(mx) + ' ' + f1(y2) + ' ' + f1(x2) + ' ' + f1(y2), pts: pts };
}
x1 = a.l + 18; y1 = a.b; x2 = b.l; y2 = b.cy;
if (x2 <= x1 + 6) return { d: 'M' + f1(x1) + ' ' + f1(y1) + 'V' + f1(b.t), pts: [ [ x1, y1 ], [ x1, b.t ] ] };
var rad = Math.min(8, Math.max(0, y2 - y1));
pts.push([ x1, y1 ]);
for (i = 0; i <= 8; i++) {
/* quadratic corner from (x1, y2 - rad) via (x1, y2) to (x1 + rad, y2) */
var s = i / 8, v = 1 - s;
pts.push([ v * v * x1 + 2 * v * s * x1 + s * s * (x1 + rad), v * v * (y2 - rad) + 2 * v * s * y2 + s * s * y2 ]);
}
pts.push([ x2, y2 ]);
return { d: 'M' + f1(x1) + ' ' + f1(y1) + 'V' + f1(y2 - rad) + 'Q' + f1(x1) + ' ' + f1(y2) + ' ' + f1(x1 + rad) + ' ' + f1(y2) + 'H' + f1(x2), pts: pts };
},
/* points at equal distances along a polyline, about `step` px apart,
each with the direction of travel in degrees (unwrapped, so the
keyframes never turn the long way round); { pts: [[x, y, deg]], len } */
resample: function(pts, step) {
var cum = [ 0 ], i, out = [];
for (i = 1; i < pts.length; i++)
cum.push(cum[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]));
var len = cum[cum.length - 1];
if (!(len > 0)) return { pts: [], len: 0 };
var n = Math.max(1, Math.ceil(len / step)), j = 1, prev = null;
for (i = 0; i <= n; i++) {
var s = len * i / n;
while (j < pts.length - 1 && cum[j] < s) j++;
/* the segment [j-1, j] holds s; skip zero-length segments for the angle */
var k = j;
while (k < pts.length - 1 && cum[k] === cum[k - 1]) k++;
var seg = cum[j] - cum[j - 1], f = seg > 0 ? (s - cum[j - 1]) / seg : 0;
var dx = pts[k][0] - pts[k - 1][0], dy = pts[k][1] - pts[k - 1][1];
var deg = Math.atan2(dy, dx) * 180 / Math.PI;
if (prev != null) { while (deg - prev > 180) deg -= 360; while (deg - prev < -180) deg += 360; }
prev = deg;
out.push([ pts[j - 1][0] + (pts[j][0] - pts[j - 1][0]) * f, pts[j - 1][1] + (pts[j][1] - pts[j - 1][1]) * f, deg ]);
}
return { pts: out, len: len };
},
/* the Web Animations keyframes for one trip along the samples */
keyframes: function(samples) {
var frames = [];
samples.forEach(function(p) {
frames.push({ transform: 'translate(' + p[0].toFixed(2) + 'px, ' + p[1].toFixed(2) + 'px) rotate(' + p[2].toFixed(1) + 'deg)' });
});
return frames;
},
/* how many dots a wire of this length carries (about GAP apart) */
dots: function(len) { return Math.max(1, Math.round(len / GAP)); },
/* one trip along a wire at speed 1, in ms; faster speeds play it at
rate(speed), so a speed change keeps every dot where it is */
trip: function(len) { return len / STRIDE * PERIOD[1]; },
rate: function(speed) { return PERIOD[1] / (PERIOD[speed] || PERIOD[1]); }
});
@@ -11,6 +11,7 @@
'require vantage.insight as insight'; 'require vantage.insight as insight';
'require vantage.series as series'; 'require vantage.series as series';
'require vantage.layout as layout'; 'require vantage.layout as layout';
'require vantage.wires as wires';
/* Vantage dashboard (luci-app-vantage): a live, read-only overview of the /* Vantage dashboard (luci-app-vantage): a live, read-only overview of the
device, its uplink, radios, SSIDs and stations. The only write is the device, its uplink, radios, SSIDs and stations. The only write is the
@@ -245,8 +246,52 @@ function sparkline(data, cols, vmax, cls, opts) {
/* -------------------------------------------------------------- helpers */ /* -------------------------------------------------------------- helpers */
function txt(el, s) { el.textContent = (s == null ? '' : String(s)); return el; } /* text writes only when the text changes: an unchanged poll leaves the
function setW(el, pct) { el.style.width = Math.max(0, Math.min(100, +pct || 0)).toFixed(2) + '%'; } node, and its layout, alone */
function txt(el, s) { s = (s == null ? '' : String(s)); if (el.textContent !== s) el.textContent = s; return el; }
function setClass(el, c) { if (el.getAttribute('class') !== c) el.setAttribute('class', c); }
function setTitle(el, v) { if (el.getAttribute('title') !== v) el.setAttribute('title', v); }
/* an element's own tag and attributes, or a text node's text */
function nodeSig(n) {
if (n.nodeType !== 1) return n.nodeType + '"' + n.data;
return '<' + n.tagName + n.getAttributeNames().map(function(a) { return ' ' + a + '=' + n.getAttribute(a); }).join('');
}
/* what a freshly built subtree would show (tags, attributes, text), so a
poll can keep the nodes it would only rebuild identically */
function treeSig(nodes) {
var out = [];
(function walk(list) {
Array.prototype.forEach.call(list, function(n) {
if (n == null || typeof n !== 'object') return;
out.push(nodeSig(n));
if (n.nodeType === 1) { walk(n.childNodes); out.push('>'); }
});
})(nodes);
return out.join('\n');
}
/* put freshly built nodes (Nodes only) into parent, keeping every old
subtree that would come out the same and descending into elements whose
own tag and attributes did not change: a poll that moves one sparkline
replaces that sparkline, not the card around it. Old elements keep their
listeners, so whatever a handler acts on must show in an attribute. */
function patch(parent, nodes) {
nodes = nodes.filter(function(n) { return n != null && n !== ''; }).map(function(n) { return typeof n === 'object' ? n : document.createTextNode(String(n)); });
var old = parent.childNodes;
if (old.length !== nodes.length) {
while (parent.firstChild) parent.removeChild(parent.firstChild);
nodes.forEach(function(n) { parent.appendChild(n); });
return;
}
nodes.slice().forEach(function(n, i) {
var o = old[i];
if (nodeSig(o) !== nodeSig(n)) parent.replaceChild(n, o);
else if (o.nodeType === 1) patch(o, Array.prototype.slice.call(n.childNodes));
});
}
function setW(el, pct) { var w = Math.max(0, Math.min(100, +pct || 0)).toFixed(2) + '%'; if (el.style.width !== w) el.style.width = w; }
function hueOf(mac) { function hueOf(mac) {
var h = 0, s = String(mac || ''); var h = 0, s = String(mac || '');
for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360;
@@ -283,14 +328,6 @@ function kv(label, value, mono) {
]); ]);
} }
/* one of the four numbers under the network path */
function kpi(label, value, sub, tone, onclick) {
var body = [ E('span', { 'class': 'vt-kpi-label' }, [ label ]), E('span', { 'class': 'vt-kpi-value vt-tn' }, [ value ]), E('span', { 'class': 'vt-kpi-sub' }, [ sub ]) ];
return onclick
? E('button', { 'type': 'button', 'class': 'vt-kpi vt-kpi-' + (tone || 'plain'), 'click': onclick }, body)
: E('div', { 'class': 'vt-kpi vt-kpi-' + (tone || 'plain') }, body);
}
function tileHead(ic, title, extra) { function tileHead(ic, title, extra) {
return E('header', { 'class': 'vt-tile-head' }, [ return E('header', { 'class': 'vt-tile-head' }, [
E('h3', { 'class': 'vt-tile-title' }, [ icon(ic), E('span', {}, [ title ]) ]), E('h3', { 'class': 'vt-tile-title' }, [ icon(ic), E('span', {}, [ title ]) ]),
@@ -702,6 +739,10 @@ return view.extend({
this.buildDrawer() this.buildDrawer()
]); ]);
this.el.root = root; this.el.root = root;
/* wires follow the nodes' geometry: redrawn when the path or a
node changes size (observer callbacks run after layout, so the
reads there force nothing), never on a poll that moves nothing */
if (window.ResizeObserver) this.wireRo = new ResizeObserver(function() { self.drawWires(); });
this.paint(); this.paint();
@@ -712,15 +753,23 @@ return view.extend({
}); });
document.addEventListener('keydown', L.bind(this.handleKey, this)); document.addEventListener('keydown', L.bind(this.handleKey, this));
window.setInterval(function() { self.paintLive(); }, 1000); window.setInterval(function() { self.paintLive(); }, 1000);
if (window.ResizeObserver) new ResizeObserver(function() { self.scheduleWires(); }).observe(this.el.path);
if (window.ResizeObserver) { if (window.ResizeObserver) {
var ro = new ResizeObserver(function() { self.scheduleLayout(); }); var ro = new ResizeObserver(function() { self.scheduleLayout(); });
ro.observe(this.el.grid); ro.observe(this.el.grid);
layout.TILES.forEach(function(id) { ro.observe(self.tiles[id]); }); layout.TILES.forEach(function(id) {
ro.observe(self.tiles[id]);
/* a held min-height keeps the tile's box still while its
content changes: watch the content as well */
Array.prototype.forEach.call(self.tiles[id].children, function(c) { if (!c.classList.contains('vt-ov')) ro.observe(c); });
});
}
if (window.matchMedia) {
var motion = window.matchMedia('(prefers-reduced-motion: reduce)');
if (motion.addEventListener) motion.addEventListener('change', function() { self.paintWireState(); });
} }
window.addEventListener('resize', function() { self.scheduleLayout(); }); window.addEventListener('resize', function() { self.scheduleLayout(); });
window.addEventListener('hashchange', function() { self.openFromHash(); }); window.addEventListener('hashchange', function() { self.openFromHash(); });
window.requestAnimationFrame(function() { self.drawWires(); self.openFromHash(); }); window.requestAnimationFrame(function() { if (!self.wireRo) self.drawWires(); self.openFromHash(); });
return root; return root;
}, },
@@ -737,8 +786,11 @@ return view.extend({
this.paintSsids(); this.paintSsids();
this.paintDrawer(); this.paintDrawer();
this.paintLive(); this.paintLive();
this.scheduleWires(); if (!this.wireRo) this.scheduleWires();
this.scheduleLayout(); /* the tiles' ResizeObserver lays out again when a tile changes
size, so a poll lays out only when the tile set may change (the
first one, wired or not); one that changes nothing measures nothing */
if (this.laidWired !== !!this.m.wired) { this.laidWired = !!this.m.wired; this.scheduleLayout(); }
}, },
/* ------------------------------------------------------------ folds */ /* ------------------------------------------------------------ folds */
@@ -769,7 +821,7 @@ return view.extend({
writeFolds(this.folds); writeFolds(this.folds);
this.applyFold(key); this.applyFold(key);
if (this.held) delete this.held[key]; if (this.held) delete this.held[key];
if (key === 'hero') this.scheduleWires(); if (key === 'hero' && !this.wireRo) this.scheduleWires();
if (key === 'chart') this.paintChart(); if (key === 'chart') this.paintChart();
}, },
@@ -839,6 +891,11 @@ return view.extend({
t.style.setProperty('--w', Math.min(e.w, cols).toFixed(0)); t.style.setProperty('--w', Math.min(e.w, cols).toFixed(0));
t.style.minHeight = ''; t.style.minHeight = '';
}); });
/* arranging: each tile's content starts below its handle bar */
if (this.editing) {
var bars = shown.map(function(e) { return self.ov[e.id].bar.offsetHeight; });
shown.forEach(function(e, k) { tiles[e.id].style.setProperty('--vt-ovh', bars[k].toFixed(0) + 'px'); });
}
/* live content changes hold a tile's height for a while; a new /* live content changes hold a tile's height for a while; a new
width or an edit shows true sizes at once */ width or an edit shows true sizes at once */
if (this.heldWidth !== width || this.drag) { this.held = Object.create(null); this.heldWidth = width; } if (this.heldWidth !== width || this.drag) { this.held = Object.create(null); this.heldWidth = width; }
@@ -855,6 +912,10 @@ return view.extend({
t.style.setProperty('--y', r.top.toFixed(1) + 'px'); t.style.setProperty('--y', r.top.toFixed(1) + 'px');
}); });
grid.style.height = p.height.toFixed(1) + 'px'; grid.style.height = p.height.toFixed(1) + 'px';
/* polls no longer lay out, so the end of a hold needs its own wake-up */
var wait = layout.nextRelease(held, now);
if (this.holdTimer) { window.clearTimeout(this.holdTimer); this.holdTimer = null; }
if (wait != null) this.holdTimer = window.setTimeout(function() { self.holdTimer = null; self.scheduleLayout(); }, wait + 50);
var g = this.drag && p.rects[this.drag.id], ghost = this.el.ghost; var g = this.drag && p.rects[this.drag.id], ghost = this.el.ghost;
ghost.classList.toggle('vt-on', !!g); ghost.classList.toggle('vt-on', !!g);
if (g) { if (g) {
@@ -915,10 +976,11 @@ return view.extend({
this.el.root.classList.toggle('vt-editing', this.editing); this.el.root.classList.toggle('vt-editing', this.editing);
this.el.layoutBtn.setAttribute('aria-pressed', String(this.editing)); this.el.layoutBtn.setAttribute('aria-pressed', String(this.editing));
this.el.editBar.hidden = !this.editing; this.el.editBar.hidden = !this.editing;
if (this.editing) { /* the handle bars change the tiles' heights both ways: true sizes
this.doLayout(); at once, nothing held from the other mode */
this.el.editDone.focus(); this.held = Object.create(null);
} this.doLayout();
if (this.editing) this.el.editDone.focus();
else this.el.layoutBtn.focus(); else this.el.layoutBtn.focus();
}, },
@@ -985,7 +1047,7 @@ return view.extend({
self.el.editDone.focus(); self.el.editDone.focus();
}); });
o.root = E('div', { 'class': 'vt-ov vt-fold-keep', 'role': 'group', 'aria-label': name }, [ o.root = E('div', { 'class': 'vt-ov vt-fold-keep', 'role': 'group', 'aria-label': name }, [
E('div', { 'class': 'vt-ov-bar' }, [ o.bar = E('div', { 'class': 'vt-ov-bar' }, [
o.grip, o.grip,
E('span', { 'class': 'vt-ov-name' }, [ name ]), E('span', { 'class': 'vt-ov-name' }, [ name ]),
E('span', { 'class': 'vt-ov-btns' }, [ o.up, o.down, o.left, o.right, o.narrow, o.size, o.wide, o.hide ]) E('span', { 'class': 'vt-ov-btns' }, [ o.up, o.down, o.left, o.right, o.narrow, o.size, o.wide, o.hide ])
@@ -1145,6 +1207,10 @@ return view.extend({
paintHealth: function() { paintHealth: function() {
var self = this; var self = this;
/* the same checks as last poll: leave the chips alone */
var sig = JSON.stringify(this.st.health.map(function(c) { return [ c.level, c.title, c.value, c.reason, c.detail, c.client, c.page ]; }));
if (sig === this.healthSig) return;
this.healthSig = sig;
dom.content(this.el.health, this.st.health.map(function(c) { dom.content(this.el.health, this.st.health.map(function(c) {
var ic = c.level === 'ok' ? 'check' : c.level === 'err' ? 'err' : c.level === 'warn' ? 'warn' : 'info'; var ic = c.level === 'ok' ? 'check' : c.level === 'err' ? 'err' : c.level === 'warn' ? 'warn' : 'info';
var body = [ var body = [
@@ -1164,11 +1230,15 @@ return view.extend({
/* ------------------------------------------------------------- hero */ /* ------------------------------------------------------------- hero */
buildHero: function() { buildHero: function() {
var self = this, el = this.el; var el = this.el;
el.path = E('div', { 'class': 'vt-path' }); el.path = E('div', { 'class': 'vt-path' });
el.wires = svgRoot('vt-wires', '0 0 10 10'); el.wires = svgRoot('vt-wires', '0 0 10 10');
/* the flowing dots: small elements moved with transform */
el.flows = E('div', { 'class': 'vt-flows', 'aria-hidden': 'true' });
el.path.appendChild(el.wires); el.path.appendChild(el.wires);
this.wireMap = {}; el.path.appendChild(el.flows);
this.wireMap = Object.create(null);
this.hero = null;
el.kpis = E('div', { 'class': 'vt-kpis vt-fold-keep' }); el.kpis = E('div', { 'class': 'vt-kpis vt-fold-keep' });
return this.foldable('hero', E('section', { 'class': 'vt-tile vt-hero', 'aria-label': _('Network path') }, [ return this.foldable('hero', E('section', { 'class': 'vt-tile vt-hero', 'aria-label': _('Network path') }, [
tileHead('pulse', _('Network path'), [ E('span', { 'class': 'vt-hint vt-hint-wide' }, [ _('Click any node for details') ]), this.foldButton('hero', _('Show or hide the network path')) ]), tileHead('pulse', _('Network path'), [ E('span', { 'class': 'vt-hint vt-hint-wide' }, [ _('Click any node for details') ]), this.foldButton('hero', _('Show or hide the network path')) ]),
@@ -1177,137 +1247,239 @@ return view.extend({
])); ]));
}, },
paintHero: function() { /* the nodes the path shows: rebuilt only when this changes (a radio,
var self = this, m = this.m, st = this.st, up = m.uplink, path = this.el.path; SSID or network comes or goes); a poll updates text in place */
var up_r = st.uplinkRate; heroKey: function() {
var m = this.m;
/* keep the wires layer, rebuild the nodes (cheap, a dozen elements) */ if (m.wired) return 'w\n' + m.networks.filter(function(n) { return n.up; }).slice(0, 8).map(function(n) { return n.name; }).join('\n');
while (path.lastChild && path.lastChild !== this.el.wires) path.removeChild(path.lastChild); return 'r\n' + m.radios.map(function(r) {
while (path.firstChild && path.firstChild !== this.el.wires) path.removeChild(path.firstChild); return r.id + '=' + m.ssids.filter(function(s) { return s.radio === r.id; }).map(function(s) { return s.id; }).join(',');
}).join('\n');
this.nodes = {};
var gw = E('button', { 'type': 'button', 'class': 'vt-node vt-node-gw', 'click': function() { self.openDrawer('uplink', 'uplink'); } }, [
E('span', { 'class': 'vt-node-ico' }, [ icon('globe') ]),
E('span', { 'class': 'vt-node-body' }, [
E('span', { 'class': 'vt-node-kicker' }, [ up && up.isWan ? _('Internet') : _('Gateway') ]),
E('span', { 'class': 'vt-node-title' }, [ up && up.gateway ? up.gateway : _('No default route') ]),
E('span', { 'class': 'vt-node-sub' }, [ up && up.dns.length ? _('DNS %s').format(up.dns[0]) : _('Reachability not probed') ])
])
]);
this.nodes.gw = gw;
var link = E('div', { 'class': 'vt-link-label' }, [
E('span', { 'class': 'vt-link-rate vt-link-down' }, [ icon('down'), E('b', {}, [ up_r ? fmt.bitsParts(up_r.rx).v : fmt.DASH ]), E('small', {}, [ up_r ? fmt.bitsParts(up_r.rx).u : '' ]) ]),
E('span', { 'class': 'vt-link-below' }, [
E('span', { 'class': 'vt-link-rate vt-link-up' }, [ icon('up'), E('b', {}, [ up_r ? fmt.bitsParts(up_r.tx).v : fmt.DASH ]), E('small', {}, [ up_r ? fmt.bitsParts(up_r.tx).u : '' ]) ]),
E('span', { 'class': 'vt-link-meta' }, [ up ? [ up.dev, up.speed ? (up.speed.mbps >= 1000 ? (up.speed.mbps / 1000) + 'G' : up.speed.mbps + 'M') + (up.speed.duplex === 'full' ? ' FD' : up.speed.duplex === 'half' ? ' HD' : '') : null ].filter(Boolean).join(' · ') : _('no uplink') ])
])
]);
var d = m.device;
var ap = E('button', { 'type': 'button', 'class': 'vt-node vt-node-ap', 'click': function() { self.openDrawer('device', 'device'); } }, [
E('span', { 'class': 'vt-node-ico' }, [ icon('ap') ]),
E('span', { 'class': 'vt-node-body' }, [
E('span', { 'class': 'vt-node-kicker' }, [ m.wired ? _('This device') : _('This access point') ]),
E('span', { 'class': 'vt-node-title' }, [ d.model ]),
E('span', { 'class': 'vt-node-sub vt-mono' }, [ up && up.ipv4[0] ? up.ipv4[0].split('/')[0] : fmt.DASH ]),
E('span', { 'class': 'vt-node-sub' }, [ d.uptime != null ? _('up %s').format(fmt.duration(d.uptime)) : '' ])
])
]);
this.nodes.ap = ap;
var branches = E('div', { 'class': 'vt-branches' });
this.nodes.radios = [];
m.radios.forEach(function(r) {
var rr = st.radioRates[r.id];
var rnode = E('button', { 'type': 'button', 'class': 'vt-node vt-node-radio' + (r.up ? '' : ' vt-node-off'), 'click': function() { self.openDrawer('radio', r.id); } }, [
E('span', { 'class': 'vt-node-body' }, [
E('span', { 'class': 'vt-node-kicker' }, [ bandBadge(r.band), E('span', { 'class': 'vt-node-gen' }, [ r.gen || '' ]) ]),
E('span', { 'class': 'vt-node-title vt-tn' }, [ r.channel ? _('Ch %d · %d MHz').format(r.channel, r.width || 20) : _('Channel —') ]),
E('span', { 'class': 'vt-node-sub vt-tn' }, [ r.disabled ? _('Disabled') : !r.up ? _('Down') : rr ? '↓ ' + fmt.bits(rr.tx) : fmt.DASH ])
])
]);
var ssidCol = E('div', { 'class': 'vt-branch-ssids' });
var ssNodes = [];
m.ssids.filter(function(s) { return s.radio === r.id; }).forEach(function(s) {
var dots = E('span', { 'class': 'vt-dots' });
m.clients.filter(function(c) { return c.ssidId === s.id; }).slice(0, 8).forEach(function(c) {
var dot = E('span', { 'class': 'vt-dot vt-lvl-' + c.level.level, 'title': c.name + ' · ' + fmt.dbm(c.signal) });
dots.appendChild(dot);
});
var sn = E('button', { 'type': 'button', 'class': 'vt-node vt-node-ssid' + (s.clients ? '' : ' vt-node-idle'), 'title': _('Show the clients of %s').format(s.ssid),
'click': function() { self.filterSsid(s.ssid, s.band); } }, [
E('span', { 'class': 'vt-node-body' }, [
E('span', { 'class': 'vt-node-title vt-ssid-name' }, [ icon(s.security.tone === 'open' ? 'unlock' : 'lock', 'vt-ico-sm'), E('span', {}, [ s.ssid || _('(hidden)') ]) ]),
E('span', { 'class': 'vt-node-sub' }, [ dots, E('span', { 'class': 'vt-tn' }, [ s.clients === 1 ? _('1 client') : _('%d clients').format(s.clients) ]) ])
])
]);
ssNodes.push({ el: sn, active: !!(st.ssidRates[s.id] && st.ssidRates[s.id].rx + st.ssidRates[s.id].tx > 50e3) });
ssidCol.appendChild(sn);
});
if (!ssNodes.length) ssidCol.appendChild(E('span', { 'class': 'vt-empty-inline' }, [ _('No SSIDs') ]));
self.nodes.radios.push({ el: rnode, ssids: ssNodes, active: !!(rr && rr.rx + rr.tx > 50e3), rate: rr ? rr.rx + rr.tx : 0 });
branches.appendChild(E('div', { 'class': 'vt-branch' }, [ rnode, ssidCol ]));
});
if (m.wired) this.netBranches(branches);
else if (!m.radios.length) branches.appendChild(E('div', { 'class': 'vt-empty-inline' }, [ _('No radios reported') ]));
this.nodes.upActive = !!(up_r && up_r.rx + up_r.tx > 50e3);
this.nodes.upRate = up_r ? up_r.rx + up_r.tx : 0;
path.insertBefore(gw, this.el.wires);
path.insertBefore(link, this.el.wires);
path.insertBefore(ap, this.el.wires);
path.insertBefore(branches, this.el.wires);
this.paintKpis();
}, },
/* wired-only: the device's other networks take the radios' place, paintHero: function() {
wired into the path the same way (nodes.radios) */ var key = this.heroKey();
netBranches: function(branches) { if (!this.hero || this.hero.key !== key) this.buildHeroNodes(key);
var st = this.st, self = this; this.updateHero();
var nets = this.m.networks.filter(function(n) { return n.up; }).slice(0, 8); this.paintKpis();
nets.forEach(function(n) { this.paintWireState();
var r = n.dev ? st.devRates[n.dev] : null; },
var node = E('a', { 'class': 'vt-node vt-node-radio vt-node-net', 'href': L.url.apply(L, insight.PAGES.network),
'title': [ n.name, n.dev, n.ipv4 ].filter(Boolean).join(' · ') }, [ buildHeroNodes: function(key) {
E('span', { 'class': 'vt-node-body' }, [ var self = this, m = this.m, path = this.el.path;
E('span', { 'class': 'vt-node-kicker' }, [ n.proto || _('Interface') ]), function span(cls) { return E('span', { 'class': cls }); }
E('span', { 'class': 'vt-node-title' }, [ n.name ]), var h = { key: key, radios: [] };
E('span', { 'class': 'vt-node-sub vt-mono' }, [ n.ipv4 ? n.ipv4.split('/')[0] : (n.dev || fmt.DASH) ]),
E('span', { 'class': 'vt-node-sub vt-tn' }, [ r ? '↓ ' + fmt.bitsShort(r.tx) + ' ↑ ' + fmt.bitsShort(r.rx) : fmt.DASH ]) /* everything but the wires and the dots goes */
]) Array.prototype.slice.call(path.childNodes).forEach(function(c) {
]); if (c !== self.el.wires && c !== self.el.flows) path.removeChild(c);
self.nodes.radios.push({ el: node, ssids: [], active: !!(r && r.rx + r.tx > 50e3), rate: r ? r.rx + r.tx : 0 }); });
branches.appendChild(E('div', { 'class': 'vt-branch' }, [ node ]));
h.gwKicker = span('vt-node-kicker'); h.gwTitle = span('vt-node-title'); h.gwSub = span('vt-node-sub');
h.gw = E('button', { 'type': 'button', 'class': 'vt-node vt-node-gw', 'click': function() { self.openDrawer('uplink', 'uplink'); } }, [
E('span', { 'class': 'vt-node-ico' }, [ icon('globe') ]),
E('span', { 'class': 'vt-node-body' }, [ h.gwKicker, h.gwTitle, h.gwSub ])
]);
h.dnVal = E('b'); h.dnUnit = E('small'); h.upVal = E('b'); h.upUnit = E('small'); h.linkMeta = span('vt-link-meta');
h.link = E('div', { 'class': 'vt-link-label' }, [
E('span', { 'class': 'vt-link-rate vt-link-down' }, [ icon('down'), h.dnVal, h.dnUnit ]),
E('span', { 'class': 'vt-link-below' }, [
E('span', { 'class': 'vt-link-rate vt-link-up' }, [ icon('up'), h.upVal, h.upUnit ]),
h.linkMeta
])
]);
h.apKicker = span('vt-node-kicker'); h.apTitle = span('vt-node-title'); h.apAddr = span('vt-node-sub vt-mono'); h.apUp = span('vt-node-sub');
h.ap = E('button', { 'type': 'button', 'class': 'vt-node vt-node-ap', 'click': function() { self.openDrawer('device', 'device'); } }, [
E('span', { 'class': 'vt-node-ico' }, [ icon('ap') ]),
E('span', { 'class': 'vt-node-body' }, [ h.apKicker, h.apTitle, h.apAddr, h.apUp ])
]);
h.branches = E('div', { 'class': 'vt-branches' });
h.watch = [ path, h.gw, h.link, h.ap, h.branches ];
if (m.wired) {
m.networks.filter(function(n) { return n.up; }).slice(0, 8).forEach(function(n) {
var b = { id: n.name, ssids: [], kicker: span('vt-node-kicker'), title: span('vt-node-title'), addr: span('vt-node-sub vt-mono'), sub: span('vt-node-sub vt-tn') };
b.el = E('a', { 'class': 'vt-node vt-node-radio vt-node-net', 'href': L.url.apply(L, insight.PAGES.network) }, [
E('span', { 'class': 'vt-node-body' }, [ b.kicker, b.title, b.addr, b.sub ])
]);
b.wrap = E('div', { 'class': 'vt-branch' }, [ b.el ]);
h.radios.push(b);
});
if (!h.radios.length) h.branches.appendChild(E('div', { 'class': 'vt-empty-inline' }, [ _('No other interfaces are up') ]));
}
else {
m.radios.forEach(function(r) {
var b = { id: r.id, ssids: [], badge: span('vt-band'), gen: span('vt-node-gen'), title: span('vt-node-title vt-tn'), sub: span('vt-node-sub vt-tn') };
b.el = E('button', { 'type': 'button', 'class': 'vt-node vt-node-radio', 'click': function() { self.openDrawer('radio', b.id); } }, [
E('span', { 'class': 'vt-node-body' }, [ E('span', { 'class': 'vt-node-kicker' }, [ b.badge, b.gen ]), b.title, b.sub ])
]);
var col = E('div', { 'class': 'vt-branch-ssids' });
m.ssids.filter(function(s) { return s.radio === r.id; }).forEach(function(s) {
var sn = { id: s.id, data: s, tone: null, ico: null, name: E('span'), dots: span('vt-dots'), count: span('vt-tn'), sig: null };
sn.title = E('span', { 'class': 'vt-node-title vt-ssid-name' }, [ sn.name ]);
sn.el = E('button', { 'type': 'button', 'class': 'vt-node vt-node-ssid',
'click': function() { self.filterSsid(sn.data.ssid, sn.data.band); } }, [
E('span', { 'class': 'vt-node-body' }, [ sn.title, E('span', { 'class': 'vt-node-sub' }, [ sn.dots, sn.count ]) ])
]);
b.ssids.push(sn);
col.appendChild(sn.el);
});
if (!b.ssids.length) col.appendChild(E('span', { 'class': 'vt-empty-inline' }, [ _('No SSIDs') ]));
b.wrap = E('div', { 'class': 'vt-branch' }, [ b.el, col ]);
h.radios.push(b);
});
if (!h.radios.length) h.branches.appendChild(E('div', { 'class': 'vt-empty-inline' }, [ _('No radios reported') ]));
}
h.radios.forEach(function(b) {
h.branches.appendChild(b.wrap);
h.watch.push(b.wrap, b.el);
b.ssids.forEach(function(sn) { h.watch.push(sn.el); });
});
path.insertBefore(h.gw, this.el.wires);
path.insertBefore(h.link, this.el.wires);
path.insertBefore(h.ap, this.el.wires);
path.insertBefore(h.branches, this.el.wires);
this.hero = h;
if (this.wireRo) {
var ro = this.wireRo;
ro.disconnect();
h.watch.forEach(function(el) { ro.observe(el); });
}
},
/* this poll's text, classes and flow state on the nodes in place */
updateHero: function() {
var self = this, m = this.m, st = this.st, h = this.hero, up = m.uplink, upR = st.uplinkRate, d = m.device;
txt(h.gwKicker, up && up.isWan ? _('Internet') : _('Gateway'));
txt(h.gwTitle, up && up.gateway ? up.gateway : _('No default route'));
txt(h.gwSub, up && up.dns.length ? _('DNS %s').format(up.dns[0]) : _('Reachability not probed'));
txt(h.dnVal, upR ? fmt.bitsParts(upR.rx).v : fmt.DASH);
txt(h.dnUnit, upR ? fmt.bitsParts(upR.rx).u : '');
txt(h.upVal, upR ? fmt.bitsParts(upR.tx).v : fmt.DASH);
txt(h.upUnit, upR ? fmt.bitsParts(upR.tx).u : '');
txt(h.linkMeta, up ? [ up.dev, up.speed ? (up.speed.mbps >= 1000 ? (up.speed.mbps / 1000) + 'G' : up.speed.mbps + 'M') + (up.speed.duplex === 'full' ? ' FD' : up.speed.duplex === 'half' ? ' HD' : '') : null ].filter(Boolean).join(' · ') : _('no uplink'));
txt(h.apKicker, m.wired ? _('This device') : _('This access point'));
txt(h.apTitle, d.model);
txt(h.apAddr, up && up.ipv4[0] ? up.ipv4[0].split('/')[0] : fmt.DASH);
txt(h.apUp, d.uptime != null ? _('up %s').format(fmt.duration(d.uptime)) : '');
h.upActive = !!(upR && upR.rx + upR.tx > 50e3);
h.upRate = upR ? upR.rx + upR.tx : 0;
if (m.wired) {
var nets = Object.create(null);
m.networks.forEach(function(n) { if (n.up && !nets[n.name]) nets[n.name] = n; });
h.radios.forEach(function(b) {
var n = nets[b.id];
if (!n) return;
var r = n.dev ? st.devRates[n.dev] : null;
setTitle(b.el, [ n.name, n.dev, n.ipv4 ].filter(Boolean).join(' · '));
txt(b.kicker, n.proto || _('Interface'));
txt(b.title, n.name);
txt(b.addr, n.ipv4 ? n.ipv4.split('/')[0] : (n.dev || fmt.DASH));
txt(b.sub, r ? '↓ ' + fmt.bitsShort(r.tx) + ' ↑ ' + fmt.bitsShort(r.rx) : fmt.DASH);
b.active = !!(r && r.rx + r.tx > 50e3);
b.rate = r ? r.rx + r.tx : 0;
});
return;
}
var radios = Object.create(null), ssids = Object.create(null);
m.radios.forEach(function(r) { radios[r.id] = r; });
m.ssids.forEach(function(s) { ssids[s.id] = s; });
h.radios.forEach(function(b) {
var r = radios[b.id], rr = st.radioRates[b.id];
if (!r) return;
setClass(b.el, 'vt-node vt-node-radio' + (r.up ? '' : ' vt-node-off'));
setClass(b.badge, 'vt-band vt-band-' + (r.band || 'x'));
txt(b.badge, wifi.bandLabel(r.band));
txt(b.gen, r.gen || '');
txt(b.title, r.channel ? _('Ch %d · %d MHz').format(r.channel, r.width || 20) : _('Channel —'));
txt(b.sub, r.disabled ? _('Disabled') : !r.up ? _('Down') : rr ? '↓ ' + fmt.bits(rr.tx) : fmt.DASH);
b.active = !!(rr && rr.rx + rr.tx > 50e3);
b.rate = rr ? rr.rx + rr.tx : 0;
b.ssids.forEach(function(sn) {
var s = ssids[sn.id];
if (!s) return;
sn.data = s;
var tone = s.security.tone === 'open' ? 'unlock' : 'lock';
if (sn.tone !== tone) {
var ico = icon(tone, 'vt-ico-sm');
if (sn.ico) sn.title.replaceChild(ico, sn.ico); else sn.title.insertBefore(ico, sn.name);
sn.ico = ico;
sn.tone = tone;
}
setClass(sn.el, 'vt-node vt-node-ssid' + (s.clients ? '' : ' vt-node-idle'));
setTitle(sn.el, _('Show the clients of %s').format(s.ssid));
txt(sn.name, s.ssid || _('(hidden)'));
txt(sn.count, s.clients === 1 ? _('1 client') : _('%d clients').format(s.clients));
var dots = m.clients.filter(function(c) { return c.ssidId === s.id; }).slice(0, 8);
var sig = dots.map(function(c) { return c.level.level + ' ' + c.name + ' · ' + fmt.dbm(c.signal); }).join('\n');
if (sn.sig !== sig) {
sn.sig = sig;
dom.content(sn.dots, dots.map(function(c) { return E('span', { 'class': 'vt-dot vt-lvl-' + c.level.level, 'title': c.name + ' · ' + fmt.dbm(c.signal) }); }));
}
var sr = st.ssidRates[s.id];
sn.active = !!(sr && sr.rx + sr.tx > 50e3);
});
}); });
if (!nets.length) branches.appendChild(E('div', { 'class': 'vt-empty-inline' }, [ _('No other interfaces are up') ]));
}, },
/* four numbers under the path: who is here, what flows, where, and /* four numbers under the path: who is here, what flows, where, and
who is worst off */ who is worst off. The cells stay; only a change of shape (tone, or
whether a cell links somewhere) rebuilds them. */
paintKpis: function() { paintKpis: function() {
var self = this, specs = this.m.wired ? this.netKpiSpecs() : this.kpiSpecs();
var shape = specs.map(function(k) { return k.tone + (k.act ? '+' : '-'); }).join(' ');
this.kpiActs = specs.map(function(k) { return k.act; });
if (this.kpiShape !== shape) {
this.kpiShape = shape;
this.kpiCells = specs.map(function(k, i) {
var c = { label: E('span', { 'class': 'vt-kpi-label' }), value: E('span', { 'class': 'vt-kpi-value vt-tn' }), sub: E('span', { 'class': 'vt-kpi-sub' }) };
var body = [ c.label, c.value, c.sub ];
c.el = k.act
? E('button', { 'type': 'button', 'class': 'vt-kpi vt-kpi-' + k.tone, 'click': function() { var f = self.kpiActs[i]; if (f) f(); } }, body)
: E('div', { 'class': 'vt-kpi vt-kpi-' + k.tone }, body);
return c;
});
dom.content(this.el.kpis, this.kpiCells.map(function(c) { return c.el; }));
}
this.kpiCells.forEach(function(c, i) {
txt(c.label, specs[i].label);
txt(c.value, specs[i].value);
txt(c.sub, specs[i].sub);
});
},
kpiSpecs: function() {
var self = this, m = this.m, st = this.st, up = st.uplinkRate; var self = this, m = this.m, st = this.st, up = st.uplinkRate;
if (m.wired) return this.paintNetKpis();
var byBand = {}; var byBand = {};
m.clients.forEach(function(c) { byBand[c.band] = (byBand[c.band] || 0) + 1; }); m.clients.forEach(function(c) { byBand[c.band] = (byBand[c.band] || 0) + 1; });
var bandText = Object.keys(byBand).sort().map(function(b) { return _('%d on %s').format(byBand[b], wifi.bandLabel(b)); }).join(' · '); var bandText = Object.keys(byBand).sort().map(function(b) { return _('%d on %s').format(byBand[b], wifi.bandLabel(b)); }).join(' · ');
var busiest = null; var busiest = null;
m.radios.forEach(function(r) { var i = st.radioInsights[r.id]; if (i && i.busiest) busiest = { r: r, share: i.share }; }); m.radios.forEach(function(r) { var i = st.radioInsights[r.id]; if (i && i.busiest) busiest = { r: r, share: i.share }; });
var weakest = m.clients.filter(function(c) { return c.signal != null; }).sort(function(a, b) { return a.signal - b.signal; })[0]; var weakest = m.clients.filter(function(c) { return c.signal != null; }).sort(function(a, b) { return a.signal - b.signal; })[0];
return [
dom.content(this.el.kpis, [ { label: _('Clients'), value: String(m.clients.length), sub: bandText || _('none connected'), tone: 'plain',
kpi(_('Clients'), String(m.clients.length), bandText || _('none connected'), 'plain', function() { self.el.clients.scrollIntoView({ behavior: reducedMotion() ? 'auto' : 'smooth', block: 'start' }); }), act: function() { self.el.clients.scrollIntoView({ behavior: reducedMotion() ? 'auto' : 'smooth', block: 'start' }); } },
kpi(_('Traffic now'), up ? '↓ ' + fmt.bits(up.rx) : fmt.DASH, up ? _('↑ %s · via %s').format(fmt.bits(up.tx), m.uplink ? m.uplink.dev : '') : _('measuring'), 'plain', function() { self.openDrawer('uplink', 'uplink'); }), { label: _('Traffic now'), value: up ? '↓ ' + fmt.bits(up.rx) : fmt.DASH, sub: up ? _('↑ %s · via %s').format(fmt.bits(up.tx), m.uplink ? m.uplink.dev : '') : _('measuring'), tone: 'plain',
busiest ? kpi(_('Busiest radio'), busiest.r.bandLabel, _('%s of Wi-Fi traffic').format(fmt.pct(busiest.share * 100)), 'plain', function() { self.openDrawer('radio', busiest.r.id); }) act: function() { self.openDrawer('uplink', 'uplink'); } },
: kpi(_('Busiest radio'), fmt.DASH, _('no Wi-Fi traffic yet'), 'plain'), busiest ? { label: _('Busiest radio'), value: busiest.r.bandLabel, sub: _('%s of Wi-Fi traffic').format(fmt.pct(busiest.share * 100)), tone: 'plain',
weakest ? kpi(_('Weakest signal'), fmt.dbm(weakest.signal), weakest.name, weakest.signal < insight.WEAK_DBM ? 'warn' : 'plain', function() { self.openDrawer('client', weakest.mac); }) act: function() { self.openDrawer('radio', busiest.r.id); } }
: kpi(_('Weakest signal'), fmt.DASH, _('no clients'), 'plain') : { label: _('Busiest radio'), value: fmt.DASH, sub: _('no Wi-Fi traffic yet'), tone: 'plain', act: null },
]); weakest ? { label: _('Weakest signal'), value: fmt.dbm(weakest.signal), sub: weakest.name, tone: weakest.signal < insight.WEAK_DBM ? 'warn' : 'plain',
act: function() { self.openDrawer('client', weakest.mac); } }
: { label: _('Weakest signal'), value: fmt.DASH, sub: _('no clients'), tone: 'plain', act: null }
];
}, },
/* wired-only: what flows, how long the uplink has held, which /* wired-only: what flows, how long the uplink has held, which
networks are up and which is busiest */ networks are up and which is busiest */
paintNetKpis: function() { netKpiSpecs: function() {
var self = this, m = this.m, st = this.st, up = st.uplinkRate, link = m.uplink; var self = this, m = this.m, st = this.st, up = st.uplinkRate, link = m.uplink;
var nets = m.networks, upNets = nets.filter(function(n) { return n.up; }); var nets = m.networks, upNets = nets.filter(function(n) { return n.up; });
var busiest = null; var busiest = null;
@@ -1316,13 +1488,14 @@ return view.extend({
if (r && r.rx + r.tx > 0 && (!busiest || r.rx + r.tx > busiest.r.rx + busiest.r.tx)) busiest = { n: n, r: r }; if (r && r.rx + r.tx > 0 && (!busiest || r.rx + r.tx > busiest.r.rx + busiest.r.tx)) busiest = { n: n, r: r };
}); });
function toNetwork() { window.location.href = L.url.apply(L, insight.PAGES.network); } function toNetwork() { window.location.href = L.url.apply(L, insight.PAGES.network); }
dom.content(this.el.kpis, [ function toUplink() { self.openDrawer('uplink', 'uplink'); }
kpi(_('Traffic now'), up ? '↓ ' + fmt.bits(up.rx) : fmt.DASH, up ? _('↑ %s · via %s').format(fmt.bits(up.tx), link ? link.dev : '') : _('measuring'), 'plain', function() { self.openDrawer('uplink', 'uplink'); }), return [
kpi(_('Connected for'), link && link.uptime != null ? fmt.duration(link.uptime) : fmt.DASH, link ? [ link.iface, link.proto ].filter(Boolean).join(' · ') : _('no uplink'), 'plain', function() { self.openDrawer('uplink', 'uplink'); }), { label: _('Traffic now'), value: up ? '↓ ' + fmt.bits(up.rx) : fmt.DASH, sub: up ? _('↑ %s · via %s').format(fmt.bits(up.tx), link ? link.dev : '') : _('measuring'), tone: 'plain', act: toUplink },
kpi(_('Networks'), String(upNets.length), nets.length > upNets.length ? _('%d down').format(nets.length - upNets.length) : _('all up'), 'plain', toNetwork), { label: _('Connected for'), value: link && link.uptime != null ? fmt.duration(link.uptime) : fmt.DASH, sub: link ? [ link.iface, link.proto ].filter(Boolean).join(' · ') : _('no uplink'), tone: 'plain', act: toUplink },
busiest ? kpi(_('Busiest network'), busiest.n.name, '↓ ' + fmt.bits(busiest.r.tx) + ' ↑ ' + fmt.bits(busiest.r.rx), 'plain', toNetwork) { label: _('Networks'), value: String(upNets.length), sub: nets.length > upNets.length ? _('%d down').format(nets.length - upNets.length) : _('all up'), tone: 'plain', act: toNetwork },
: kpi(_('Busiest network'), fmt.DASH, _('no traffic yet'), 'plain') busiest ? { label: _('Busiest network'), value: busiest.n.name, sub: '↓ ' + fmt.bits(busiest.r.tx) + ' ↑ ' + fmt.bits(busiest.r.rx), tone: 'plain', act: toNetwork }
]); : { label: _('Busiest network'), value: fmt.DASH, sub: _('no traffic yet'), tone: 'plain', act: toNetwork }
];
}, },
scheduleWires: function() { scheduleWires: function() {
@@ -1332,63 +1505,141 @@ return view.extend({
window.requestAnimationFrame(function() { self.wirePending = false; self.drawWires(); }); window.requestAnimationFrame(function() { self.wirePending = false; self.drawWires(); });
}, },
/* connectors between the hero nodes, measured after layout; wires are /* the wires the nodes call for: key, ends, and how busy */
kept by key so the flow animation does not restart every poll */ wireList: function() {
drawWires: function() { var h = this.hero, out = [];
var path = this.el && this.el.path, svg = this.el && this.el.wires, n = this.nodes; if (!h) return out;
if (!path || !svg || !n || !path.isConnected) return; out.push({ key: 'gw', from: h.gw, to: h.ap, active: h.upActive, speed: wires.speed(h.upRate), main: true });
var box = path.getBoundingClientRect(); h.radios.forEach(function(b) {
if (!box.width) return; out.push({ key: 'r:' + b.id, from: h.ap, to: b.el, active: b.active, speed: wires.speed(b.rate) });
svg.setAttribute('viewBox', '0 0 ' + Math.round(box.width) + ' ' + Math.round(box.height)); b.ssids.forEach(function(sn) { out.push({ key: 's:' + b.id + ':' + sn.id, from: b.el, to: sn.el, active: sn.active, speed: 1 }); });
svg.setAttribute('width', String(Math.round(box.width)));
svg.setAttribute('height', String(Math.round(box.height)));
function rect(el) {
var r = el.getBoundingClientRect();
return { l: r.left - box.left, r: r.right - box.left, t: r.top - box.top, b: r.bottom - box.top, cy: (r.top + r.bottom) / 2 - box.top };
}
function curve(a, b) {
var x1, y1, x2, y2;
if (b.l >= a.r - 2) {
x1 = a.r; y1 = a.cy; x2 = b.l; y2 = b.cy;
var mx = (x1 + x2) / 2;
return 'M' + x1.toFixed(1) + ' ' + y1.toFixed(1) + 'C' + mx.toFixed(1) + ' ' + y1.toFixed(1) + ' ' + mx.toFixed(1) + ' ' + y2.toFixed(1) + ' ' + x2.toFixed(1) + ' ' + y2.toFixed(1);
}
/* stacked layout: an elbow down the left edge */
x1 = a.l + 18; y1 = a.b; x2 = b.l; y2 = b.cy;
if (x2 <= x1 + 6) return 'M' + x1.toFixed(1) + ' ' + y1.toFixed(1) + 'V' + b.t.toFixed(1);
var rad = Math.min(8, Math.max(0, y2 - y1));
return 'M' + x1.toFixed(1) + ' ' + y1.toFixed(1) + 'V' + (y2 - rad).toFixed(1) + 'Q' + x1.toFixed(1) + ' ' + y2.toFixed(1) + ' ' + (x1 + rad).toFixed(1) + ' ' + y2.toFixed(1) + 'H' + x2.toFixed(1);
}
function speed(bps) { return bps > 100e6 ? 3 : bps > 5e6 ? 2 : 1; }
var want = [];
want.push({ key: 'gw', d: curve(rect(n.gw), rect(n.ap)), active: n.upActive, speed: speed(n.upRate), main: true });
n.radios.forEach(function(r, i) {
var rr = rect(r.el);
want.push({ key: 'r' + i, d: curve(rect(n.ap), rr), active: r.active, speed: speed(r.rate) });
r.ssids.forEach(function(s, j) { want.push({ key: 'r' + i + 's' + j, d: curve(rr, rect(s.el)), active: s.active, speed: 1 }); });
}); });
return out;
},
var seen = {}, map = this.wireMap; /* connectors between the hero nodes, measured after layout (called
want.forEach(function(w) { from the ResizeObserver); wires are kept by key so the flow does not
restart when nothing moved */
drawWires: function() {
var self = this, path = this.el && this.el.path, svg = this.el && this.el.wires;
if (!path || !svg || !this.hero || !path.isConnected) return;
var box = path.getBoundingClientRect();
/* folded or hidden: no dots running where nobody sees them, and
none rebuilt by a poll until the path has a size again */
if (!box.width) { this.stopFlows(); return; }
this.flowsHidden = false;
var vb = '0 0 ' + Math.round(box.width) + ' ' + Math.round(box.height);
if (svg.getAttribute('viewBox') !== vb) {
svg.setAttribute('viewBox', vb);
svg.setAttribute('width', String(Math.round(box.width)));
svg.setAttribute('height', String(Math.round(box.height)));
}
var rects = new Map();
function rect(el) {
var r = rects.get(el);
if (!r) {
var b = el.getBoundingClientRect();
r = { l: b.left - box.left, r: b.right - box.left, t: b.top - box.top, b: b.bottom - box.top, cy: (b.top + b.bottom) / 2 - box.top };
rects.set(el, r);
}
return r;
}
var list = this.wireList(), seen = Object.create(null), map = this.wireMap;
list.forEach(function(w) { rect(w.from); rect(w.to); });
list.forEach(function(w) {
seen[w.key] = true; seen[w.key] = true;
var g = map[w.key]; var g = map[w.key];
if (!g) { if (!g) {
g = map[w.key] = { base: svgPath('', 'vt-wire'), flow: svgPath('', 'vt-flow') }; g = map[w.key] = { base: svgPath('', 'vt-wire'), flow: svgPath('', 'vt-flow'), fw: E('div', { 'class': 'vt-fwire' }), dots: [], anims: [], d: null };
svg.appendChild(g.base); svg.appendChild(g.base);
svg.appendChild(g.flow); svg.appendChild(g.flow);
self.el.flows.appendChild(g.fw);
}
var c = wires.curve(rect(w.from), rect(w.to));
if (g.d !== c.d) {
g.base.setAttribute('d', c.d);
g.flow.setAttribute('d', c.d);
g.d = c.d;
var rs = wires.resample(c.pts, 8);
g.samples = rs.pts;
g.len = rs.len;
} }
if (g.d !== w.d) { g.base.setAttribute('d', w.d); g.flow.setAttribute('d', w.d); g.d = w.d; }
g.base.setAttribute('class', 'vt-wire' + (w.main ? ' vt-wire-main' : '') + (w.active ? ' vt-wire-on' : ''));
g.flow.setAttribute('class', 'vt-flow vt-flow-' + w.speed + (w.active ? ' vt-flow-on' : ''));
}); });
Object.keys(map).forEach(function(k) { Object.keys(map).forEach(function(k) {
if (seen[k]) return; if (seen[k]) return;
svg.removeChild(map[k].base); var g = map[k];
svg.removeChild(map[k].flow); self.clearDots(g);
svg.removeChild(g.base);
svg.removeChild(g.flow);
self.el.flows.removeChild(g.fw);
delete map[k]; delete map[k];
}); });
this.paintWireState();
},
/* which wires carry traffic and how fast: classes only, no geometry */
paintWireState: function() {
var self = this, map = this.wireMap, still = reducedMotion() || !!this.flowsHidden;
if (!map) return;
this.wireList().forEach(function(w) {
var g = map[w.key];
if (!g) return;
setClass(g.base, 'vt-wire' + (w.main ? ' vt-wire-main' : '') + (w.active ? ' vt-wire-on' : ''));
setClass(g.flow, 'vt-flow vt-flow-' + w.speed + (w.active ? ' vt-flow-on' : ''));
self.syncFlow(g, !!(w.active && !still && g.d), w.speed);
});
},
/* The flow: about one dot every 16 px moving along the wire, each
animated with transform only (the compositor runs it; the old
stroke-dashoffset animation repainted the wire layer every frame).
Dots exist only while a wire is busy; reduced motion shows the static
dotted path (app.css) instead. */
syncFlow: function(g, on, speed) {
if (on) {
if (g.offTimer) { window.clearTimeout(g.offTimer); g.offTimer = null; }
if (g.dotsFor !== g.d) this.buildDots(g, speed);
else if (g.speed !== speed) g.anims.forEach(function(a) { a.playbackRate = wires.rate(speed); });
g.speed = speed;
}
else if (g.dots.length && !g.offTimer) {
/* fade out (app.css), then stop */
var self = this;
g.offTimer = window.setTimeout(function() { g.offTimer = null; self.clearDots(g); }, 450);
}
setClass(g.fw, 'vt-fwire' + (g.speed === 3 ? ' vt-fwire-3' : '') + (on ? ' vt-fwire-on' : ''));
},
buildDots: function(g, speed) {
this.clearDots(g);
g.dotsFor = g.d;
if (!g.samples || g.samples.length < 2 || !(g.len >= 8) || typeof g.fw.animate !== 'function') return;
var n = wires.dots(g.len), dur = wires.trip(g.len), rate = wires.rate(speed);
for (var i = 0; i < n; i++) {
var dot = E('span', { 'class': 'vt-fdot' });
g.fw.appendChild(dot);
/* every dot takes the whole trip, started i/n of the way along */
var a = dot.animate(wires.keyframes(g.samples), { duration: dur, delay: -dur * i / n, iterations: Infinity, easing: 'linear' });
a.playbackRate = rate;
g.dots.push(dot);
g.anims.push(a);
}
},
clearDots: function(g) {
if (g.offTimer) { window.clearTimeout(g.offTimer); g.offTimer = null; }
g.anims.forEach(function(a) { a.cancel(); });
g.dots.forEach(function(d) { if (d.parentNode) d.parentNode.removeChild(d); });
g.anims = [];
g.dots = [];
g.dotsFor = null;
},
stopFlows: function() {
var self = this, map = this.wireMap || {};
this.flowsHidden = true;
Object.keys(map).forEach(function(k) { self.clearDots(map[k]); setClass(map[k].fw, 'vt-fwire'); });
}, },
/* ----------------------------------------------------------- system */ /* ----------------------------------------------------------- system */
@@ -1434,8 +1685,8 @@ return view.extend({
txt(el.cpuVal, cpu ? Math.round(cpu.pct) + '%' : fmt.DASH); txt(el.cpuVal, cpu ? Math.round(cpu.pct) + '%' : fmt.DASH);
el.cpuVal.className = 'vt-big vt-tn' + (cpu && cpu.pct >= 90 ? ' vt-t-err' : cpu && cpu.pct >= 70 ? ' vt-t-warn' : ''); el.cpuVal.className = 'vt-big vt-tn' + (cpu && cpu.pct >= 90 ? ' vt-t-err' : cpu && cpu.pct >= 70 ? ' vt-t-warn' : '');
txt(el.cpuLoad, _('Load avg %s').format(d.load.map(fmt.load).join(' · '))); txt(el.cpuLoad, _('Load avg %s').format(d.load.map(fmt.load).join(' · ')));
dom.content(el.cpuSpark, [ sparkline(this.st.hist.cpu, [ 1 ], 100, 'vt-spark-cpu', { scale: function() { return '100%'; } }) ]); patch(el.cpuSpark, [ sparkline(this.st.hist.cpu, [ 1 ], 100, 'vt-spark-cpu', { scale: function() { return '100%'; } }) ]);
dom.content(el.cpuCores, (cpu ? cpu.cores : []).map(function(p, i) { patch(el.cpuCores, (cpu ? cpu.cores : []).map(function(p, i) {
var fill = E('span', { 'class': 'vt-core-fill' }); var fill = E('span', { 'class': 'vt-core-fill' });
fill.style.height = Math.max(4, Math.min(100, p || 0)).toFixed(1) + '%'; fill.style.height = Math.max(4, Math.min(100, p || 0)).toFixed(1) + '%';
return E('span', { 'class': 'vt-core', 'title': _('Core %d: %s').format(i, fmt.pct(p)) }, [ fill ]); return E('span', { 'class': 'vt-core', 'title': _('Core %d: %s').format(i, fmt.pct(p)) }, [ fill ]);
@@ -1444,31 +1695,36 @@ return view.extend({
/* memory: one bar, three named segments */ /* memory: one bar, three named segments */
var mem = d.memory; var mem = d.memory;
if (mem) { if (mem) {
dom.content(el.memHead, [ patch(el.memHead, [
E('span', { 'class': 'vt-mem-avail vt-tn' }, [ _('%s available').format(fmt.pct(mem.availPct)) ]), E('span', { 'class': 'vt-mem-avail vt-tn' }, [ _('%s available').format(fmt.pct(mem.availPct)) ]),
E('span', { 'class': 'vt-mem-of vt-tn' }, [ _('%s of %s').format(fmt.bytes(mem.available), fmt.bytes(mem.total)) ]) E('span', { 'class': 'vt-mem-of vt-tn' }, [ _('%s of %s').format(fmt.bytes(mem.available), fmt.bytes(mem.total)) ])
]); ]);
if (!el.memSegs) { if (!el.memSegs) {
el.memSegs = mem.segments.map(function(s) { var x = E('span', { 'class': 'vt-seg-part vt-seg-' + s.key }); el.memBar.appendChild(x); return x; }); el.memSegs = mem.segments.map(function(s) { var x = E('span', { 'class': 'vt-seg-part vt-seg-' + s.key }); el.memBar.appendChild(x); return x; });
} }
mem.segments.forEach(function(s, i) { setW(el.memSegs[i], s.pct); el.memSegs[i].setAttribute('title', s.label + ': ' + fmt.bytes(s.bytes)); }); /* animate a visible change only (app.css): a width transition
lays out every frame for most of a second */
var was = el.memPct || [];
el.memBar.classList.toggle('vt-seg-move', mem.segments.some(function(s, i) { return was[i] != null && Math.abs(s.pct - was[i]) >= 2; }));
el.memPct = mem.segments.map(function(s) { return s.pct; });
mem.segments.forEach(function(s, i) { setW(el.memSegs[i], s.pct); setTitle(el.memSegs[i], s.label + ': ' + fmt.bytes(s.bytes)); });
el.memBar.setAttribute('aria-label', mem.segments.map(function(s) { return s.label + ' ' + fmt.bytes(s.bytes); }).join(', ')); el.memBar.setAttribute('aria-label', mem.segments.map(function(s) { return s.label + ' ' + fmt.bytes(s.bytes); }).join(', '));
dom.content(el.memLegend, mem.segments.map(function(s) { patch(el.memLegend, mem.segments.map(function(s) {
return E('span', { 'class': 'vt-legend-item' }, [ E('span', { 'class': 'vt-key vt-seg-' + s.key }), E('span', {}, [ s.label ]), E('b', { 'class': 'vt-tn' }, [ fmt.bytes(s.bytes) ]) ]); return E('span', { 'class': 'vt-legend-item' }, [ E('span', { 'class': 'vt-key vt-seg-' + s.key }), E('span', {}, [ s.label ]), E('b', { 'class': 'vt-tn' }, [ fmt.bytes(s.bytes) ]) ]);
})); }));
} }
else { else {
dom.content(el.memHead, [ E('span', { 'class': 'vt-muted' }, [ _('Not reported') ]) ]); patch(el.memHead, [ E('span', { 'class': 'vt-muted' }, [ _('Not reported') ]) ]);
} }
if (d.swap) { if (d.swap) {
var sb = E('span', { 'class': 'vt-thin-fill' }); var sb = E('span', { 'class': 'vt-thin-fill' });
setW(sb, d.swap.usedPct); setW(sb, d.swap.usedPct);
dom.content(el.swap, [ E('span', { 'class': 'vt-swap-label' }, [ _('Swap') ]), E('span', { 'class': 'vt-thin' }, [ sb ]), patch(el.swap, [ E('span', { 'class': 'vt-swap-label' }, [ _('Swap') ]), E('span', { 'class': 'vt-thin' }, [ sb ]),
E('span', { 'class': 'vt-tn vt-hint' }, [ _('%s of %s').format(fmt.bytes(d.swap.used), fmt.bytes(d.swap.total)) ]) ]); E('span', { 'class': 'vt-tn vt-hint' }, [ _('%s of %s').format(fmt.bytes(d.swap.used), fmt.bytes(d.swap.total)) ]) ]);
} }
else dom.content(el.swap, []); else patch(el.swap, []);
dom.content(el.storage, d.storage.map(function(s) { patch(el.storage, d.storage.map(function(s) {
var f = E('span', { 'class': 'vt-bar-fill' + (s.usedPct >= 95 ? ' vt-bg-err' : s.usedPct >= 85 ? ' vt-bg-warn' : '') }); var f = E('span', { 'class': 'vt-bar-fill' + (s.usedPct >= 95 ? ' vt-bg-err' : s.usedPct >= 85 ? ' vt-bg-warn' : '') });
setW(f, Math.max(s.usedPct, 0.8)); setW(f, Math.max(s.usedPct, 0.8));
return E('div', { 'class': 'vt-mount' }, [ return E('div', { 'class': 'vt-mount' }, [
@@ -1476,7 +1732,7 @@ return view.extend({
E('div', { 'class': 'vt-bar' }, [ f ]) E('div', { 'class': 'vt-bar' }, [ f ])
]); ]);
})); }));
dom.content(el.fw, [ patch(el.fw, [
E('span', {}, [ d.firmware || '' ]), E('span', {}, [ d.firmware || '' ]),
d.kernel ? E('span', { 'class': 'vt-hint' }, [ _('Kernel %s').format(d.kernel) ]) : '' d.kernel ? E('span', { 'class': 'vt-hint' }, [ _('Kernel %s').format(d.kernel) ]) : ''
]); ]);
@@ -1729,7 +1985,7 @@ return view.extend({
dom.content(this.el.radioList, [ E('div', { 'class': 'vt-tile vt-empty' }, [ icon('radio'), E('p', {}, [ _('No wireless radios are reported by this device.') ]) ]) ]); dom.content(this.el.radioList, [ E('div', { 'class': 'vt-tile vt-empty' }, [ icon('radio'), E('p', {}, [ _('No wireless radios are reported by this device.') ]) ]) ]);
return; return;
} }
dom.content(this.el.radioList, m.radios.map(function(r) { patch(this.el.radioList, m.radios.map(function(r) {
var rr = st.radioRates[r.id], ins = st.radioInsights[r.id] || { notes: [] }; var rr = st.radioRates[r.id], ins = st.radioInsights[r.id] || { notes: [] };
var sp = r.spectrum, spec = E('div', { 'class': 'vt-spectrum', 'title': sp ? _('%d–%d MHz of the %d–%d MHz band').format(sp.lo, sp.hi, sp.bandLo, sp.bandHi) : '' }); var sp = r.spectrum, spec = E('div', { 'class': 'vt-spectrum', 'title': sp ? _('%d–%d MHz of the %d–%d MHz band').format(sp.lo, sp.hi, sp.bandLo, sp.bandHi) : '' });
if (sp) { if (sp) {
@@ -1742,7 +1998,7 @@ return view.extend({
} }
var hist = st.hist.net[r.id] || []; var hist = st.hist.net[r.id] || [];
var clients = m.clients.filter(function(c) { return c.radio === r.id; }); var clients = m.clients.filter(function(c) { return c.radio === r.id; });
return E('article', { 'class': 'vt-tile vt-radio vt-radio-' + (r.band || 'x') + (r.up ? '' : ' vt-radio-off') + (ins.busiest ? ' vt-radio-busiest' : '') }, [ return E('article', { 'class': 'vt-tile vt-radio vt-radio-' + (r.band || 'x') + (r.up ? '' : ' vt-radio-off') + (ins.busiest ? ' vt-radio-busiest' : ''), 'data-radio': r.id }, [
E('header', { 'class': 'vt-radio-head' }, [ E('header', { 'class': 'vt-radio-head' }, [
bandBadge(r.band), bandBadge(r.band),
E('span', { 'class': 'vt-radio-name' }, [ r.gen || r.id ]), E('span', { 'class': 'vt-radio-name' }, [ r.gen || r.id ]),
@@ -1851,17 +2107,21 @@ return view.extend({
chipDefs.push([ 'weak', _('Weak signal'), m.clients.filter(function(c) { return c.signal != null && c.signal < insight.WEAK_DBM; }).length ]); chipDefs.push([ 'weak', _('Weak signal'), m.clients.filter(function(c) { return c.signal != null && c.signal < insight.WEAK_DBM; }).length ]);
chipDefs.push([ 'new', _('New'), m.clients.filter(function(c) { return c.isNew; }).length ]); chipDefs.push([ 'new', _('New'), m.clients.filter(function(c) { return c.isNew; }).length ]);
if (!chipDefs.some(function(d) { return d[0] === ui.chip; })) ui.chip = 'all'; if (!chipDefs.some(function(d) { return d[0] === ui.chip; })) ui.chip = 'all';
dom.content(el.chips, chipDefs.map(function(d) { /* chips and headings change with the counts or the sort, not with every poll */
var chipSig = JSON.stringify([ ui.chip, chipDefs ]);
if (this.chipSig !== chipSig) dom.content(el.chips, chipDefs.map(function(d) {
return E('button', { 'type': 'button', 'class': 'vt-fchip' + (ui.chip === d[0] ? ' vt-fchip-on' : '') + (d[0] === 'weak' && d[2] ? ' vt-fchip-warn' : ''), return E('button', { 'type': 'button', 'class': 'vt-fchip' + (ui.chip === d[0] ? ' vt-fchip-on' : '') + (d[0] === 'weak' && d[2] ? ' vt-fchip-warn' : ''),
'aria-pressed': String(ui.chip === d[0]), 'click': function() { ui.chip = d[0]; self.paintClients(); } }, [ 'aria-pressed': String(ui.chip === d[0]), 'click': function() { ui.chip = d[0]; self.paintClients(); } }, [
E('span', {}, [ d[1] ]), E('span', { 'class': 'vt-fchip-n vt-tn' }, [ String(d[2]) ]) E('span', {}, [ d[1] ]), E('span', { 'class': 'vt-fchip-n vt-tn' }, [ String(d[2]) ])
]); ]);
})); }));
this.chipSig = chipSig;
el.filterClear.hidden = !ui.filter; el.filterClear.hidden = !ui.filter;
/* header with sort buttons */ /* header with sort buttons */
var cols = [ [ 'name', _('Device') ], [ 'signal', _('Signal') ], [ 'ssid', _('Network') ], [ 'rate', _('Link rate') ], [ 'live', _('Live') ], [ 'exp', _('Experience') ], [ 'time', _('Online') ] ]; var cols = [ [ 'name', _('Device') ], [ 'signal', _('Signal') ], [ 'ssid', _('Network') ], [ 'rate', _('Link rate') ], [ 'live', _('Live') ], [ 'exp', _('Experience') ], [ 'time', _('Online') ] ];
dom.content(el.thead, [ E('tr', {}, cols.map(function(c) { var headSig = ui.sort + ' ' + ui.dir;
if (this.headSig !== headSig) dom.content(el.thead, [ E('tr', {}, cols.map(function(c) {
var on = ui.sort === c[0]; var on = ui.sort === c[0];
return E('th', { 'class': 'vt-th-' + c[0], 'scope': 'col', 'aria-sort': on ? (ui.dir > 0 ? 'ascending' : 'descending') : 'none' }, [ return E('th', { 'class': 'vt-th-' + c[0], 'scope': 'col', 'aria-sort': on ? (ui.dir > 0 ? 'ascending' : 'descending') : 'none' }, [
E('button', { 'type': 'button', 'class': 'vt-sort' + (on ? ' vt-sort-on' : ''), 'click': function() { E('button', { 'type': 'button', 'class': 'vt-sort' + (on ? ' vt-sort-on' : ''), 'click': function() {
@@ -1870,6 +2130,7 @@ return view.extend({
} }, [ E('span', {}, [ c[1] ]), E('span', { 'class': 'vt-sort-ind', 'aria-hidden': 'true' }, [ on ? (ui.dir > 0 ? '↑' : '↓') : '' ]) ]) } }, [ E('span', {}, [ c[1] ]), E('span', { 'class': 'vt-sort-ind', 'aria-hidden': 'true' }, [ on ? (ui.dir > 0 ? '↑' : '↓') : '' ]) ])
]); ]);
})) ]); })) ]);
this.headSig = headSig;
var list = m.clients.filter(L.bind(this.matches, this)); var list = m.clients.filter(L.bind(this.matches, this));
list.sort(function(a, b) { list.sort(function(a, b) {
@@ -1886,7 +2147,8 @@ return view.extend({
var maxRate = 0; var maxRate = 0;
m.clients.forEach(function(c) { var r = st.staRates[c.mac]; if (r && r.rx + r.tx > maxRate) maxRate = r.rx + r.tx; }); m.clients.forEach(function(c) { var r = st.staRates[c.mac]; if (r && r.rx + r.tx > maxRate) maxRate = r.rx + r.tx; });
var keep = new Set(); var keep = new Set(), at = 0;
this.rowSig = this.rowSig || new Map();
list.concat(gone).forEach(function(c) { list.concat(gone).forEach(function(c) {
var key = (c.gone ? 'gone:' : '') + c.mac; var key = (c.gone ? 'gone:' : '') + c.mac;
keep.add(key); keep.add(key);
@@ -1901,13 +2163,18 @@ return view.extend({
tr.classList.toggle('vt-row-gone', !!c.gone); tr.classList.toggle('vt-row-gone', !!c.gone);
tr.classList.toggle('vt-row-sel', !!(ui.drawer && ui.drawer.kind === 'client' && ui.drawer.id === c.mac)); tr.classList.toggle('vt-row-sel', !!(ui.drawer && ui.drawer.kind === 'client' && ui.drawer.id === c.mac));
tr.setAttribute('aria-label', _('Open details of %s').format(c.name)); tr.setAttribute('aria-label', _('Open details of %s').format(c.name));
dom.content(tr, self.clientCells(c, maxRate)); /* a row that would come out the same keeps its cells, and rows
el.tbody.appendChild(tr); already in order stay where they are */
var cells = self.clientCells(c, maxRate), sig = treeSig(cells);
if (self.rowSig.get(key) !== sig) { dom.content(tr, cells); self.rowSig.set(key, sig); }
if (el.tbody.children[at] !== tr) el.tbody.insertBefore(tr, el.tbody.children[at] || null);
at++;
}); });
this.rowMap.forEach(function(tr, key) { this.rowMap.forEach(function(tr, key) {
if (keep.has(key)) return; if (keep.has(key)) return;
if (tr.parentNode) tr.parentNode.removeChild(tr); if (tr.parentNode) tr.parentNode.removeChild(tr);
self.rowMap.delete(key); self.rowMap.delete(key);
self.rowSig.delete(key);
}); });
window.setTimeout(function() { self.rowMap.forEach(function(tr) { tr.classList.remove('vt-row-enter'); }); }, 600); window.setTimeout(function() { self.rowMap.forEach(function(tr) { tr.classList.remove('vt-row-enter'); }); }, 600);
@@ -2027,7 +2294,7 @@ return view.extend({
dom.content(this.el.ssids, [ E('p', { 'class': 'vt-empty-inline' }, [ _('No wireless networks configured.') ]) ]); dom.content(this.el.ssids, [ E('p', { 'class': 'vt-empty-inline' }, [ _('No wireless networks configured.') ]) ]);
return; return;
} }
dom.content(this.el.ssids, groups.map(function(g) { patch(this.el.ssids, groups.map(function(g) {
var rx = 0, tx = 0, has = false; var rx = 0, tx = 0, has = false;
g.ids.forEach(function(id) { var r = st.ssidRates[id]; if (r) { rx += r.rx; tx += r.tx; has = true; } }); g.ids.forEach(function(id) { var r = st.ssidRates[id]; if (r) { rx += r.rx; tx += r.tx; has = true; } });
var bands = g.bands.map(function(b) { return bandBadge(b); }); var bands = g.bands.map(function(b) { return bandBadge(b); });
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -22,6 +22,20 @@
local current_omr_version = luci.model.uci.cursor():get("openmptcprouter","settings","version") or "" local current_omr_version = luci.model.uci.cursor():get("openmptcprouter","settings","version") or ""
local latest_omr_version = luci.model.uci.cursor():get("openmptcprouter","latest_versions","omr") or "" local latest_omr_version = luci.model.uci.cursor():get("openmptcprouter","latest_versions","omr") or ""
-- Numeric per dotted component of the release part ("0.64.10" of
-- "0.64.10-6.18"): a string compare put 0.64.10 before 0.64.9.
local function omr_version_newer(cur, new)
local a, b = {}, {}
for n in (cur:match("^v?([%d%.]+)") or ""):gmatch("%d+") do a[#a + 1] = tonumber(n) end
for n in (new:match("^v?([%d%.]+)") or ""):gmatch("%d+") do b[#b + 1] = tonumber(n) end
if #a == 0 or #b == 0 then return false end
for i = 1, math.max(#a, #b) do
local x, y = a[i] or 0, b[i] or 0
if x ~= y then return y > x end
end
return false
end
-%> -%>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="<%=luci.i18n.context.lang%>"> <html lang="<%=luci.i18n.context.lang%>">
@@ -46,7 +60,7 @@
<script src="<%=resource%>/cbi.js"></script> <script src="<%=resource%>/cbi.js"></script>
</head> </head>
<body class="lang_<%=luci.i18n.context.lang%> <% if node then %><%= striptags( node.title ) %><%- end %>" data-page="<%= table.concat(disp.context.requestpath, "-") %>"> <body class="lang_<%=luci.i18n.context.lang%> <% if node then %><%= striptags( node.title ) %><%- end %>" data-page="<%= pcdata(table.concat(disp.context.requestpath, "-")) %>">
<header> <header>
<div class="fill"> <div class="fill">
<div class="container"> <div class="container">
@@ -67,9 +81,9 @@
<% end %> <% end %>
</div> </div>
<%- end -%> <%- end -%>
<%- if current_omr_version ~= "" and latest_omr_version ~= "" and current_omr_version < latest_omr_version then -%> <%- if omr_version_newer(current_omr_version, latest_omr_version) then -%>
<div class="alert-message notice"> <div class="alert-message notice">
<p><%=translatef("You have OpenMPTCProuter version %s and version %s is now available",current_omr_version,latest_omr_version)%></p> <p><%=translatef("You have OpenMPTCProuter version %s and version %s is now available",pcdata(current_omr_version),pcdata(latest_omr_version))%></p>
<div class="right"><a class="btn" href="https://www.openmptcprouter.com/"><%:Download latest version...%></a></div> <div class="right"><a class="btn" href="https://www.openmptcprouter.com/"><%:Download latest version...%></a></div>
</div> </div>
<%- end -%> <%- end -%>
+2 -2
View File
@@ -10,8 +10,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=luci-theme-vantage PKG_NAME:=luci-theme-vantage
PKG_VERSION:=1.0.4 PKG_VERSION:=1.0.5
PKG_RELEASE:=17 PKG_RELEASE:=18
PKG_LICENSE:=GPL-3.0-or-later PKG_LICENSE:=GPL-3.0-or-later
LUCI_TITLE:=Vantage theme LUCI_TITLE:=Vantage theme
@@ -186,6 +186,14 @@ function headingKey(text) {
return String(text || '').replace(/\s+/g, ' ').replace(/[\s:]+$/, '').trim().toLowerCase(); return String(text || '').replace(/\s+/g, ' ').replace(/[\s:]+$/, '').trim().toLowerCase();
} }
/* Does a heading only repeat the breadcrumb? A page title (h2) may repeat
any crumb; a card heading (h3) only the page's or the tab's, never the
category's: on Services > Dynamic DNS the "Services" table is content. */
function repeatsTrail(key, tag, trail) {
if (!key) return false;
return (tag === 'H2' ? trail.all : trail.page).has(key);
}
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
* Stock-page enhancers, only where CSS cannot restructure LuCI's markup * Stock-page enhancers, only where CSS cannot restructure LuCI's markup
* (Status -> Overview: fused memory bar, per-mount storage bars, radio band * (Status -> Overview: fused memory bar, per-mount storage bars, radio band
@@ -587,7 +595,7 @@ return baseclass.extend({
this.closeTimer = null; this.closeTimer = null;
this.openTimer = null; this.openTimer = null;
this.entries = []; this.entries = [];
this.trailKeys = new Set(); this.trail = { all: new Set(), page: new Set() };
this.setupTheme(); this.setupTheme();
this.setupIndicators(); this.setupIndicators();
@@ -670,8 +678,11 @@ return baseclass.extend({
link.lastChild.setAttribute('class', 'vt-rail-caret'); link.lastChild.setAttribute('class', 'vt-rail-caret');
link.addEventListener('click', ev => this.handleRailClick(ev, cat, li, link)); link.addEventListener('click', ev => this.handleRailClick(ev, cat, li, link));
/* hover opens a category; an open page switcher was opened on
purpose, so passing over the rail leaves it be (a click on a
category still swaps it) */
li.addEventListener('pointerenter', ev => { li.addEventListener('pointerenter', ev => {
if (ev.pointerType !== 'mouse' || this.isPinned() || this.mqMobile.matches) return; if (ev.pointerType !== 'mouse' || this.isPinned() || this.mqMobile.matches || this.switchOpen()) return;
this.cancelClose(); this.cancelClose();
window.clearTimeout(this.openTimer); window.clearTimeout(this.openTimer);
this.openTimer = window.setTimeout(() => this.openFlyout(cat, li, link, false), this.fly.hidden ? 90 : 0); this.openTimer = window.setTimeout(() => this.openFlyout(cat, li, link, false), this.fly.hidden ? 90 : 0);
@@ -679,6 +690,7 @@ return baseclass.extend({
li.addEventListener('pointerleave', ev => { li.addEventListener('pointerleave', ev => {
if (ev.pointerType !== 'mouse') return; if (ev.pointerType !== 'mouse') return;
window.clearTimeout(this.openTimer); window.clearTimeout(this.openTimer);
if (this.isPinned() || this.switchOpen()) return;
this.scheduleClose(); this.scheduleClose();
}); });
@@ -701,13 +713,16 @@ return baseclass.extend({
list.addEventListener('keydown', ev => this.handleRailKeys(ev, list)); list.addEventListener('keydown', ev => this.handleRailKeys(ev, list));
fly.addEventListener('pointerenter', () => this.cancelClose()); fly.addEventListener('pointerenter', () => this.cancelClose());
fly.addEventListener('pointerleave', ev => { if (ev.pointerType === 'mouse') this.scheduleClose(); }); fly.addEventListener('pointerleave', ev => { if (ev.pointerType === 'mouse' && !fly.classList.contains('is-switch')) this.scheduleClose(); });
fly.addEventListener('keydown', ev => this.handleFlyKeys(ev)); fly.addEventListener('keydown', ev => this.handleFlyKeys(ev));
/* the switcher closes once focus moves on past it (a null target is
a click on nothing focusable, which the document click handles) */
fly.addEventListener('focusout', ev => this.leaveSwitch(ev.relatedTarget));
if (this.scrim) this.scrim.addEventListener('click', () => this.closeFlyout(false)); if (this.scrim) this.scrim.addEventListener('click', () => this.closeFlyout(false));
document.addEventListener('click', ev => { document.addEventListener('click', ev => {
if (fly.hidden) return; if (fly.hidden) return;
if (ev.target.closest('#vt-flyout') || ev.target.closest('#vt-rail')) return; if (ev.target.closest('#vt-flyout') || ev.target.closest('#vt-rail') || ev.target.closest('.vt-crumb-switch')) return;
this.closeFlyout(false); this.closeFlyout(false);
}); });
@@ -777,7 +792,8 @@ return baseclass.extend({
handleFlyKeys(ev) { handleFlyKeys(ev) {
const links = Array.prototype.slice.call(this.fly.querySelectorAll('a')); const links = Array.prototype.slice.call(this.fly.querySelectorAll('a'));
const i = links.indexOf(document.activeElement); const i = links.indexOf(document.activeElement);
if (ev.key === 'Escape' || (ev.key === 'ArrowLeft' && !this.mqMobile.matches)) { const isSwitch = this.switchOpen();
if (ev.key === 'Escape' || (ev.key === 'ArrowLeft' && !isSwitch && !this.mqMobile.matches)) {
ev.preventDefault(); ev.preventDefault();
this.closeFlyout(true); this.closeFlyout(true);
} }
@@ -787,15 +803,57 @@ return baseclass.extend({
links[(i + d + links.length) % links.length].focus(); links[(i + d + links.length) % links.length].focus();
} }
else if (ev.key === 'Tab' && links.length) { else if (ev.key === 'Tab' && links.length) {
/* leaving the panel returns to the rail */
const edge = ev.shiftKey ? 0 : links.length - 1; const edge = ev.shiftKey ? 0 : links.length - 1;
if (i === edge) { ev.preventDefault(); this.closeFlyout(true); } if (i !== edge) return;
ev.preventDefault();
/* the switcher reads as part of the top bar, after its crumb:
Tab goes on to the next control there, Shift+Tab back to the
crumb. The rail's flyout returns to its rail button. */
if (isSwitch && !ev.shiftKey) {
const btn = this.switchBtn;
this.closeFlyout(false);
this.focusAfter(btn);
}
else this.closeFlyout(true);
} }
}, },
/* keys on the crumb switch: arrows open it (or enter it) at the current
page; Tab from the open switch goes into its list */
handleSwitchKeys(ev, cat, page, btn) {
const down = ev.key === 'ArrowDown', up = ev.key === 'ArrowUp';
if (!down && !up && !(ev.key === 'Tab' && !ev.shiftKey && this.switchOpen())) return;
ev.preventDefault();
if (!this.switchOpen()) { this.openSwitch(cat, page, btn, true); return; }
const links = this.fly.querySelectorAll('a');
const target = (ev.key === 'Tab' ? null : this.fly.querySelector('a[aria-current]')) ||
(up ? links[links.length - 1] : links[0]);
if (target) target.focus();
},
switchOpen() {
return !!(this.fly && !this.fly.hidden && this.fly.classList.contains('is-switch'));
},
/* focus moved from the switch or its list to `to` */
leaveSwitch(to) {
if (!to || !this.switchOpen()) return;
if (this.fly.contains(to) || to === this.switchBtn) return;
this.closeFlyout(false);
},
/* the control after `el` in tab order, outside the flyout */
focusAfter(el) {
const all = Array.prototype.filter.call(document.querySelectorAll('a[href], button, input, select, textarea, [tabindex]'), n =>
n.tabIndex >= 0 && !n.disabled && n.type !== 'hidden' && !this.fly.contains(n) && n.getClientRects().length > 0);
const next = all[all.indexOf(el) + 1];
(next || el).focus();
},
openFlyout(cat, li, link, focusFirst) { openFlyout(cat, li, link, focusFirst) {
const fly = this.fly; const fly = this.fly;
this.cancelClose(); this.cancelClose();
this.endSwitch();
if (fly.getAttribute('data-cat') !== cat.name || fly.hidden) { if (fly.getAttribute('data-cat') !== cat.name || fly.hidden) {
fly.textContent = ''; fly.textContent = '';
const total = cat.pages.length; const total = cat.pages.length;
@@ -851,6 +909,7 @@ return baseclass.extend({
this.cancelClose(); this.cancelClose();
this.fly.hidden = true; this.fly.hidden = true;
if (this.scrim) this.scrim.hidden = true; if (this.scrim) this.scrim.hidden = true;
this.endSwitch();
document.querySelectorAll('.vt-rail-item.is-open').forEach(n => { document.querySelectorAll('.vt-rail-item.is-open').forEach(n => {
n.classList.remove('is-open'); n.classList.remove('is-open');
const b = n.querySelector('.vt-rail-link[aria-expanded]'); const b = n.querySelector('.vt-rail-link[aria-expanded]');
@@ -859,6 +918,54 @@ return baseclass.extend({
if (refocus && this.openLink) this.openLink.focus(); if (refocus && this.openLink) this.openLink.focus();
}, },
/* the flyout, filled with a page's tabs: a dropdown under the crumb, the
same bottom sheet as the rail's on phones */
openSwitch(cat, page, btn, focusFirst) {
const fly = this.fly;
if (!fly) return;
/* a rail hover may have left a timer behind; closeFlyout() skips
cancelling it when the panel is hidden, so cancel both here */
this.cancelClose();
window.clearTimeout(this.openTimer);
this.openTimer = null;
this.closeFlyout(false);
fly.textContent = '';
const total = page.subs.length;
fly.appendChild(E('div', { 'class': 'vt-fly-head' }, [
svg(cat.icon, 18),
E('span', { 'class': 'vt-fly-title', 'id': 'vt-fly-title' }, [ page.title ]),
E('span', { 'class': 'vt-fly-count' }, [ '%d %s'.format(total, total === 1 ? _('page') : _('pages')) ])
]));
const ul = E('ul', { 'class': 'vt-fly-list', 'role': 'list' });
for (const s of page.subs)
ul.appendChild(E('li', {}, E('a', {
'class': 'vt-fly-link', 'href': L.url('admin', cat.name, page.name, s.name), 'aria-current': this.dp[3] === s.name ? 'page' : null
}, [ E('span', {}, [ s.title ]) ])));
fly.appendChild(ul);
fly.setAttribute('data-cat', '');
fly.setAttribute('role', 'region');
fly.setAttribute('aria-labelledby', 'vt-fly-title');
const x = Math.max(8, Math.min(btn.getBoundingClientRect().left - 8, window.innerWidth - 328));
fly.style.setProperty('--x', x.toFixed(0) + 'px');
fly.classList.add('is-switch');
btn.setAttribute('aria-expanded', 'true');
this.switchBtn = btn;
this.openLink = btn;
fly.hidden = false;
if (this.scrim) this.scrim.hidden = !this.mqMobile.matches;
if (focusFirst) {
const first = fly.querySelector('a[aria-current]') || fly.querySelector('a');
if (first) first.focus();
}
},
endSwitch() {
if (this.fly) this.fly.classList.remove('is-switch');
if (this.switchBtn) this.switchBtn.setAttribute('aria-expanded', 'false');
},
scheduleClose() { scheduleClose() {
this.cancelClose(); this.cancelClose();
this.closeTimer = window.setTimeout(() => this.closeFlyout(false), 220); this.closeTimer = window.setTimeout(() => this.closeFlyout(false), 220);
@@ -883,17 +990,43 @@ return baseclass.extend({
} }
if (!parts.length) return; if (!parts.length) return;
/* a page with page tabs: its current crumb switches between them,
from the top bar, which stays in view while the tabs scroll away */
const cat = this.cats.find(c => c.name === this.dp[1]);
const page = cat && this.dp.length > 3 ? cat.pages.find(p => p.name === this.dp[2]) : null;
const siblings = page && page.subs.length > 1 ? page : null;
nav.textContent = ''; nav.textContent = '';
parts.forEach((p, i) => { parts.forEach((p, i) => {
const last = i === parts.length - 1; const last = i === parts.length - 1;
if (i) nav.appendChild(E('span', { 'class': 'vt-crumb-sep', 'aria-hidden': 'true' }, [ '›' ])); if (i) nav.appendChild(E('span', { 'class': 'vt-crumb-sep', 'aria-hidden': 'true' }, [ '›' ]));
nav.appendChild(last if (last && siblings && parts.length === 3) {
/* a disclosure (aria-expanded + aria-controls): the panel is a
region of links, not a menu, so no aria-haspopup */
const btn = E('button', {
'type': 'button', 'class': 'vt-crumb vt-crumb-current vt-crumb-switch', 'aria-current': 'page',
'aria-expanded': 'false', 'aria-controls': 'vt-flyout',
'title': _('Other pages in %s').format(siblings.title)
}, [ E('span', {}, [ p.title ]), svg('caret', 14) ]);
btn.addEventListener('click', ev => {
if (this.switchOpen()) this.closeFlyout(true);
else this.openSwitch(cat, siblings, btn, ev.detail === 0);
});
btn.addEventListener('keydown', ev => this.handleSwitchKeys(ev, cat, siblings, btn));
btn.addEventListener('focusout', ev => this.leaveSwitch(ev.relatedTarget));
nav.appendChild(btn);
}
else nav.appendChild(last
? E('span', { 'class': 'vt-crumb vt-crumb-current', 'aria-current': 'page' }, [ p.title ]) ? E('span', { 'class': 'vt-crumb vt-crumb-current', 'aria-current': 'page' }, [ p.title ])
: E('a', { 'class': 'vt-crumb', 'href': L.url.apply(L, p.segs) }, [ p.title ])); : E('a', { 'class': 'vt-crumb', 'href': L.url.apply(L, p.segs) }, [ p.title ]));
}); });
this.pageTitle = parts[parts.length - 1].title; this.pageTitle = parts[parts.length - 1].title;
parts.forEach(p => this.trailKeys.add(headingKey(p.title))); parts.forEach((p, i) => {
this.trail.all.add(headingKey(p.title));
/* the category, unless it is the page itself */
if (i || parts.length === 1) this.trail.page.add(headingKey(p.title));
});
this.updateTitle(); this.updateTitle();
}, },
@@ -906,17 +1039,16 @@ return baseclass.extend({
const tabs = ui.menu.getChildren(page); const tabs = ui.menu.getChildren(page);
if (tabs.length < 2) return; if (tabs.length < 2) return;
bar.textContent = ''; const seg = E('div', { 'class': 'vt-pagetabs' });
for (const t of tabs) { for (const t of tabs) {
const cur = this.dp[3] === t.name; const cur = this.dp[3] === t.name;
bar.appendChild(E('a', { seg.appendChild(E('a', {
'class': 'vt-tab', 'href': L.url('admin', this.dp[1], this.dp[2], t.name), 'aria-current': cur ? 'page' : null 'class': 'vt-tab', 'href': L.url('admin', this.dp[1], this.dp[2], t.name), 'aria-current': cur ? 'page' : null
}, [ _(t.title) ])); }, [ _(t.title) ]));
} }
bar.textContent = '';
bar.appendChild(seg);
bar.hidden = false; bar.hidden = false;
const active = bar.querySelector('[aria-current]');
if (active && active.scrollIntoView && bar.scrollWidth > bar.clientWidth)
bar.scrollLeft = active.offsetLeft - 16;
}, },
/* ------------------------------------------------------ palette */ /* ------------------------------------------------------ palette */
@@ -954,8 +1086,11 @@ return baseclass.extend({
openPalette() { openPalette() {
if (this.pal) return; if (this.pal) return;
/* focus inside the flyout goes back to what opened the flyout, since
closing it hides the element that had focus */
const from = document.activeElement;
this.palReturn = (this.fly && this.fly.contains(from)) ? this.openLink : from;
this.closeFlyout(false); this.closeFlyout(false);
this.palReturn = document.activeElement;
const input = E('input', { const input = E('input', {
'class': 'vt-pal-input', 'type': 'text', 'role': 'combobox', 'aria-expanded': 'true', 'class': 'vt-pal-input', 'type': 'text', 'role': 'combobox', 'aria-expanded': 'true',
@@ -1238,7 +1373,7 @@ return baseclass.extend({
setupForms() { setupForms() {
this.formsQueued = false; this.formsQueued = false;
this.helpSeq = 0; this.helpSeq = 0;
this.helpSizes = window.ResizeObserver ? new ResizeObserver(entries => entries.forEach(e => this.fitHelp(e.target))) : null; this.helpSizes = window.ResizeObserver ? new ResizeObserver(entries => this.fitHelps(entries.map(e => e.target))) : null;
if (window.MutationObserver) if (window.MutationObserver)
new MutationObserver(() => this.queueForms()).observe(document.body, { childList: true, subtree: true }); new MutationObserver(() => this.queueForms()).observe(document.body, { childList: true, subtree: true });
this.decorateForms(); this.decorateForms();
@@ -1319,14 +1454,26 @@ return baseclass.extend({
not displayed (another tab, an unmet dependency) measures 0 and waits not displayed (another tab, an unmet dependency) measures 0 and waits
for the next resize notification. */ for the next resize notification. */
fitHelp(desc) { fitHelp(desc) {
if (!desc.isConnected) { this.fitHelps([ desc ]);
if (this.helpSizes) this.helpSizes.unobserve(desc); },
return;
/* all sizes are read before any button changes: a write between two
reads forced a layout per field (66 when the Wireless modal opened) */
fitHelps(descs) {
const fits = [];
for (const desc of descs) {
if (!desc.isConnected) {
if (this.helpSizes) this.helpSizes.unobserve(desc);
continue;
}
const h = desc._vtHelp;
if (!h || !h.btn.isConnected || !desc.clientHeight) continue;
fits.push([ h, desc.scrollHeight > desc.clientHeight + 1 ]);
}
for (const [ h, cut ] of fits) {
const hide = !cut && h.holder.getAttribute('data-v-help') !== 'open';
if (h.btn.hidden !== hide) h.btn.hidden = hide;
} }
const h = desc._vtHelp;
if (!h || !h.btn.isConnected || !desc.clientHeight) return;
const cut = desc.scrollHeight > desc.clientHeight + 1;
h.btn.hidden = !cut && h.holder.getAttribute('data-v-help') !== 'open';
}, },
/* A page title or card heading that only repeats the breadcrumb, the /* A page title or card heading that only repeats the breadcrumb, the
@@ -1338,7 +1485,7 @@ return baseclass.extend({
document.querySelectorAll('#maincontent > h2, #view h2, #view .cbi-section > h3:first-child').forEach(h => { document.querySelectorAll('#maincontent > h2, #view h2, #view .cbi-section > h3:first-child').forEach(h => {
if (h.closest('.vt-app') || h.querySelector('a, button, input, select, [data-clickable], [tabindex]')) return; if (h.closest('.vt-app') || h.querySelector('a, button, input, select, [data-clickable], [tabindex]')) return;
const key = headingKey(h.textContent); const key = headingKey(h.textContent);
let repeat = this.trailKeys.has(key); let repeat = repeatsTrail(key, h.tagName, this.trail);
if (!repeat && h.tagName === 'H3') { if (!repeat && h.tagName === 'H3') {
const map = h.closest('.cbi-map'); const map = h.closest('.cbi-map');
const mapTitle = map && map.querySelector(':scope > h2'); const mapTitle = map && map.querySelector(':scope > h2');
@@ -69,14 +69,17 @@
--warn-soft: rgba(214, 138, 0, .15); --warn-soft: rgba(214, 138, 0, .15);
--err: #c42b2b; --err: #c42b2b;
--err-soft: rgba(196, 43, 43, .1); --err-soft: rgba(196, 43, 43, .1);
--err-ink: #ffffff; /* text on an --err / --info fill, >= 4.5:1 */
--info: #1c64c4; --info: #1c64c4;
--info-ink: #ffffff;
--ok-text: #0a7262; /* --ok as text on control fills (s3, s4) */
--info-soft: rgba(28, 100, 196, .1); --info-soft: rgba(28, 100, 196, .1);
--scrim: rgba(20, 23, 26, .38); --scrim: rgba(20, 23, 26, .38);
--shadow-pop: 0 12px 32px rgba(20, 23, 26, .16), 0 2px 6px rgba(20, 23, 26, .08); --shadow-pop: 0 12px 32px rgba(20, 23, 26, .16), 0 2px 6px rgba(20, 23, 26, .08);
--zone-mix: .22; --zone-mix: .22;
--seg-bg: var(--s3); /* segmented control: track, selected segment */ --seg-bg: var(--s3); /* segmented control: track, selected segment */
--seg-on: var(--s2); --seg-on: var(--s2);
--seg-on-ring: 0 0 0 1px var(--line), var(--v-shadow); --seg-on-ring: inset 0 0 0 1px var(--line); /* inset: drawn at the segment's padding edge */
} }
html[data-darkmode="true"] { html[data-darkmode="true"] {
@@ -107,14 +110,17 @@ html[data-darkmode="true"] {
--warn-soft: rgba(245, 184, 74, .13); --warn-soft: rgba(245, 184, 74, .13);
--err: #ff6b6b; --err: #ff6b6b;
--err-soft: rgba(255, 107, 107, .12); --err-soft: rgba(255, 107, 107, .12);
--err-ink: #1a0505; /* white on the light dark-mode red was 2.8:1 */
--info: #6cb4ff; --info: #6cb4ff;
--info-ink: #0d0e10;
--ok-text: var(--ok);
--info-soft: rgba(108, 180, 255, .12); --info-soft: rgba(108, 180, 255, .12);
--scrim: rgba(0, 0, 0, .58); --scrim: rgba(0, 0, 0, .58);
--shadow-pop: 0 16px 40px rgba(0, 0, 0, .55), 0 0 0 1px rgba(255, 255, 255, .02); --shadow-pop: 0 16px 40px rgba(0, 0, 0, .55), 0 0 0 1px rgba(255, 255, 255, .02);
--zone-mix: .2; --zone-mix: .2;
--seg-bg: var(--bg); --seg-bg: var(--bg);
--seg-on: var(--s3); --seg-on: var(--s3);
--seg-on-ring: 0 0 0 1px var(--line-2); --seg-on-ring: inset 0 0 0 1px var(--line-2);
} }
/* touch: controls are sized by the pointer, not the screen width */ /* touch: controls are sized by the pointer, not the screen width */
@@ -161,7 +167,7 @@ html[data-darkmode="true"] {
--v-r-lg: var(--r-lg); --v-r-lg: var(--r-lg);
--v-ctl-h: var(--ctl-h); /* every control: 36px, 44px on touch */ --v-ctl-h: var(--ctl-h); /* every control: 36px, 44px on touch */
--v-shadow: 0 1px 2px rgba(20, 23, 26, .06), 0 1px 0 rgba(20, 23, 26, .03); --v-shadow: 0 1px 2px rgba(20, 23, 26, .06), 0 1px 0 rgba(20, 23, 26, .03);
/* where sticky content may start: below the top bar and page tabs */ /* where sticky content may start: below the top bar (page tabs scroll) */
--v-stick-top: var(--stick-top); --v-stick-top: var(--stick-top);
/* charts (stock realtime graphs, channel analysis, the dashboard app). /* charts (stock realtime graphs, channel analysis, the dashboard app).
@@ -429,7 +435,7 @@ html[data-rail="pinned"] .vt-frame { margin-left: var(--rail-w-pinned); }
.vt-fly-list > li + li { margin-top: 1px; } .vt-fly-list > li + li { margin-top: 1px; }
.vt-fly-link { .vt-fly-link {
display: flex; align-items: center; gap: var(--sp-2); display: flex; align-items: center; gap: var(--sp-2);
min-height: 34px; padding: 6px 10px; min-height: var(--ctl-h); padding: 6px 10px;
border-radius: var(--r); border-radius: var(--r);
color: var(--text); color: var(--text);
text-decoration: none !important; text-decoration: none !important;
@@ -475,16 +481,49 @@ html[data-rail="pinned"] .vt-frame { margin-left: var(--rail-w-pinned); }
.vt-crumbs { .vt-crumbs {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;
min-width: 0; flex: 1 1 auto; min-width: 0; flex: 1 .001 auto; /* gives way last: see .vt-top-actions */
align-self: stretch; /* the bar's height: room for focus rings */
margin-inline: -8px; padding-inline: 8px; /* room for the switch's ring (it sits 6px out) */
font-size: var(--fs-base); font-size: var(--fs-base);
white-space: nowrap; overflow: hidden; white-space: nowrap; overflow: hidden;
} }
.vt-crumb { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; text-decoration: none !important; } .vt-crumb { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; text-decoration: none !important; }
/* a link crumb is a control high to click; the padding adds no height to the bar */
a.vt-crumb { padding-block: calc((var(--ctl-h) - 1.5em) / 2); }
a.vt-crumb:hover { color: var(--text); } a.vt-crumb:hover { color: var(--text); }
.vt-crumb-current { color: var(--text); font-weight: 600; } .vt-crumb-current { color: var(--text); font-weight: 600; }
.vt-crumb-sep { color: var(--text-3); flex: none; font-family: var(--mono); font-size: var(--fs-sm); } .vt-crumb-sep { color: var(--text-3); flex: none; font-family: var(--mono); font-size: var(--fs-sm); }
/* the current crumb of a page with page tabs: opens its sibling pages.
A control high; the ring sits on its edge, inside the crumbs' clip. */
.vt-crumb-switch {
display: inline-flex; align-items: center; gap: 4px;
min-width: 0; min-height: var(--ctl-h); margin: 0 -6px; padding: 0 6px;
border: 0; border-radius: var(--r);
background: none;
font: inherit; font-weight: 600; color: var(--text);
cursor: pointer;
}
.vt-crumb-switch > span { overflow: hidden; text-overflow: ellipsis; }
.vt-crumb-switch > svg { flex: none; width: 14px; height: 14px; color: var(--text-3); transition: transform .15s; }
.vt-crumb-switch:hover, .vt-crumb-switch[aria-expanded="true"] { background: var(--s3); }
.vt-crumb-switch:focus-visible { outline-offset: 0; }
.vt-crumb-switch[aria-expanded="true"] > svg { transform: rotate(90deg); }
@media (width > 720px) {
/* the flyout as a dropdown under the crumb (left: set from the crumb) */
.vt-flyout.is-switch {
top: calc(var(--top-h) - 4px); bottom: auto; left: var(--x, var(--rail-w));
width: auto; min-width: 240px; max-width: 320px;
max-height: calc(100vh - var(--top-h) - var(--sp-4));
border: 1px solid var(--line-2); border-radius: var(--r-lg);
animation: vt-drop-in .12s ease-out;
}
}
@keyframes vt-drop-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
.vt-top-actions { display: flex; align-items: center; gap: var(--sp-2); flex: none; } /* when the bar is short of room, the host chip gives way first, then the
search field's label, and only then the crumbs */
.vt-top-actions { display: flex; align-items: center; gap: var(--sp-2); flex: 0 1 auto; min-width: 0; }
.vt-top-actions > :not(.vt-host, .vt-search) { flex: none; }
.vt-search { .vt-search {
display: inline-flex; align-items: center; gap: var(--sp-2); display: inline-flex; align-items: center; gap: var(--sp-2);
@@ -522,6 +561,7 @@ a.vt-crumb:hover { color: var(--text); }
.vt-host { .vt-host {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
height: 28px; max-width: 300px; height: 28px; max-width: 300px;
flex: 0 1000 auto; min-width: 0; overflow: hidden;
padding: 0 10px; padding: 0 10px;
border: 1px solid var(--line); border-radius: 999px; border: 1px solid var(--line); border-radius: 999px;
background: var(--s1); background: var(--s1);
@@ -547,18 +587,30 @@ a.vt-crumb:hover { color: var(--text); }
} }
#indicators > span[data-clickable] { cursor: pointer; } #indicators > span[data-clickable] { cursor: pointer; }
#indicators > span[data-clickable]:hover { border-color: var(--text-3); color: var(--text); } #indicators > span[data-clickable]:hover { border-color: var(--text-3); color: var(--text); }
#indicators > span[data-indicator="poll-status"][data-style="active"] { position: relative; isolation: isolate; }
#indicators > span[data-indicator="poll-status"][data-style="active"]::before { #indicators > span[data-indicator="poll-status"][data-style="active"]::before {
content: ""; width: 7px; height: 7px; border-radius: 50%; content: ""; width: 7px; height: 7px; border-radius: 50%;
background: var(--accent-text); background: var(--accent-text);
box-shadow: 0 0 0 0 var(--accent-line); }
/* the pulse is a ring of its own that scales and fades: the compositor
runs it, where an animated box-shadow repainted the page every frame.
It sits behind the dot (z-index -1 in the pill's isolated stack), so
the dot never lightens, and is invisible unless animated: with reduced
motion the one near-instant run ends on opacity 0. */
#indicators > span[data-indicator="poll-status"][data-style="active"]::after {
content: ""; position: absolute; z-index: -1; left: 10px; top: calc(50% - 3.5px);
width: 7px; height: 7px; border-radius: 50%;
background: var(--accent-line);
opacity: 0;
pointer-events: none;
animation: vt-live 2s ease-out infinite; animation: vt-live 2s ease-out infinite;
} }
#indicators > span[data-indicator="poll-status"][data-style="inactive"]::before { #indicators > span[data-indicator="poll-status"][data-style="inactive"]::before {
content: ""; width: 7px; height: 7px; border-radius: 1px; background: var(--text-3); content: ""; width: 7px; height: 7px; border-radius: 1px; background: var(--text-3);
} }
@keyframes vt-live { @keyframes vt-live {
0% { box-shadow: 0 0 0 0 var(--accent-line); } 0% { transform: scale(1); opacity: 1; }
70%, 100% { box-shadow: 0 0 0 6px transparent; } 70%, 100% { transform: scale(2.7); opacity: 0; }
} }
/* "Unsaved Changes: N" -> label + prominent count (text node stays for LuCI) */ /* "Unsaved Changes: N" -> label + prominent count (text node stays for LuCI) */
#indicators > span[data-indicator][data-count] { #indicators > span[data-indicator][data-count] {
@@ -580,35 +632,14 @@ a.vt-crumb:hover { color: var(--text); }
} }
#indicators > span[data-indicator="media_error"] { color: var(--err); border-color: var(--err); background: var(--err-soft); } #indicators > span[data-indicator="media_error"] { color: var(--err); border-color: var(--err); background: var(--err-soft); }
/* level-3 page tabs */ /* level-3 page tabs: on the content column, drawn as every other tab
level (.vt-pagetabs shares the .cbi-tabmenu rules below). They scroll with
the page; the current crumb in the top bar switches between them. */
.vt-tabs { .vt-tabs {
position: sticky; top: var(--top-h); z-index: 190; width: 100%; max-width: calc(var(--content-max) + 2 * var(--sp-6));
display: flex; gap: var(--sp-1); margin: 0 auto; padding: var(--sp-6) var(--sp-6) 0;
padding: 0 var(--sp-5);
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid var(--line);
overflow-x: auto;
scrollbar-width: none;
}
.vt-tabs::-webkit-scrollbar { display: none; }
.vt-tab {
position: relative;
display: inline-flex; align-items: center;
height: var(--ctl-h); padding: 0 var(--sp-3);
color: var(--text-2);
font-weight: 500;
white-space: nowrap;
text-decoration: none !important;
}
.vt-tab:hover { color: var(--text); }
.vt-tab[aria-current] { color: var(--text); font-weight: 600; }
.vt-tab[aria-current]::after {
content: ""; position: absolute; left: var(--sp-3); right: var(--sp-3); bottom: -1px;
height: 2px; border-radius: 2px 2px 0 0;
background: var(--accent-text);
} }
.vt-tabs:not([hidden]) + .vt-main { padding-top: var(--sp-5); }
/* content column */ /* content column */
.vt-main { .vt-main {
@@ -712,7 +743,6 @@ a.vt-crumb:hover { color: var(--text); }
/* page = title block + a stack of cards. Cards carry a full-bleed header /* page = title block + a stack of cards. Cards carry a full-bleed header
row (h3) and full-bleed tables; nested sections lose their chrome. */ row (h3) and full-bleed tables; nested sections lose their chrome. */
:root { --card-px: 20px; --card-py: 16px; --stick-top: var(--top-h); } :root { --card-px: 20px; --card-py: 16px; --stick-top: var(--top-h); }
html:has(.vt-tabs:not([hidden])) { --stick-top: calc(var(--top-h) + var(--ctl-h) + 1px); }
.cbi-map { margin-bottom: var(--sp-6); } .cbi-map { margin-bottom: var(--sp-6); }
.cbi-map > h2, .vt-main > h2, #view > h2, #view > div > h2:first-child, .cbi-map > h2:first-child { .cbi-map > h2, .vt-main > h2, #view > h2, #view > div > h2:first-child, .cbi-map > h2:first-child {
@@ -884,6 +914,8 @@ legend { font-size: var(--fs-md); font-weight: 600; padding: 0; margin-bottom: v
.cbi-section-create > * { margin: 0; } .cbi-section-create > * { margin: 0; }
.cbi-section-create > .cbi-section-create-name { max-width: 240px; } .cbi-section-create > .cbi-section-create-name { max-width: 240px; }
.cbi-section-remove { text-align: right; padding: var(--sp-2) 0; } .cbi-section-remove { text-align: right; padding: var(--sp-2) 0; }
/* several nodes of one typed section in a card: a rule between them */
.cbi-section-node + :is(.cbi-section-node, .cbi-section-remove) { margin-top: var(--sp-5); padding-top: var(--sp-5); border-top: 1px solid var(--line); }
/* page actions: a floating command bar docked to the bottom right */ /* page actions: a floating command bar docked to the bottom right */
.cbi-page-actions { .cbi-page-actions {
@@ -905,6 +937,13 @@ legend { font-size: var(--fs-md); font-weight: 600; padding: 0; margin-bottom: v
.cbi-page-actions .cbi-button-save { order: 2; } .cbi-page-actions .cbi-button-save { order: 2; }
.cbi-page-actions .cbi-button-apply, .cbi-page-actions .cbi-dropdown { order: 3; } .cbi-page-actions .cbi-button-apply, .cbi-page-actions .cbi-dropdown { order: 3; }
.cbi-page-actions:empty { display: none; } .cbi-page-actions:empty { display: none; }
/* the bar sits at the bottom of the view (docked, or the page's end), so
its Save & Apply menu opens upwards; LuCI's touch branch would open it
down, under the phone's bottom bar and past the screen */
.cbi-page-actions .cbi-dropdown[open] > ul.dropdown {
top: auto !important; bottom: 100% !important;
max-height: 50vh !important;
}
/* ------------------------------------------------------------ 6. widgets */ /* ------------------------------------------------------------ 6. widgets */
@@ -929,6 +968,8 @@ input, select, textarea, button { font: inherit; color: inherit; }
transition: border-color .12s, box-shadow .12s; transition: border-color .12s, box-shadow .12s;
} }
.vt-main textarea, .cbi-input-textarea, .modal textarea { max-width: calc(2 * var(--field-max)); min-height: 88px; font-family: var(--mono); font-size: var(--fs-sm); resize: vertical; } .vt-main textarea, .cbi-input-textarea, .modal textarea { max-width: calc(2 * var(--field-max)); min-height: 88px; font-family: var(--mono); font-size: var(--fs-sm); resize: vertical; }
/* read-only text (System and Kernel log) is a viewer, not a field: full width */
.vt-main textarea[readonly] { max-width: none; }
.vt-main select, .cbi-input-select, .modal select { .vt-main select, .cbi-input-select, .modal select {
appearance: none; -webkit-appearance: none; appearance: none; -webkit-appearance: none;
padding-right: 30px; padding-right: 30px;
@@ -953,6 +994,15 @@ input:focus, select:focus, textarea:focus {
box-shadow: 0 0 0 3px var(--accent-soft); box-shadow: 0 0 0 3px var(--accent-soft);
} }
input:focus-visible, select:focus-visible, textarea:focus-visible { outline: none; } input:focus-visible, select:focus-visible, textarea:focus-visible { outline: none; }
/* fields in pages, modals and the login: the rules above lose to the field
rules' (0,2,1), so hover and focus are restated at their weight. Focus:
a --focus border plus a 1px ring of it (>= 3:1 against the field and
the card in both modes) and the soft halo. The dashboard styles its own. */
:is(input, select, textarea):not([type="checkbox"], [type="radio"], [type="range"], .vt-pal-input, .vt-app *):hover { border-color: var(--text-3); }
:is(input, select, textarea):not([type="checkbox"], [type="radio"], [type="range"], .vt-pal-input, .vt-app *):focus {
border-color: var(--focus);
box-shadow: 0 0 0 1px var(--focus), 0 0 0 4px var(--accent-soft);
}
input[disabled], select[disabled], textarea[disabled], input[readonly] { opacity: .6; cursor: not-allowed; } input[disabled], select[disabled], textarea[disabled], input[readonly] { opacity: .6; cursor: not-allowed; }
input[readonly] { cursor: default; } input[readonly] { cursor: default; }
@@ -975,6 +1025,9 @@ input[readonly] { cursor: default; }
.control-group > :not(:last-child) { border-top-right-radius: 0 !important; border-bottom-right-radius: 0 !important; } .control-group > :not(:last-child) { border-top-right-radius: 0 !important; border-bottom-right-radius: 0 !important; }
.control-group > :not(:first-child) { border-top-left-radius: 0 !important; border-bottom-left-radius: 0 !important; } .control-group > :not(:first-child) { border-top-left-radius: 0 !important; border-bottom-left-radius: 0 !important; }
.control-group > .btn, .control-group > .cbi-button { flex: none; } .control-group > .btn, .control-group > .cbi-button { flex: none; }
/* in LuCI's unclassed field wrapper (a flex row) the group sized to its
content, 254px for a password with its reveal button: as wide as a field */
.cbi-value-field > div:not([class]) > .control-group { flex: 1 1 auto; }
/* checkbox */ /* checkbox */
.cbi-checkbox { display: inline-flex; align-items: center; min-height: var(--ctl-h); gap: var(--sp-2); } .cbi-checkbox { display: inline-flex; align-items: center; min-height: var(--ctl-h); gap: var(--sp-2); }
@@ -1054,7 +1107,7 @@ input[type="button"], input[type="reset"], input[type="submit"]) {
vertical-align: middle; vertical-align: middle;
} }
.cbi-dropdown:hover { border-color: var(--text-3); } .cbi-dropdown:hover { border-color: var(--text-3); }
.cbi-dropdown:focus, .cbi-dropdown[open] { border-color: var(--accent-line); box-shadow: 0 0 0 3px var(--accent-soft); } .cbi-dropdown:focus, .cbi-dropdown[open] { border-color: var(--focus); box-shadow: 0 0 0 1px var(--focus), 0 0 0 4px var(--accent-soft); }
.cbi-dropdown:focus-visible { outline: none; } .cbi-dropdown:focus-visible { outline: none; }
.cbi-dropdown[disabled] { opacity: .55; pointer-events: none; } .cbi-dropdown[disabled] { opacity: .55; pointer-events: none; }
@@ -1229,15 +1282,15 @@ input[type="button"], input[type="reset"], input[type="submit"]) {
} }
.cbi-value-field :is(.btn, .cbi-button).cbi-button-apply:hover, .cbi-value-field :is(.btn, .cbi-button).cbi-button-apply:hover,
.td :is(.btn, .cbi-button).cbi-button-apply:hover { background: color-mix(in srgb, var(--accent) 20%, transparent); color: var(--accent-text); } .td :is(.btn, .cbi-button).cbi-button-apply:hover { background: color-mix(in srgb, var(--accent) 20%, transparent); color: var(--accent-text); }
:is(.btn, .cbi-button).cbi-button-positive:not(.important) { color: var(--ok); } :is(.btn, .cbi-button).cbi-button-positive:not(.important) { color: var(--ok-text); }
:is(.btn, .cbi-button, .cbi-dropdown):is(.cbi-button-negative, .cbi-button-remove) { :is(.btn, .cbi-button, .cbi-dropdown):is(.cbi-button-negative, .cbi-button-remove) {
background: transparent; background: transparent;
color: var(--err); color: var(--err);
border-color: color-mix(in srgb, var(--err) 40%, var(--line-2)); border-color: color-mix(in srgb, var(--err) 40%, var(--line-2));
} }
:is(.btn, .cbi-button, .cbi-dropdown):is(.cbi-button-negative, .cbi-button-remove):hover { background: var(--err-soft); border-color: var(--err); } :is(.btn, .cbi-button, .cbi-dropdown):is(.cbi-button-negative, .cbi-button-remove):hover { background: var(--err-soft); border-color: var(--err); }
:is(.btn, .cbi-button, .cbi-dropdown).cbi-button-negative.important { background: var(--err); border-color: var(--err); color: #fff; } :is(.btn, .cbi-button, .cbi-dropdown).cbi-button-negative.important { background: var(--err); border-color: var(--err); color: var(--err-ink); }
:is(.btn, .cbi-button, .cbi-dropdown).cbi-button-negative.important:hover { background: color-mix(in srgb, var(--err) 85%, #000); color: #fff; } :is(.btn, .cbi-button, .cbi-dropdown).cbi-button-negative.important:hover { background: color-mix(in srgb, var(--err) 88%, var(--err-ink)); color: var(--err-ink); }
:is(.btn, .cbi-button).cbi-button-reset { background: transparent; color: var(--text-2); } :is(.btn, .cbi-button).cbi-button-reset { background: transparent; color: var(--text-2); }
:is(.btn, .cbi-button).cbi-button-reset:hover { color: var(--text); } :is(.btn, .cbi-button).cbi-button-reset:hover { color: var(--text); }
@@ -1397,119 +1450,45 @@ div.right, div.left { display: block; }
div.right { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-4); } div.right { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-4); }
div.right > .btn, div.right > .cbi-button, div.right > button { margin-left: 0; } div.right > .btn, div.right > .cbi-button, div.right > button { margin-left: 0; }
/* Tabs inside a page (ui.tabs), one look per level: /* Tabs: one segmented control for every level, the menu's page tabs
- tabs standing at page level (not inside a form section or another (.vt-pagetabs in #vt-tabs), ui.tabs at page level, in a form or modal,
tab) are the page's sub-pages when the menu shows no page tabs: they and tabs inside a tab. Nesting shows by where a control sits, not by
take the page-tab underline, like .vt-tabs (Interfaces / Devices / another look. It wraps its segments instead of hiding them. Every
..., DHCP's Leases / dnsmasq, Channel Analysis's radios). Under segment is a whole control high (--ctl-h, 36px / 44px on touch) to
.vt-tabs they are the first level inside the page instead; click or tap: the 3px between the track's edge and the drawn segment
- the first level inside a page, form or modal: a segmented control; is the segment's own transparent border, so the track is one control
- tabs inside a tab: a side list where the pane is wide enough high as well. */
(container query on the pane, so modals and pages alike), chips .cbi-tabmenu, .vt-tabs > .vt-pagetabs {
where it is not and at every deeper level. display: flex; flex-wrap: wrap; align-items: stretch;
A level is a pane of a section's option tabs (.cbi-section-node-tabbed);
map-level panes are not counted, so a page's sub-pages add no level. */
.cbi-tabmenu {
display: flex; flex-wrap: nowrap; align-items: center; gap: 2px;
width: fit-content; max-width: 100%; min-height: var(--ctl-h); width: fit-content; max-width: 100%; min-height: var(--ctl-h);
margin: 0 0 var(--sp-5); padding: 2px; margin: 0 0 var(--sp-5); padding: 0;
list-style: none; list-style: none;
background: var(--seg-bg); background: var(--seg-bg);
border: 1px solid var(--line);
border-radius: var(--r-lg); border-radius: var(--r-lg);
overflow-x: auto; box-shadow: inset 0 0 0 1px var(--line);
scrollbar-width: none;
} }
.cbi-tabmenu::-webkit-scrollbar { display: none; } .vt-tabs > .vt-pagetabs { margin: 0; }
.cbi-tabmenu > li { position: relative; flex: none; } .cbi-tabmenu > li { position: relative; flex: none; display: flex; max-width: 100%; }
.cbi-tabmenu > li > a { .cbi-tabmenu > li > a, .vt-pagetabs > .vt-tab {
flex: 1 1 auto;
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;
height: calc(var(--ctl-h) - 6px); padding: 0 var(--sp-3); min-height: var(--ctl-h); padding: 0 10px;
border-radius: var(--r); border: 3px solid transparent; background-clip: padding-box;
border-radius: calc(var(--r) + 3px);
color: var(--text-2); color: var(--text-2);
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
text-decoration: none !important; text-decoration: none !important;
} }
.cbi-tabmenu > li > a:hover { color: var(--text); } :is(.cbi-tabmenu > li > a, .vt-pagetabs > .vt-tab):hover { color: var(--text); }
.cbi-tabmenu > li.cbi-tab > a { background: var(--seg-on); color: var(--text); font-weight: 600; box-shadow: var(--seg-on-ring); } :is(.cbi-tabmenu > li > a, .vt-pagetabs > .vt-tab):focus-visible { outline-offset: -1px; border-radius: calc(var(--r) + 3px); }
.cbi-tabmenu > li.cbi-tab > a, .vt-pagetabs > .vt-tab[aria-current] { background-color: var(--seg-on); color: var(--text); font-weight: 600; box-shadow: var(--seg-on-ring); }
html:not(:has(.vt-tabs:not([hidden]))) #view .cbi-tabmenu:not(.cbi-section *, [data-tab-title] *) {
gap: var(--sp-1);
width: auto; padding: 0;
background: none; border: 0; border-bottom: 1px solid var(--line); border-radius: 0;
}
html:not(:has(.vt-tabs:not([hidden]))) #view .cbi-tabmenu:not(.cbi-section *, [data-tab-title] *) > li > a { height: var(--ctl-h); border-radius: 0; }
html:not(:has(.vt-tabs:not([hidden]))) #view .cbi-tabmenu:not(.cbi-section *, [data-tab-title] *) > li.cbi-tab > a { background: none; box-shadow: none; }
html:not(:has(.vt-tabs:not([hidden]))) #view .cbi-tabmenu:not(.cbi-section *, [data-tab-title] *) > li.cbi-tab::after {
content: ""; position: absolute; left: var(--sp-3); right: var(--sp-3); bottom: 0;
height: 2px; background: var(--accent-text); border-radius: 2px 2px 0 0;
}
.cbi-section-node-tabbed > [data-tab-title] { container-type: inline-size; }
.cbi-section-node-tabbed > [data-tab-title] .cbi-tabmenu {
flex-wrap: wrap; gap: var(--sp-2);
width: auto; margin-bottom: var(--sp-4); padding: 0;
background: none; border: 0; border-radius: 0;
overflow: visible;
}
.cbi-section-node-tabbed > [data-tab-title] .cbi-tabmenu > li > a {
height: var(--ctl-h); padding: 0 14px;
border: 1px solid var(--line-2); border-radius: 999px;
}
.cbi-section-node-tabbed > [data-tab-title] .cbi-tabmenu > li.cbi-tab > a {
background: var(--accent-soft); border-color: var(--accent-line);
color: var(--accent-text); box-shadow: none;
}
@container (min-width: 640px) {
.cbi-section-node-tabbed > [data-tab-title] :has(> .cbi-tabmenu) {
display: grid;
grid-template-columns: 168px minmax(0, 1fr);
column-gap: var(--sp-6);
align-items: start;
}
.cbi-section-node-tabbed > [data-tab-title] :has(> .cbi-tabmenu) > * { grid-column: 1 / -1; }
.cbi-section-node-tabbed > [data-tab-title] :has(> .cbi-tabmenu) > .cbi-tabmenu + * { grid-column: 2; }
.cbi-section-node-tabbed > [data-tab-title] .cbi-tabmenu {
grid-column: 1;
flex-direction: column; flex-wrap: nowrap; align-items: stretch; gap: 2px;
margin: 0;
}
.cbi-section-node-tabbed > [data-tab-title] .cbi-tabmenu > li > a {
justify-content: space-between;
padding: 0 var(--sp-3);
border: 0; border-radius: var(--r);
white-space: normal; line-height: 1.25;
}
.cbi-section-node-tabbed > [data-tab-title] .cbi-tabmenu > li > a:hover { background: var(--s3); }
.cbi-section-node-tabbed > [data-tab-title] .cbi-tabmenu > li.cbi-tab > a { background: var(--accent-soft); box-shadow: inset 2px 0 0 var(--accent-line); }
/* beside the list there is no card edge to bleed to: table sections and
tables fit the column */
.cbi-section-node-tabbed > [data-tab-title] :has(> .cbi-tabmenu) > .cbi-tabmenu + * .cbi-tblsection { margin-left: 0; margin-right: 0; padding-left: 0; padding-right: 0; }
.cbi-section-node-tabbed > [data-tab-title] :has(> .cbi-tabmenu) > .cbi-tabmenu + * .table { width: 100% !important; margin-left: 0; margin-right: 0; }
.cbi-section-node-tabbed > [data-tab-title] :has(> .cbi-tabmenu) > .cbi-tabmenu + * .table .tr > :is(.th, .td):first-child { padding-left: 0; }
.cbi-section-node-tabbed > [data-tab-title] :has(> .cbi-tabmenu) > .cbi-tabmenu + * .table .tr > :is(.th, .td):last-child { padding-right: 0; }
/* deeper levels: back to the chips above */
.cbi-section-node-tabbed > [data-tab-title] .cbi-section-node-tabbed > [data-tab-title] :has(> .cbi-tabmenu) { display: block; }
.cbi-section-node-tabbed > [data-tab-title] .cbi-section-node-tabbed > [data-tab-title] .cbi-tabmenu {
flex-direction: row; flex-wrap: wrap; align-items: center; gap: var(--sp-2);
margin: 0 0 var(--sp-4);
}
.cbi-section-node-tabbed > [data-tab-title] .cbi-section-node-tabbed > [data-tab-title] .cbi-tabmenu > li > a {
justify-content: normal;
padding: 0 14px;
border: 1px solid var(--line-2); border-radius: 999px;
white-space: nowrap; line-height: inherit;
}
.cbi-section-node-tabbed > [data-tab-title] .cbi-section-node-tabbed > [data-tab-title] .cbi-tabmenu > li > a:hover { background: none; }
.cbi-section-node-tabbed > [data-tab-title] .cbi-section-node-tabbed > [data-tab-title] .cbi-tabmenu > li.cbi-tab > a { border-color: var(--accent-line); box-shadow: none; }
}
.cbi-tabmenu > li[data-errors] > a::after { .cbi-tabmenu > li[data-errors] > a::after {
content: attr(data-errors); content: attr(data-errors);
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
min-width: 18px; height: 18px; padding: 0 5px; min-width: 18px; height: 18px; padding: 0 5px;
border-radius: 999px; border-radius: 999px;
background: var(--err); color: #fff; background: var(--err); color: var(--err-ink);
font: 700 var(--fs-micro)/1 var(--mono); font: 700 var(--fs-micro)/1 var(--mono);
} }
.cbi-tabmenu > li.cbi-tab-disabled[data-errors] > a { color: var(--err); } .cbi-tabmenu > li.cbi-tab-disabled[data-errors] > a { color: var(--err); }
@@ -1554,21 +1533,27 @@ html:not(:has(.vt-tabs:not([hidden]))) #view .cbi-tabmenu:not(.cbi-section *, [d
/* "<strong>Label:</strong> value<br>" dumps (LuCI itemlist) -> aligned /* "<strong>Label:</strong> value<br>" dumps (LuCI itemlist) -> aligned
label/value grid. Each span.nowrap becomes a subgrid row: the <strong> label/value grid. Each span.nowrap becomes a subgrid row: the <strong>
is the label cell, the contiguous value run the anonymous value cell. */ is the label cell, the contiguous value run the anonymous value cell. */
/* LuCI's " | " separators between the items are bare text, so each would
be a grid row of its own: no text size, line or row gap for them; the
items keep their own size and space */
:is(.ifacebox-body, .ifacebadge) > span:has(> .nowrap) { :is(.ifacebox-body, .ifacebadge) > span:has(> .nowrap) {
display: grid; display: grid;
grid-template-columns: max-content minmax(0, 1fr); grid-template-columns: max-content minmax(0, 1fr);
column-gap: 14px; row-gap: 3px; column-gap: 14px; row-gap: 0;
align-items: baseline; align-items: baseline;
min-width: 0; min-width: 0;
white-space: normal; white-space: normal;
text-align: left; text-align: left;
font-size: 0; line-height: 0;
} }
:is(.ifacebox-body, .ifacebadge) > span:has(> .nowrap) > br { display: none; } :is(.ifacebox-body, .ifacebadge) > span:has(> .nowrap) > br { display: none; }
:is(.ifacebox-body, .ifacebadge) > span:has(> .nowrap) > :not(.nowrap) { font-size: var(--fs-sm); line-height: 1.55; }
:is(.ifacebox-body, .ifacebadge) > span:has(> .nowrap) > .nowrap { :is(.ifacebox-body, .ifacebadge) > span:has(> .nowrap) > .nowrap {
grid-column: 1 / -1; grid-column: 1 / -1;
display: grid; grid-template-columns: subgrid; display: grid; grid-template-columns: subgrid;
align-items: baseline; align-items: baseline;
min-width: 0; min-width: 0;
margin-block: 1.5px;
white-space: normal; overflow-wrap: anywhere; white-space: normal; overflow-wrap: anywhere;
color: var(--text); color: var(--text);
font: 400 var(--fs-sm)/1.55 var(--mono); font: 400 var(--fs-sm)/1.55 var(--mono);
@@ -1680,7 +1665,8 @@ html:not(:has(.vt-tabs:not([hidden]))) #view .cbi-tabmenu:not(.cbi-section *, [d
.zonebadge[style*="--zone-color-rgb"] { box-shadow: inset 3px 0 0 rgb(var(--zone-color-rgb)); padding-left: 10px; } .zonebadge[style*="--zone-color-rgb"] { box-shadow: inset 3px 0 0 rgb(var(--zone-color-rgb)); padding-left: 10px; }
.ifacebox-head[style*="--zone-color-rgb"] { box-shadow: inset 0 3px 0 rgb(var(--zone-color-rgb)); border-bottom-color: rgba(var(--zone-color-rgb), .4) !important; } .ifacebox-head[style*="--zone-color-rgb"] { box-shadow: inset 0 3px 0 rgb(var(--zone-color-rgb)); border-bottom-color: rgba(var(--zone-color-rgb), .4) !important; }
.ifacebox-head[style*="background-color:#"], .ifacebox-head[style*="background-color: #"] { color: #111 !important; } .ifacebox-head[style*="background-color:#"], .ifacebox-head[style*="background-color: #"] { color: #111 !important; }
.zonebadge > em { font-style: normal; color: var(--text-3); } /* muted, but on the zone tint: --text-3 fell to 3.1:1 there in dark mode */
.zonebadge > em { font-style: normal; color: color-mix(in srgb, var(--text) 78%, transparent); }
.zonebadge .ifacebadge { padding: 0 4px; border: 0; background: transparent; } .zonebadge .ifacebadge { padding: 0 4px; border: 0; background: transparent; }
.zone-forwards { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; } .zone-forwards { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
.zone-forwards > span { color: var(--text-3); } .zone-forwards > span { color: var(--text-3); }
@@ -1774,9 +1760,9 @@ html:not(:has(.vt-tabs:not([hidden]))) #view .cbi-tabmenu:not(.cbi-section *, [d
transition: opacity .15s; transition: opacity .15s;
pointer-events: none; pointer-events: none;
} }
.cbi-tooltip.error { background: var(--err); border-color: var(--err); color: #fff; } .cbi-tooltip.error { background: var(--err); border-color: var(--err); color: var(--err-ink); }
.cbi-tooltip.warning { background: var(--warn); border-color: var(--warn); color: #1a1200; } .cbi-tooltip.warning { background: var(--warn); border-color: var(--warn); color: #1a1200; }
.cbi-tooltip.info { background: var(--info); border-color: var(--info); color: #fff; } .cbi-tooltip.info { background: var(--info); border-color: var(--info); color: var(--info-ink); }
.cbi-tooltip-container { position: relative; cursor: help; } .cbi-tooltip-container { position: relative; cursor: help; }
.cbi-tooltip-container > .cbi-tooltip { left: -10000px; top: auto; margin-top: 6px; white-space: normal; } .cbi-tooltip-container > .cbi-tooltip { left: -10000px; top: auto; margin-top: 6px; white-space: normal; }
.cbi-tooltip-container:hover > .cbi-tooltip { left: 0; top: 100%; opacity: 1; } .cbi-tooltip-container:hover > .cbi-tooltip { left: 0; top: 100%; opacity: 1; }
@@ -1813,6 +1799,10 @@ body.modal-overlay-active #modal_overlay { display: block; }
} }
#modal_overlay > .modal.cbi-modal { width: min(960px, 100%); } #modal_overlay > .modal.cbi-modal { width: min(960px, 100%); }
#modal_overlay > .modal .cbi-section { background: none; border: 0; padding: 0; margin: 0; box-shadow: none; } #modal_overlay > .modal .cbi-section { background: none; border: 0; padding: 0; margin: 0; box-shadow: none; }
/* a modal's sections have no card padding, so nothing in them bleeds out
by it: tables keep the modal's width (a bleed scrolled the section
sideways) and their first and last cells line up with the fields */
#modal_overlay > .modal .cbi-section { --card-px: 0px; --card-py: 0px; }
#modal_overlay > .modal .cbi-section > h3:first-child { margin: 0 0 var(--sp-3); padding: 0; min-height: 0; border: 0; } #modal_overlay > .modal .cbi-section > h3:first-child { margin: 0 0 var(--sp-3); padding: 0; min-height: 0; border: 0; }
.uci-dialog > .cbi-section > br, .uci-change-list > br:last-child { display: none; } .uci-dialog > .cbi-section > br, .uci-change-list > br:last-child { display: none; }
.uci-dialog > .cbi-section > strong { display: block; margin-bottom: var(--sp-2); color: var(--text-3); font-size: var(--fs-micro); letter-spacing: .08em; text-transform: uppercase; } .uci-dialog > .cbi-section > strong { display: block; margin-bottom: var(--sp-2); color: var(--text-3); font-size: var(--fs-micro); letter-spacing: .08em; text-transform: uppercase; }
@@ -2215,7 +2205,12 @@ html:not([data-darkmode="true"]) .vt-login-page::before {
color: var(--text-3); color: var(--text-3);
font-size: var(--fs-micro); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; font-size: var(--fs-micro); font-weight: 600; letter-spacing: .08em; text-transform: uppercase;
} }
.vt-live-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-text); animation: vt-live 2s ease-out infinite; } .vt-login-sub { isolation: isolate; }
/* the ring as on the poll pill: behind the dot (the dot itself makes no
stacking context, so z-index -1 lands under its background), opacity 0
unless animated */
.vt-login-dot { position: relative; width: 6px; height: 6px; border-radius: 50%; background: var(--accent-text); }
.vt-login-dot::after { content: ""; position: absolute; z-index: -1; inset: 0; border-radius: 50%; background: var(--accent-line); opacity: 0; animation: vt-live 2s ease-out infinite; }
.vt-login-error { .vt-login-error {
margin: 0 0 var(--sp-4); margin: 0 0 var(--sp-4);
padding: var(--sp-2) var(--sp-3); padding: var(--sp-2) var(--sp-3);
@@ -2229,7 +2224,7 @@ html:not([data-darkmode="true"]) .vt-login-page::before {
.vt-field { display: flex; flex-direction: column; gap: 6px; } .vt-field { display: flex; flex-direction: column; gap: 6px; }
.vt-field-label { color: var(--text-3); font-size: var(--fs-micro); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; } .vt-field-label { color: var(--text-3); font-size: var(--fs-micro); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
.vt-field input { .vt-field input {
width: 100%; height: 38px; width: 100%; height: var(--ctl-h);
padding: 0 12px; padding: 0 12px;
background: var(--s2); background: var(--s2);
color: var(--text); color: var(--text);
@@ -2238,7 +2233,7 @@ html:not([data-darkmode="true"]) .vt-login-page::before {
font-size: var(--fs-md); font-size: var(--fs-md);
} }
.vt-field input[name="luci_username"] { font-family: var(--mono); } .vt-field input[name="luci_username"] { font-family: var(--mono); }
.vt-login-submit { height: 38px; margin-top: var(--sp-2); font-size: var(--fs-md) !important; width: 100%; } .vt-login-submit { height: var(--ctl-h); margin-top: var(--sp-2); font-size: var(--fs-md) !important; width: 100%; }
.vt-login-foot { margin: var(--sp-4) 0 0; text-align: center; color: var(--text-3); font: var(--fs-micro)/1.4 var(--mono); letter-spacing: .03em; } .vt-login-foot { margin: var(--sp-4) 0 0; text-align: center; color: var(--text-3); font: var(--fs-micro)/1.4 var(--mono); letter-spacing: .03em; }
/* ------------------------------------------------ 9. stock page layouts */ /* ------------------------------------------------ 9. stock page layouts */
@@ -2359,9 +2354,19 @@ html:not([data-darkmode="true"]) .vt-login-page::before {
a non-scrolling ancestor, so status tables only scroll when narrow. */ a non-scrolling ancestor, so status tables only scroll when narrow. */
.cbi-tblsection, .cbi-section:has(> .cbi-tblsection) { overflow-x: auto; } .cbi-tblsection, .cbi-section:has(> .cbi-tblsection) { overflow-x: auto; }
.cbi-section:has(.cbi-dropdown[open]), .cbi-tblsection:has(.cbi-dropdown[open]) { overflow: visible; } .cbi-section:has(.cbi-dropdown[open]), .cbi-tblsection:has(.cbi-dropdown[open]) { overflow: visible; }
@media (max-width: 1180px) and (min-width: 721px) { @media (720px < width <= 1180px) {
.cbi-section { overflow-x: auto; } .cbi-section { overflow-x: auto; }
.cbi-section:has(.cbi-dropdown[open]) { overflow: visible; } .cbi-section:has(.cbi-dropdown[open]) { overflow: visible; }
/* a free-standing table card (Processes, Wireless's stations) has no
section to scroll in: the card itself scrolls, its rows held in one
table box (LuCI puts every row, titles too, in the tbody, so the
columns stay aligned), its header static as in sections */
:is(#view, #view > div, .cbi-map, #view > div > div > [data-tab-title]:not(.cbi-section)) > .table:not(:has(> thead)) {
display: block; overflow-x: auto;
}
:is(#view, #view > div, .cbi-map, #view > div > div > [data-tab-title]:not(.cbi-section)) > .table:not(:has(> thead)) > tbody { display: table; width: 100%; }
:is(#view, #view > div, .cbi-map, #view > div > div > [data-tab-title]:not(.cbi-section)) > .table:has(.cbi-dropdown[open]) { overflow: visible; }
:is(#view, #view > div, .cbi-map, #view > div > div > [data-tab-title]:not(.cbi-section)) > .table .tr > .th.th { position: static; }
} }
@@ -2370,6 +2375,18 @@ html:not([data-darkmode="true"]) .vt-login-page::before {
.cbi-tblsection .table .tr > .th.th, .cbi-section:has(> .cbi-tblsection) .table .tr > .th.th { position: static; } .cbi-tblsection .table .tr > .th.th, .cbi-section:has(> .cbi-tblsection) .table .tr > .th.th { position: static; }
@media (max-width: 1180px) { .cbi-section .table .tr > .th.th { position: static; } } @media (max-width: 1180px) { .cbi-section .table .tr > .th.th { position: static; } }
/* add-ons: adblock and banIP inject status palettes keyed on the OS
colour scheme (@media prefers-color-scheme), so with the theme toggle
set the other way their text fell to 1.7-3:1. Their own two palettes,
picked by the toggle instead (html[data-darkmode] outweighs their #id
rules); nothing else of theirs is touched. */
html[data-darkmode="false"] #adb-status { --adb-ok: #1f8a5f; --adb-warn: #a8760a; --adb-err: #c0392b; --adb-info: #2f6fb0; }
html[data-darkmode="true"] #adb-status { --adb-ok: #63c79b; --adb-warn: #d9ab4e; --adb-err: #e8897e; --adb-info: #7fb3e8; }
html[data-darkmode="false"] #adb-dash { --adb-blocked: #c0392b; --adb-allowed: #1f8a5f; --adb-neutral: #2f6fb0; --adb-warn: #a8760a; }
html[data-darkmode="true"] #adb-dash { --adb-blocked: #e8897e; --adb-allowed: #63c79b; --adb-neutral: #7fb3e8; --adb-warn: #d9ab4e; }
html[data-darkmode="false"] :is(#ban-status, #ban-report) { --ban-ok: #1f8a5f; --ban-err: #c0392b; --ban-info: #2f6fb0; }
html[data-darkmode="true"] :is(#ban-status, #ban-report) { --ban-ok: #63c79b; --ban-err: #e8897e; --ban-info: #7fb3e8; }
/* ----------------------------------------------------- responsive */ /* ----------------------------------------------------- responsive */
@media (max-width: 1100px) { @media (max-width: 1100px) {
@@ -2380,6 +2397,13 @@ html:not([data-darkmode="true"]) .vt-login-page::before {
@media (max-width: 900px) { @media (max-width: 900px) {
.vt-host { display: none !important; } .vt-host { display: none !important; }
/* indicators go compact (dot, count) before the crumbs get cut; the
label stays as text for screen readers and as the title */
#indicators > span[data-indicator] { height: 28px; padding: 0 8px; }
#indicators > span[data-indicator="poll-status"] { font-size: 0; gap: 0; width: 28px; justify-content: center; padding: 0; }
#indicators > span[data-indicator="poll-status"][data-style="active"]::after { left: calc(50% - 3.5px); }
#indicators > span[data-indicator][data-count] { padding: 0 3px; gap: 0; }
#indicators > span[data-indicator][data-count]::before { content: none; }
html[data-rail="pinned"] .vt-rail { width: var(--rail-w); } html[data-rail="pinned"] .vt-rail { width: var(--rail-w); }
html[data-rail="pinned"] .vt-frame { margin-left: var(--rail-w); } html[data-rail="pinned"] .vt-frame { margin-left: var(--rail-w); }
html[data-rail="pinned"] body.vt-auth .vt-shell::before { width: var(--rail-w); } html[data-rail="pinned"] body.vt-auth .vt-shell::before { width: var(--rail-w); }
@@ -2405,22 +2429,29 @@ html:not([data-darkmode="true"]) .vt-login-page::before {
.vt-rail > .vt-brand, .vt-rail-foot { display: none; } .vt-rail > .vt-brand, .vt-rail-foot { display: none; }
body.vt-auth .vt-shell::before { display: none; } body.vt-auth .vt-shell::before { display: none; }
.vt-rail-list { .vt-rail-list {
flex-direction: row; justify-content: space-around; flex-direction: row; justify-content: flex-start;
gap: 0; padding: 0 var(--sp-1); gap: 0; padding: 0 var(--sp-1);
overflow-x: auto; overflow-y: hidden; overflow-x: auto; overflow-y: hidden;
} }
.vt-rail-item { flex: 1 0 60px; padding: 6px 2px; } /* the buttons stay a centred group on wider screens. Centred with auto
margins, not justify-content: center, which would push an overflowing
list's first items past the scroll start where nothing reaches them;
auto margins drop to 0 when the list overflows. */
.vt-rail-item { flex: 1 0 60px; max-width: 104px; padding: 6px 2px; }
.vt-rail-item:first-child { margin-inline-start: auto; }
.vt-rail-item:last-child { margin-inline-end: auto; }
.vt-rail-link, html[data-rail="pinned"] .vt-rail-link { .vt-rail-link, html[data-rail="pinned"] .vt-rail-link {
flex-direction: column; justify-content: center; gap: 3px; flex-direction: column; justify-content: center; gap: 3px;
height: 48px; padding: 0 4px; height: 48px; padding: 0 4px;
} }
.vt-rail-link .vt-rail-label, html[data-rail="pinned"] .vt-rail-link .vt-rail-label { .vt-rail-link .vt-rail-label, html[data-rail="pinned"] .vt-rail-link .vt-rail-label {
display: block !important; display: block !important;
flex: none; /* the rail's label grows; here it would push the icon up */
max-width: 100%; max-width: 100%;
font-size: var(--fs-micro); font-weight: 500; font-size: var(--fs-micro); font-weight: 500;
text-align: center; text-align: center;
} }
.vt-rail-item.is-active > .vt-rail-link::before { left: 14px; right: 14px; top: -6px; bottom: auto; width: auto; height: 2px; border-radius: 0 0 2px 2px; } .vt-rail-item.is-active > .vt-rail-link::before { left: calc(50% - 10px); top: -6px; bottom: auto; width: 20px; height: 3px; border-radius: 0 0 3px 3px; }
.vt-frame, html[data-rail="pinned"] .vt-frame { margin-left: 0; padding-bottom: calc(var(--bar-h) + env(safe-area-inset-bottom)); } .vt-frame, html[data-rail="pinned"] .vt-frame { margin-left: 0; padding-bottom: calc(var(--bar-h) + env(safe-area-inset-bottom)); }
.vt-flyout { .vt-flyout {
@@ -2447,11 +2478,8 @@ html:not([data-darkmode="true"]) .vt-login-page::before {
.vt-crumbs .vt-crumb-current { font-size: var(--fs-md); } .vt-crumbs .vt-crumb-current { font-size: var(--fs-md); }
.vt-top-actions { gap: 2px; } .vt-top-actions { gap: 2px; }
.vt-search { border-color: transparent; background: none; color: var(--text-2); width: 32px; height: 32px; } .vt-search { border-color: transparent; background: none; color: var(--text-2); width: 32px; height: 32px; }
#indicators > span[data-indicator] { height: 28px; padding: 0 8px; } .vt-tabs { padding: var(--sp-4) var(--sp-4) 0; }
#indicators > span[data-indicator="poll-status"] { font-size: 0; gap: 0; width: 28px; justify-content: center; padding: 0; } .vt-tabs:not([hidden]) + .vt-main { padding-top: var(--sp-4); }
#indicators > span[data-indicator][data-count] { padding: 0 3px; gap: 0; }
#indicators > span[data-indicator][data-count]::before { content: none; }
.vt-tabs { padding: 0 var(--sp-2); }
.vt-main { padding: var(--sp-4); } .vt-main { padding: var(--sp-4); }
.vt-foot { padding-bottom: var(--sp-4); } .vt-foot { padding-bottom: var(--sp-4); }
@@ -2462,7 +2490,14 @@ html:not([data-darkmode="true"]) .vt-login-page::before {
width: auto; width: auto;
margin: var(--sp-4) 0 0; margin: var(--sp-4) 0 0;
} }
.cbi-page-actions > * { flex: 1 1 auto; } /* one row: the buttons share the bar from a zero basis, so the row only
breaks when their longest words no longer fit, and a long label
wraps inside its button instead of taking the bar to a second row */
.cbi-page-actions > *, .cbi-page-actions > form > * { flex: 1 1 0; }
.cbi-page-actions :is(.btn, .cbi-button, button, input[type="button"], input[type="submit"]):not(.cbi-dropdown) {
padding: 4px 8px;
white-space: normal; text-align: center; line-height: 1.15;
}
.cbi-section { overflow-x: visible; } .cbi-section { overflow-x: visible; }
.cbi-tblsection, .cbi-section:has(> .cbi-tblsection) { overflow-x: visible; } .cbi-tblsection, .cbi-section:has(> .cbi-tblsection) { overflow-x: visible; }
.network-status-table .ifacebox-head, .network-status-table .ifacebox-body { padding-left: 12px; padding-right: 12px; } .network-status-table .ifacebox-head, .network-status-table .ifacebox-body { padding-left: 12px; padding-right: 12px; }
@@ -2490,38 +2525,36 @@ html:not([data-darkmode="true"]) .vt-login-page::before {
.cbi-value:has(> .cbi-value-field > .cbi-checkbox) > .cbi-value-title { grid-row: 1; grid-column: 2; padding: 0; } .cbi-value:has(> .cbi-value-field > .cbi-checkbox) > .cbi-value-title { grid-row: 1; grid-column: 2; padding: 0; }
.cbi-value:has(> .cbi-value-field > .cbi-checkbox) > .vt-help { grid-row: 1; grid-column: 3; } .cbi-value:has(> .cbi-value-field > .cbi-checkbox) > .vt-help { grid-row: 1; grid-column: 3; }
.cbi-value:has(> .cbi-value-field > .cbi-checkbox) > .cbi-value-field > :not(.cbi-checkbox) { grid-column: 1 / -1; } .cbi-value:has(> .cbi-value-field > .cbi-checkbox) > .cbi-value-field > :not(.cbi-checkbox) { grid-column: 1 / -1; }
/* a segmented control wraps its segments instead of hiding them */ /* a segmented control spans the width and its segments share each row */
.cbi-tabmenu:not(.cbi-section-node-tabbed > [data-tab-title] *) { flex-wrap: wrap; width: auto; } .cbi-tabmenu, .vt-tabs > .vt-pagetabs { width: auto; }
.cbi-tabmenu:not(.cbi-section-node-tabbed > [data-tab-title] *) > li { flex: 1 1 auto; } .cbi-tabmenu > li, .vt-pagetabs > .vt-tab { flex: 1 1 auto; }
.cbi-tabmenu:not(.cbi-section-node-tabbed > [data-tab-title] *) > li > a { justify-content: center; } .cbi-tabmenu > li > a, .vt-pagetabs > .vt-tab { justify-content: center; }
/* ... while an underline strip scrolls, like the page tabs */
html:not(:has(.vt-tabs:not([hidden]))) #view .cbi-tabmenu:not(.cbi-section *, [data-tab-title] *) { flex-wrap: nowrap; }
html:not(:has(.vt-tabs:not([hidden]))) #view .cbi-tabmenu:not(.cbi-section *, [data-tab-title] *) > li { flex: none; }
html:not(:has(.vt-tabs:not([hidden]))) #view .cbi-tabmenu:not(.cbi-section *, [data-tab-title] *) > li > a { justify-content: normal; }
.cbi-dropdown, .vt-main input, .vt-main select, .control-group, .cbi-dynlist { max-width: none; } .cbi-dropdown, .vt-main input, .vt-main select, .control-group, .cbi-dynlist { max-width: none; }
/* tables become cards */ /* tables become cards, in pages and in modals alike. Header rows go
(LuCI also puts .cbi-section-table-titles on each row's name cell,
which stays); every cell drops the fixed row height. */
.hide-xs { display: none !important; } .hide-xs { display: none !important; }
.vt-main .table, .vt-main .table > tbody, .vt-main .table > thead, .modal .table, .modal .table > tbody { display: block; width: 100%; } :is(.vt-main, .modal) .table, :is(.vt-main, .modal) .table > :is(tbody, thead) { display: block; width: 100%; }
.vt-main .table > .tr.table-titles, .vt-main .table > thead > .tr.table-titles, .vt-main .table > tbody > .tr.table-titles, :is(.vt-main, .modal) .table :is(tr, .tr):is(.table-titles, .cbi-section-table-titles, .cbi-section-table-descr) { display: none !important; }
.vt-main .cbi-section-table-titles, .vt-main .cbi-section-table-descr, .modal .table-titles { display: none !important; } :is(.vt-main, .modal) .table .tr {
.vt-main .table .tr, .modal .table .tr {
display: flex; flex-wrap: wrap; display: flex; flex-wrap: wrap;
padding: 6px 0; padding: 6px 0;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.vt-main .table .tr:last-child { border-bottom: 0; } :is(.vt-main, .modal) .table .tr:last-child { border-bottom: 0; }
.vt-main .table .tr > .td, .modal .table .tr > .td { :is(.vt-main, .modal) .table .tr > .td {
display: block; display: block;
flex: 1 1 100%; flex: 1 1 100%;
min-width: 0; min-width: 0;
width: auto !important; width: auto !important;
height: auto;
padding: 4px 10px; padding: 4px 10px;
border: 0; border: 0;
text-align: left !important; text-align: left !important;
} }
.vt-main :is(.cbi-section, .cbi-value) .table .tr > .td:nth-child(n) { padding: 4px var(--card-px); height: auto; } :is(.vt-main, .modal) :is(.cbi-section, .cbi-value) .table .tr > .td:nth-child(n) { padding: 4px var(--card-px); }
.vt-main .table .tr > .td[data-title]::before, .modal .table .tr > .td[data-title]::before { :is(.vt-main, .modal) .table .tr > .td[data-title]::before {
content: attr(data-title); content: attr(data-title);
display: block; display: block;
margin-bottom: 2px; margin-bottom: 2px;
@@ -2529,18 +2562,20 @@ html:not([data-darkmode="true"]) .vt-login-page::before {
color: var(--text-3); color: var(--text-3);
font-size: var(--fs-micro); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; font-size: var(--fs-micro); font-weight: 600; letter-spacing: .08em; text-transform: uppercase;
} }
.vt-main .table:not(:has(.table-titles)) .tr > .td:first-child:nth-last-child(2) { :is(.vt-main, .modal) .table:not(:has(.table-titles)) .tr > .td:first-child:nth-last-child(2) {
width: auto !important; padding-bottom: 0; width: auto !important; padding-bottom: 0;
color: var(--text-3); font-size: var(--fs-micro); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--text-3); font-size: var(--fs-micro); font-weight: 600; letter-spacing: .08em; text-transform: uppercase;
} }
.vt-main .table .tr > .td[data-title]:not(:only-child) { flex-basis: 50%; } :is(.vt-main, .modal) .table .tr > .td[data-title]:not(:only-child) { flex-basis: 50%; }
.vt-main .table .tr > .td[data-title]:last-child:nth-child(odd) { flex-basis: 100%; } :is(.vt-main, .modal) .table .tr > .td[data-title]:last-child:nth-child(odd) { flex-basis: 100%; }
.vt-main .table .tr > .td.cbi-section-actions { flex-basis: 100%; text-align: left !important; } :is(.vt-main, .modal) .table .tr > .td.cbi-section-actions { flex-basis: 100%; text-align: left !important; }
.vt-main .table .tr > .td[data-name="_ifacebox"], .vt-main .table .tr > .td[data-name="_ifacestat"] { flex-basis: 100%; } /* a named grid section's name cell heads its card */
:is(.vt-main, .modal) .table .tr > .td.cbi-section-table-titles { flex-basis: 100%; color: var(--text); font-weight: 600; }
:is(.vt-main, .modal) .table .tr > .td:is([data-name="_ifacebox"], [data-name="_ifacestat"]) { flex-basis: 100%; }
.cbi-section-table .td[data-name="_ifacebox"] .ifacebox { width: 100%; } .cbi-section-table .td[data-name="_ifacebox"] .ifacebox { width: 100%; }
.vt-main .table .tr > .td.cbi-section-actions > div { flex-wrap: wrap; } :is(.vt-main, .modal) .table .tr > .td.cbi-section-actions > div { flex-wrap: wrap; }
.vt-main .table .tr > .td.cbi-section-actions::before { display: none !important; } :is(.vt-main, .modal) .table .tr > .td.cbi-section-actions::before { display: none !important; }
.vt-main .table .tr:hover > .td { background: none; } :is(.vt-main, .modal) .table .tr:hover > .td { background: none; }
.cbi-section .table, .cbi-section .cbi-section-table { border: 0; } .cbi-section .table, .cbi-section .cbi-section-table { border: 0; }
.cbi-progressbar { max-width: none; } .cbi-progressbar { max-width: none; }
.network-status-table > .ifacebox { max-width: none; } .network-status-table > .ifacebox { max-width: none; }
@@ -2563,6 +2598,10 @@ html:not([data-darkmode="true"]) .vt-login-page::before {
} }
@media (pointer: coarse) { @media (pointer: coarse) {
input[type="checkbox"], input[type="radio"] { width: 20px; height: 20px; } input[type="checkbox"], input[type="radio"] { width: 20px; height: 20px; }
/* top-bar buttons: a touch control high, at every width */
.vt-icon-btn, .vt-search { min-width: var(--ctl-h); height: var(--ctl-h); }
/* dropdown choices (Save & Apply's too) are touch targets */
.cbi-dropdown[open] > ul.dropdown > li { min-height: var(--ctl-h); }
.cbi-value { padding: 8px 0; } .cbi-value { padding: 8px 0; }
} }
@@ -2592,6 +2631,7 @@ html:not([data-darkmode="true"]) .vt-login-page::before {
/* ui.Dropdown moves focus on transitionend: keep a (near-instant) transition */ /* ui.Dropdown moves focus on transitionend: keep a (near-instant) transition */
.cbi-dropdown[open] > ul.dropdown { transition-duration: .01s; } .cbi-dropdown[open] > ul.dropdown { transition-duration: .01s; }
.cbi-progressbar > div { transition: none; } .cbi-progressbar > div { transition: none; }
.vt-crumb-switch > svg, .vt-rail-caret, .vt-rail-pin svg { transition: none; }
} }
@media print { @media print {
@@ -1,13 +1,13 @@
msgid "" msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8" msgstr "Content-Type: text/plain; charset=UTF-8"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:951 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1083
msgid "Actions" msgid "Actions"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:947 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1079
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:948 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1080
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:949 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1081
msgid "Appearance" msgid "Appearance"
msgstr "" msgstr ""
@@ -15,7 +15,7 @@ msgstr ""
msgid "Authorization Required" msgid "Authorization Required"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1125 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1260
msgid "Auto" msgid "Auto"
msgstr "" msgstr ""
@@ -23,52 +23,52 @@ msgstr ""
msgid "Breadcrumb" msgid "Breadcrumb"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:269 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:277
msgid "Buffered" msgid "Buffered"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:280 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:288
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:290 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:298
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:294 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:302
msgid "Cache" msgid "Cache"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:269 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:277
msgid "Cached" msgid "Cached"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:350 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:358
msgid "Channel" msgid "Channel"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:719 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:734
msgid "Collapse navigation" msgid "Collapse navigation"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1126 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1261
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:81 #: luci-theme-vantage/ucode/template/themes/vantage/header.ut:81
msgid "Colour theme" msgid "Colour theme"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:967 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1102
msgid "Command palette" msgid "Command palette"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1125 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1260
msgid "Dark" msgid "Dark"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:719 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:734
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:54 #: luci-theme-vantage/ucode/template/themes/vantage/header.ut:54
msgid "Expand navigation" msgid "Expand navigation"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:949 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1081
msgid "Follow system theme" msgid "Follow system theme"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:281 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:289
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:295 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:303
msgid "Free" msgid "Free"
msgstr "" msgstr ""
@@ -76,9 +76,9 @@ msgstr ""
msgid "Home" msgid "Home"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:280 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:288
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:289 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:297
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:293 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:301
msgid "In use" msgid "In use"
msgstr "" msgstr ""
@@ -86,11 +86,11 @@ msgstr ""
msgid "Invalid username and/or password! Please try again." msgid "Invalid username and/or password! Please try again."
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:963 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1098
msgid "Jump to a page or action…" msgid "Jump to a page or action…"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1125 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1260
msgid "Light" msgid "Light"
msgstr "" msgstr ""
@@ -99,7 +99,7 @@ msgstr ""
msgid "Log in" msgid "Log in"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:950 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1082
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:84 #: luci-theme-vantage/ucode/template/themes/vantage/header.ut:84
msgid "Log out" msgid "Log out"
msgstr "" msgstr ""
@@ -108,15 +108,19 @@ msgstr ""
msgid "Main navigation" msgid "Main navigation"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:264 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:272
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:279 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:287
msgid "Memory" msgid "Memory"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1038 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1173
msgid "No matching pages" msgid "No matching pages"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1009
msgid "Other pages in %s"
msgstr ""
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:90 #: luci-theme-vantage/ucode/template/themes/vantage/header.ut:90
msgid "Page tabs" msgid "Page tabs"
msgstr "" msgstr ""
@@ -125,32 +129,33 @@ msgstr ""
msgid "Password" msgid "Password"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:825 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:883
msgid "Press" msgid "Press"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:965 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1100
msgid "Results" msgid "Results"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:963 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1098
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:72
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:74 #: luci-theme-vantage/ucode/template/themes/vantage/header.ut:74
msgid "Search pages" msgid "Search pages"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:950 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1082
msgid "Session" msgid "Session"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1283 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1418
msgid "Show full description" msgid "Show full description"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1272 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1407
msgid "Show full help" msgid "Show full help"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1272 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1407
msgid "Show full help: %s" msgid "Show full help: %s"
msgstr "" msgstr ""
@@ -158,36 +163,36 @@ msgstr ""
msgid "Skip to content" msgid "Skip to content"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:316 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:324
msgid "Storage" msgid "Storage"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:302 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:310
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:304 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:312
msgid "Swap" msgid "Swap"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:269 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:277
msgid "Swap free" msgid "Swap free"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:281 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:289
msgid "Total" msgid "Total"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:268 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:276
msgid "Total Available" msgid "Total Available"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:947 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1079
msgid "Use dark theme" msgid "Use dark theme"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:948 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1080
msgid "Use light theme" msgid "Use light theme"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:268 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:276
msgid "Used" msgid "Used"
msgstr "" msgstr ""
@@ -195,54 +200,56 @@ msgstr ""
msgid "Username" msgid "Username"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:343 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:351
msgid "Wireless" msgid "Wireless"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:285 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:293
msgid "available" msgid "available"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:973 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1108
msgid "close" msgid "close"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:331 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:339
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:334 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:342
msgid "free" msgid "free"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1040 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1175
msgid "items" msgid "items"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:971 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1106
msgid "navigate" msgid "navigate"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:972 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1107
msgid "open" msgid "open"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:805 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:863
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:937
msgid "page" msgid "page"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:805 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:863
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:937
msgid "pages" msgid "pages"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:280 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:288
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:290 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:298
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:294 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:302
msgid "reclaimable" msgid "reclaimable"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:825 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:883
msgid "to search all pages" msgid "to search all pages"
msgstr "" msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:331 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:339
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:334 #: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:342
msgid "used" msgid "used"
msgstr "" msgstr ""
@@ -78,7 +78,7 @@
<nav class="vt-crumbs" id="vt-crumbs" aria-label="{{ entityencode(_('Breadcrumb'), true) }}"><span class="vt-crumb vt-crumb-current">{{ entityencode(page_title, true) }}</span></nav> <nav class="vt-crumbs" id="vt-crumbs" aria-label="{{ entityencode(_('Breadcrumb'), true) }}"><span class="vt-crumb vt-crumb-current">{{ entityencode(page_title, true) }}</span></nav>
<div class="vt-top-actions"> <div class="vt-top-actions">
{% if (authed): %} {% if (authed): %}
<button type="button" class="vt-search" id="vt-search-btn" aria-haspopup="dialog" aria-keyshortcuts="Control+K /"> <button type="button" class="vt-search" id="vt-search-btn" aria-haspopup="dialog" aria-keyshortcuts="Control+K /" aria-label="{{ entityencode(_('Search pages'), true) }}">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4.5 4.5"/></svg> <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4.5 4.5"/></svg>
<span class="vt-search-label">{{ entityencode(_('Search pages'), true) }}</span> <span class="vt-search-label">{{ entityencode(_('Search pages'), true) }}</span>
<kbd class="vt-kbd">Ctrl K</kbd> <kbd class="vt-kbd">Ctrl K</kbd>
@@ -49,7 +49,7 @@
</svg> </svg>
<div> <div>
<h1 class="vt-login-title">{{ hostname }}</h1> <h1 class="vt-login-title">{{ hostname }}</h1>
<p class="vt-login-sub"><span class="vt-live-dot" aria-hidden="true"></span>{{ entityencode(_('Authorization Required'), true) }}</p> <p class="vt-login-sub"><span class="vt-login-dot" aria-hidden="true"></span>{{ entityencode(_('Authorization Required'), true) }}</p>
</div> </div>
</div> </div>
+2 -2
View File
@@ -14,8 +14,8 @@ MISE_HASH_x86_64:=3832f39c325e343f81fe3d92b2447c5d1a5eea1bc85092bb7b6c2580622264
MISE_HASH_aarch64:=06186cfbfe947049b21d58575fb0ea800cc26ed1375f20f4b678cb3a9d679437 MISE_HASH_aarch64:=06186cfbfe947049b21d58575fb0ea800cc26ed1375f20f4b678cb3a9d679437
PKG_NAME:=mise PKG_NAME:=mise
PKG_VERSION:=2026.10.8 PKG_VERSION:=2026.10.9
PKG_RELEASE:=33 PKG_RELEASE:=34
PKG_SOURCE:=$(PKG_NAME)-v$(PKG_VERSION)-linux-$(MISE_ARCH)-musl.tar.gz PKG_SOURCE:=$(PKG_NAME)-v$(PKG_VERSION)-linux-$(MISE_ARCH)-musl.tar.gz
PKG_SOURCE_URL:=https://github.com/jdx/mise/releases/download/v$(PKG_VERSION)/ PKG_SOURCE_URL:=https://github.com/jdx/mise/releases/download/v$(PKG_VERSION)/
PKG_HASH:=skip PKG_HASH:=skip
+1 -1
View File
@@ -10,7 +10,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=mptcp PKG_NAME:=mptcp
PKG_VERSION:=6.1 PKG_VERSION:=6.1
PKG_RELEASE:=25 PKG_RELEASE:=26
PKG_MAINTAINER:=Ycarus (Yannick Chabanois) <ycarus@zugaina.org> PKG_MAINTAINER:=Ycarus (Yannick Chabanois) <ycarus@zugaina.org>
PKG_BUILD_DIR := $(BUILD_DIR)/$(PKG_NAME) PKG_BUILD_DIR := $(BUILD_DIR)/$(PKG_NAME)
+76 -14
View File
@@ -37,6 +37,21 @@ _mptcp_limit() {
esac esac
} }
# Persist the sysctl $1=$2 in zzz_openmptcprouter.conf. procd reloads mptcp on
# every interface event, and "sed -i" rewrote the file on flash each time,
# the value unchanged: it is only edited when a line of that key differs.
_sysctl_conf_set() {
local conf=/etc/sysctl.d/zzz_openmptcprouter.conf
if grep -q "^$1=" "$conf" 2>/dev/null && ! grep "^$1=" "$conf" | grep -qvx "$1=$2"; then
return 0
fi
if grep -q "$1" "$conf" 2>/dev/null; then
sed -i "s:^$1=[0-1]*:$1=$2:" "$conf"
else
echo "$1=$2" >> "$conf"
fi
}
restart_scheduler_dependent_services() { restart_scheduler_dependent_services() {
local proxy vpn local proxy vpn
proxy="$(uci -q get openmptcprouter.settings.proxy)" proxy="$(uci -q get openmptcprouter.settings.proxy)"
@@ -145,11 +160,7 @@ global_multipath_settings() {
# Global MPTCP configuration # Global MPTCP configuration
if [ -f /proc/sys/net/mptcp/mptcp_enabled ]; then if [ -f /proc/sys/net/mptcp/mptcp_enabled ]; then
sysctl -qew net.mptcp.mptcp_enabled="$multipath_status" sysctl -qew net.mptcp.mptcp_enabled="$multipath_status"
if grep -q net.mptcp.mptcp_enabled /etc/sysctl.d/zzz_openmptcprouter.conf; then _sysctl_conf_set net.mptcp.mptcp_enabled "$multipath_status"
sed -i "s:^net.mptcp.mptcp_enabled=[0-1]*:net.mptcp.mptcp_enabled=${multipath_status}:" /etc/sysctl.d/zzz_openmptcprouter.conf
else
echo "net.mptcp.mptcp_enabled=${multipath_status}" >> /etc/sysctl.d/zzz_openmptcprouter.conf
fi
[ -z "$mptcp_path_manager" ] || sysctl -qew net.mptcp.mptcp_path_manager="$mptcp_path_manager" [ -z "$mptcp_path_manager" ] || sysctl -qew net.mptcp.mptcp_path_manager="$mptcp_path_manager"
if [ -n "$mptcp_scheduler" ]; then if [ -n "$mptcp_scheduler" ]; then
local prev_sched="$(cat /proc/sys/net/mptcp/mptcp_scheduler 2>/dev/null)" local prev_sched="$(cat /proc/sys/net/mptcp/mptcp_scheduler 2>/dev/null)"
@@ -170,11 +181,7 @@ global_multipath_settings() {
[ -z "$mptcp_version" ] || sysctl -qew net.mptcp.mptcp_version="$mptcp_version" [ -z "$mptcp_version" ] || sysctl -qew net.mptcp.mptcp_version="$mptcp_version"
elif [ -f /proc/sys/net/mptcp/enabled ]; then elif [ -f /proc/sys/net/mptcp/enabled ]; then
sysctl -qew net.mptcp.enabled="$multipath_status" sysctl -qew net.mptcp.enabled="$multipath_status"
if grep -q net.mptcp.enabled /etc/sysctl.d/zzz_openmptcprouter.conf; then _sysctl_conf_set net.mptcp.enabled "$multipath_status"
sed -i "s:^net.mptcp.enabled=[0-1]*:net.mptcp.enabled=${multipath_status}:" /etc/sysctl.d/zzz_openmptcprouter.conf
else
echo "net.mptcp.enabled=${multipath_status}" >> /etc/sysctl.d/zzz_openmptcprouter.conf
fi
# The kernel takes at most 8 for each limit (MPTCP_PM_ADDR_MAX) and # The kernel takes at most 8 for each limit (MPTCP_PM_ADDR_MAX) and
# refuses the whole command when one is above, so a single value too # refuses the whole command when one is above, so a single value too
# high left both limits at whatever they were # high left both limits at whatever they were
@@ -282,7 +289,9 @@ interface_max_metric() {
EOF EOF
;; ;;
lan) lan)
sed -i 's:50 lan:9999 lan:g' /etc/iproute2/rt_tables # only when needed: this runs on every reload, sed -i writes flash
grep -q '50 lan' /etc/iproute2/rt_tables 2>/dev/null && \
sed -i 's:50 lan:9999 lan:g' /etc/iproute2/rt_tables
;; ;;
loopback) loopback)
;; ;;
@@ -380,8 +389,48 @@ _prune_wan_routes() {
done done
} }
# config_foreach callback: device_section is the "config device" section of
# the device $2
_find_device_section() {
local name
config_get name "$1" name
[ -n "$name" ] && [ "$name" = "$2" ] && device_section="$1"
}
# config_foreach callback: _multipath_dev_found is set when the interface $1,
# not $3, is in MPTCP (on, master or backup) on the device $2
_check_multipath_device() {
local sec="$1" dev="$2" self="$3" mode disabled sdev
[ "$sec" = "$self" ] && return 0
[ -n "$_multipath_dev_found" ] && return 0
case "$sec" in *_6) return 0 ;; esac
config_get disabled "$sec" disabled
[ "$disabled" = "1" ] && return 0
config_get mode "$sec" multipath
[ -n "$mode" ] || mode="$(uci -q get openmptcprouter.${sec}.multipath)"
case "$mode" in on|master|backup) ;; *) return 0 ;; esac
network_get_device sdev "$sec"
[ -z "$sdev" ] && network_get_physdev sdev "$sec"
[ -z "$sdev" ] && sdev=$(ifstatus "$sec" | jsonfilter -q -e '@["l3_device"]')
[ -z "$sdev" ] && config_get sdev "$sec" ifname
case "$sdev" in *@*) sdev=$(ifstatus "$sec" | jsonfilter -q -e '@["device"]') ;; esac
[ -n "$sdev" ] && [ "$sdev" = "$dev" ] && _multipath_dev_found=1
return 0
}
# Whether another interface than $2 has MPTCP on the device $1. A second
# interface on a WAN's device (a wan6 on @wan2, a static alias) is "off"
# itself, and turning MPTCP off for it removed the WAN's endpoints on every
# reload: until the WAN's own turn put them back, or for good when the WAN
# came first.
_device_used_by_multipath() {
_multipath_dev_found=""
config_foreach _check_multipath_device interface "$1" "$2"
[ -n "$_multipath_dev_found" ]
}
interface_multipath_settings() { interface_multipath_settings() {
local mode iface proto metric ip4table qdisc local mode iface proto metric ip4table qdisc device_section
local config="$1" local config="$1"
local intf="$2" local intf="$2"
# DHCPv6 companion interfaces (<intf>_6) share the parent's device: # DHCPv6 companion interfaces (<intf>_6) share the parent's device:
@@ -400,6 +449,17 @@ interface_multipath_settings() {
config_get txqueuelen "$config" txqueuelen config_get txqueuelen "$config" txqueuelen
config_get qdisc "$config" qdisc config_get qdisc "$config" qdisc
case "$iface" in *@*) iface=$(ifstatus "$config" | jsonfilter -q -e '@["device"]') ;; esac case "$iface" in *@*) iface=$(ifstatus "$config" | jsonfilter -q -e '@["device"]') ;; esac
# LuCI's device dialog writes the qdisc, the TX queue length and the
# offloads to the "config device" section of the device: only the
# interface's own options were read, so those were never applied and the
# queue length was put back to 1000 over the one netifd had set. The
# device's value wins, the interface's is the fallback.
device_section=""
[ -n "$iface" ] && config_foreach _find_device_section device "$iface"
[ -n "$device_section" ] && {
config_get txqueuelen "$device_section" txqueuelen "$txqueuelen"
config_get qdisc "$device_section" qdisc "$qdisc"
}
if [ "$(uci -q get openmptcprouter.${config}.metric)" = "" ] || [ "$(uci -q get openmptcprouter.${config}.metric)" = "1" ]; then if [ "$(uci -q get openmptcprouter.${config}.metric)" = "" ] || [ "$(uci -q get openmptcprouter.${config}.metric)" = "1" ]; then
count=$((count+1)) count=$((count+1))
metric=$count metric=$count
@@ -430,7 +490,8 @@ interface_multipath_settings() {
[ -n "$iface" ] && { [ -n "$iface" ] && {
local offload val local offload val
for offload in gro gso lro ufo tso; do for offload in gro gso lro ufo tso; do
val=$(uci -q get network.${config}.${offload}) config_get val "$config" "$offload"
[ -n "$device_section" ] && config_get val "$device_section" "$offload" "$val"
[ "$val" = "1" ] && ethtool -K $iface $offload on >/dev/null 2>&1 [ "$val" = "1" ] && ethtool -K $iface $offload on >/dev/null 2>&1
[ "$val" = "0" ] && ethtool -K $iface $offload off >/dev/null 2>&1 [ "$val" = "0" ] && ethtool -K $iface $offload off >/dev/null 2>&1
done done
@@ -787,7 +848,8 @@ interface_multipath_settings() {
elif ([ "$mode" = "master" ] || [ "$mode" = "on" ]) && { [ -z "$(multipath $iface | grep default)" ] || ! multipath -i "$iface" "$id"; }; then elif ([ "$mode" = "master" ] || [ "$mode" = "on" ]) && { [ -z "$(multipath $iface | grep default)" ] || ! multipath -i "$iface" "$id"; }; then
logger -t "MPTCP" "Set $iface to $mode from $(multipath $iface)" logger -t "MPTCP" "Set $iface to $mode from $(multipath $iface)"
multipath "$iface" "on" "$id" multipath "$iface" "on" "$id"
elif ([ "$mode" = "off" ] || [ -z "$mode" ]) && [ -z "$(multipath $iface | grep deactivated)" ]; then elif ([ "$mode" = "off" ] || [ -z "$mode" ]) && [ -z "$(multipath $iface | grep deactivated)" ] && \
! _device_used_by_multipath "$iface" "$config"; then
logger -t "MPTCP" "Set $iface to $mode from $(multipath $iface)" logger -t "MPTCP" "Set $iface to $mode from $(multipath $iface)"
multipath "$iface" "$mode" "$id" multipath "$iface" "$mode" "$id"
elif [ "$mode" = "backup" ] && { [ -z "$(multipath $iface | grep backup)" ] || ! multipath -i "$iface" "$id"; }; then elif [ "$mode" = "backup" ] && { [ -z "$(multipath $iface | grep backup)" ] || ! multipath -i "$iface" "$id"; }; then
+41 -5
View File
@@ -66,10 +66,45 @@ EOF
# endpoints only use a free slot, and a refused add is logged. # endpoints only use a free slot, and a refused add is logged.
MPTCP_ENDPOINTS_MAX=8 MPTCP_ENDPOINTS_MAX=8
# The id of the endpoint whose address is $2 in the listing $1. The address
# is compared as the whole first field: "grep -F '1.2.3.4 '" also matched
# 11.2.3.4, the endpoint of another WAN, which was then deleted.
endpoint_id_of_addr() {
printf '%s\n' "$1" | awk -v a="$2" '$1 == a {
for (i = 2; i < NF; i++) if ($i == "id") { print $(i + 1); exit }
}'
}
# Whether the device $2 has an endpoint with an id below $3 in the listing $1
_dev_has_lower_id() {
local listing="$1" dev="$2" below="$3" ep_line ep_id ep_dev
[ -n "$dev" ] || return 1
while IFS= read -r ep_line; do
ep_id=""
ep_dev=""
set -- $ep_line
while [ "$#" -gt 1 ]; do
case "$1" in
id) ep_id="$2" ;;
dev) ep_dev="$2" ;;
esac
shift
done
[ "$ep_dev" = "$dev" ] && [ -n "$ep_id" ] && [ "$ep_id" -lt "$below" ] && return 0
done <<EOF
$listing
EOF
return 1
}
# Return 0 when a slot is free, or was freed by removing the extra endpoint # Return 0 when a slot is free, or was freed by removing the extra endpoint
# with the highest id of another device # with the highest id of another device. An id of 128 or more alone does not
# make an extra endpoint: one the kernel picked (no id given, or the WAN's id
# was held by another device) can be the only endpoint of a WAN, which was
# then taken out of MPTCP. The device must keep an endpoint with a lower id.
_free_endpoint_slot() { _free_endpoint_slot() {
local line ep_addr ep_id ep_dev victim_id="" victim_addr="" victim_dev="" count=0 local line ep_addr ep_id ep_dev victim_id="" victim_addr="" victim_dev="" count=0 listing
listing="$(ip mptcp endpoint show)"
while IFS= read -r line; do while IFS= read -r line; do
[ -n "$line" ] || continue [ -n "$line" ] || continue
count=$((count + 1)) count=$((count + 1))
@@ -86,11 +121,12 @@ _free_endpoint_slot() {
done done
[ -n "$ep_id" ] && [ "$ep_id" -ge 128 ] && [ "$ep_dev" != "$DEVICE" ] || continue [ -n "$ep_id" ] && [ "$ep_id" -ge 128 ] && [ "$ep_dev" != "$DEVICE" ] || continue
[ -z "$victim_id" ] || [ "$ep_id" -gt "$victim_id" ] || continue [ -z "$victim_id" ] || [ "$ep_id" -gt "$victim_id" ] || continue
_dev_has_lower_id "$listing" "$ep_dev" "$ep_id" || continue
victim_id="$ep_id" victim_id="$ep_id"
victim_addr="$ep_addr" victim_addr="$ep_addr"
victim_dev="$ep_dev" victim_dev="$ep_dev"
done <<EOF done <<EOF
$(ip mptcp endpoint show) $listing
EOF EOF
[ "$count" -lt "$MPTCP_ENDPOINTS_MAX" ] && return 0 [ "$count" -lt "$MPTCP_ENDPOINTS_MAX" ] && return 0
[ -n "$victim_id" ] || return 1 [ -n "$victim_id" ] || return 1
@@ -228,7 +264,7 @@ case $1 in
fi fi
if [ -n "$IP" ] && [ -n "$endpoint" ]; then if [ -n "$IP" ] && [ -n "$endpoint" ]; then
for i in $IP; do for i in $IP; do
ID=$(echo "${endpoint}" | grep -F "$i " | awk '{print $3}') ID=$(endpoint_id_of_addr "${endpoint}" "$i")
[ -n "$ID" ] && break [ -n "$ID" ] && break
done done
fi fi
@@ -364,7 +400,7 @@ EOF
fi fi
if [ -z "$ID" ] && [ -n "$IP" ] && [ -n "$endpoint" ]; then if [ -z "$ID" ] && [ -n "$IP" ] && [ -n "$endpoint" ]; then
for i in $IP; do for i in $IP; do
ID=$(echo "${endpoint}" | grep -F "$i " | awk '{print $3}') ID=$(endpoint_id_of_addr "${endpoint}" "$i")
[ -n "$ID" ] && break [ -n "$ID" ] && break
done done
fi fi
+2 -2
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=teleproxy PKG_NAME:=teleproxy
PKG_VERSION:=4.16.1 PKG_VERSION:=4.16.1
PKG_RELEASE:=27 PKG_RELEASE:=28
PKG_MAINTAINER:=Kosntantine Shevlakov <shevlakov@132lan.ru> PKG_MAINTAINER:=Kosntantine Shevlakov <shevlakov@132lan.ru>
PKG_LICENSE:=GPLv2 PKG_LICENSE:=GPLv2
@@ -10,7 +10,7 @@ PKG_LICENSE_FILES:=LICENSE
PKG_SOURCE_PROTO:=git PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/teleproxy/teleproxy.git PKG_SOURCE_URL:=https://github.com/teleproxy/teleproxy.git
PKG_SOURCE_VERSION:=2deeb7a9c870940f5835ab3979cc5c5158172a4c PKG_SOURCE_VERSION:=9d70b228161585d008c88de630470a75a4d5828b
PKG_SOURCE_SUBDIR:=$(PKG_NAME) PKG_SOURCE_SUBDIR:=$(PKG_NAME)
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
+3 -3
View File
@@ -50,12 +50,12 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=wwand PKG_NAME:=wwand
PKG_VERSION:=1.7.0_pre21 PKG_VERSION:=1.7.0_pre23
PKG_RELEASE:=76 PKG_RELEASE:=77
PKG_SOURCE_PROTO:=git PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git
PKG_SOURCE_VERSION:=b67a83e2da98e77953a9577653394c61fd910da0 PKG_SOURCE_VERSION:=4d745c6c61622bb81c7ab258a967ea3604a2a4e5
PKG_MIRROR_HASH:=skip PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only PKG_LICENSE:=GPL-2.0-only