💋 Sync 2026-11-02 01:02:56

This commit is contained in:
github-actions[bot] committed 2026-11-02 01:02:56 +08:00
1 parent d97dc35e3d
commit 2f941f0ffb
48 files changed
+4891 -1002

No files matched your search

+45 -75
View File
@@ -1,30 +1,33 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=dockerd PKG_NAME:=dockerd
PKG_FLAGS:=hold PKG_VERSION:=29.6.1
PKG_VERSION:=27.3.1 PKG_RELEASE:=9
PKG_RELEASE:=8
PKG_LICENSE:=Apache-2.0 PKG_LICENSE:=Apache-2.0
PKG_LICENSE_FILES:=LICENSE PKG_LICENSE_FILES:=LICENSE
PKG_CPE_ID:=cpe:/a:mobyproject:moby
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_GIT_URL:=github.com/moby/moby PKG_GIT_URL:=github.com/moby/moby
PKG_GIT_REF:=v$(PKG_VERSION) PKG_GIT_REF:=docker-v$(PKG_VERSION)
PKG_SOURCE_URL:=https://codeload.$(PKG_GIT_URL)/tar.gz/$(PKG_GIT_REF)? PKG_SOURCE_URL:=https://codeload.$(PKG_GIT_URL)/tar.gz/$(PKG_GIT_REF)?
PKG_HASH:=skip PKG_HASH:=skip
PKG_GIT_SHORT_COMMIT:=41ca978 # SHA1 used within the docker executables PKG_GIT_SHORT_COMMIT:=8ec5ab3 # SHA1 used within the docker executables
PKG_MAINTAINER:=Gerard Ryan <G.M0N3Y.2503@gmail.com> PKG_MAINTAINER:=Gerard Ryan <G.M0N3Y.2503@gmail.com>
PKG_BUILD_DEPENDS:=golang/host PKG_BUILD_DEPENDS:=golang/host
PKG_BUILD_PARALLEL:=1 PKG_BUILD_PARALLEL:=1
PKG_BUILD_FLAGS:=no-mips16
GO_PKG:=github.com/docker/docker GO_PKG:=github.com/docker/docker
include $(INCLUDE_DIR)/package.mk include $(INCLUDE_DIR)/package.mk
include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk
EMPTY:=
SPACE:=$(EMPTY) $(EMPTY)
ARCH_DEPENDS:=@($(subst $(SPACE),||,$(strip $(filter-out mips mips64 mipsel mips64el,$(subst ||,$(SPACE),$(patsubst @(%),%,$(GO_ARCH_DEPENDS)))))))
define Package/dockerd/config define Package/dockerd/config
source "$(SOURCE)/Config.in" source "$(SOURCE)/Config.in"
endef endef
@@ -34,8 +37,7 @@ define Package/dockerd
CATEGORY:=Utilities CATEGORY:=Utilities
TITLE:=Docker Community Edition Daemon TITLE:=Docker Community Edition Daemon
URL:=https://www.docker.com/ URL:=https://www.docker.com/
DEPENDS:=$(GO_ARCH_DEPENDS) \ DEPENDS:=$(ARCH_DEPENDS) \
+jsonfilter \
+ca-certificates \ +ca-certificates \
+containerd \ +containerd \
+iptables \ +iptables \
@@ -48,10 +50,9 @@ define Package/dockerd
+kmod-nf-ipvs \ +kmod-nf-ipvs \
+kmod-veth \ +kmod-veth \
+tini \ +tini \
+uci-firewall \
+fuse-overlayfs \ +fuse-overlayfs \
+kmod-vxlan +kmod-macvlan +kmod-dummy +kmod-crypto-gcm +kmod-crypto-seqiv +kmod-crypto-ghash +kmod-ipsec +kmod-nf-nathelper +kmod-nf-nathelper-extra +btrfs-progs \ +kmod-vxlan +kmod-macvlan +kmod-dummy +kmod-crypto-gcm +kmod-crypto-seqiv +kmod-crypto-ghash +kmod-ipsec +kmod-nf-nathelper +kmod-nf-nathelper-extra +btrfs-progs \
@!(mips||mips64||mipsel) +uci-firewall
USERID:=docker:docker USERID:=docker:docker
MENU:=1 MENU:=1
endef endef
@@ -61,65 +62,45 @@ define Package/dockerd/conffiles
endef endef
define Package/dockerd/description define Package/dockerd/description
The Docker CE Engine. The Docker CE Engine.
endef endef
TAR_OPTIONS:=--strip-components 1 $(TAR_OPTIONS) TAR_OPTIONS:=--strip-components 1 $(TAR_OPTIONS)
TAR_CMD=$(HOST_TAR) -C $(1) $(TAR_OPTIONS) TAR_CMD=$(HOST_TAR) -C $(1) $(TAR_OPTIONS)
TARGET_LDFLAGS += $(if $(CONFIG_USE_GLIBC),-lc -lgcc_eh) TARGET_LDFLAGS += $(if $(CONFIG_USE_GLIBC),-lc -lgcc_eh)
# $(1) = path to dependent package 'Makefile' # $(1) = OpenWrt dependent PKG_NAME
# $(2) = relevant dependency '.installer' file
define EnsureVendoredVersion define EnsureVendoredVersion
( \ DEP_VER=$$$$( grep --only-matching --perl-regexp '(?<=PKG_VERSION:=)(.*)' "../../packages/utils/$(1)/Makefile" ); \
DEP_VER=$$$$( grep --only-matching --perl-regexp '(?<=PKG_VERSION:=)(.*)' "$(1)" ); \ VEN_VER=$$$$( grep --only-matching --perl-regexp '(?<=$(call toupper,$(1))_VERSION=v)(.*)' "$(PKG_BUILD_DIR)/Dockerfile" ); \
VEN_VER=$$$$( grep --only-matching --perl-regexp '(?<=_VERSION:=v)(.*)(?=})' "$(PKG_BUILD_DIR)/hack/dockerfile/install/$(2)" ); \ if [ "$$$${VEN_VER}" != "$$$${DEP_VER}" ]; then \
if [ "$$$${VEN_VER}" != "$$$${DEP_VER}" ]; then \ echo "ERROR: $(PKG_NAME) Expected 'PKG_VERSION:=$$$${VEN_VER}' in '$(1)', found 'PKG_VERSION:=$$$${DEP_VER}'"; \
echo "ERROR: $(PKG_NAME) Expected 'PKG_VERSION:=$$$${VEN_VER}' in '$(1)', found 'PKG_VERSION:=$$$${DEP_VER}'"; \ exit 1; \
exit 1; \ fi
fi \
)
endef
# $(1) = path to dependent package 'Makefile'
# $(2) = relevant dependency '.installer' file
define EnsureVendoredCommit
( \
DEP_VER=$$$$( grep --only-matching --perl-regexp '(?<=PKG_SOURCE_VERSION:=)(.*)' "$(1)" ); \
VEN_VER=$$$$( grep --only-matching --perl-regexp '(?<=_COMMIT:=)(.*)(?=})' "$(PKG_BUILD_DIR)/hack/dockerfile/install/$(2)" ); \
if [ "$$$${VEN_VER}" != "$$$${DEP_VER}" ]; then \
echo "ERROR: Expected 'PKG_SOURCE_VERSION:=$$$${VEN_VER}' in '$(1)', found 'PKG_SOURCE_VERSION:=$$$${DEP_VER}'"; \
exit 1; \
fi \
)
endef endef
define Build/Prepare define Build/Prepare
$(Build/Prepare/Default) $(Build/Prepare/Default)
# Verify dependencies are the vendored version # Verify dependencies are the vendored version
$(call EnsureVendoredVersion,../../packages/utils/containerd/Makefile,containerd.installer) $(call EnsureVendoredVersion,containerd)
$(call EnsureVendoredVersion,../../packages/utils/runc/Makefile,runc.installer) $(call EnsureVendoredVersion,runc)
$(call EnsureVendoredVersion,../../packages/utils/tini/Makefile,tini.installer) $(call EnsureVendoredVersion,tini)
# Verify CLI is the same version # Verify CLI is the same version
( \ CLI_MAKEFILE="../../packages/utils/docker/Makefile"; \
CLI_MAKEFILE="../../packages/utils/docker/Makefile"; \ CLI_VERSION=$$$$( grep --only-matching --perl-regexp '(?<=PKG_VERSION:=)(.*)' "$$$${CLI_MAKEFILE}" ); \
CLI_VERSION=$$$$( grep --only-matching --perl-regexp '(?<=PKG_VERSION:=)(.*)' "$$$${CLI_MAKEFILE}" ); \ if [ "$$$${CLI_VERSION}" != "$(PKG_VERSION)" ]; then \
if [ "$$$${CLI_VERSION}" != "$(PKG_VERSION)" ]; then \ echo "ERROR: Expected 'PKG_VERSION:=$(PKG_VERSION)' in '$$$${CLI_MAKEFILE}', found 'PKG_VERSION:=$$$${CLI_VERSION}'"; \
echo "ERROR: Expected 'PKG_VERSION:=$(PKG_VERSION)' in '$$$${CLI_MAKEFILE}', found 'PKG_VERSION:=$$$${CLI_VERSION}'"; \ exit 1; \
exit 1; \ fi
fi \
)
# Verify PKG_GIT_SHORT_COMMIT # Verify PKG_GIT_SHORT_COMMIT
( \ EXPECTED_PKG_GIT_SHORT_COMMIT=$$$$( $(CURDIR)/git-short-commit.sh '$(PKG_GIT_URL)' '$(PKG_GIT_REF)' '$(TMP_DIR)/git-short-commit/$(PKG_NAME)-$(PKG_VERSION)' ); \
EXPECTED_PKG_GIT_SHORT_COMMIT=$$$$( $(CURDIR)/git-short-commit.sh '$(PKG_GIT_URL)' '$(PKG_GIT_REF)' '$(TMP_DIR)/git-short-commit/$(PKG_NAME)-$(PKG_VERSION)' ); \ if [ "$$$${EXPECTED_PKG_GIT_SHORT_COMMIT}" != "$(strip $(PKG_GIT_SHORT_COMMIT))" ]; then \
if [ "$$$${EXPECTED_PKG_GIT_SHORT_COMMIT}" != "$(strip $(PKG_GIT_SHORT_COMMIT))" ]; then \ echo "ERROR: Expected 'PKG_GIT_SHORT_COMMIT:=$$$${EXPECTED_PKG_GIT_SHORT_COMMIT}', found 'PKG_GIT_SHORT_COMMIT:=$(strip $(PKG_GIT_SHORT_COMMIT))'"; \
echo "ERROR: Expected 'PKG_GIT_SHORT_COMMIT:=$$$${EXPECTED_PKG_GIT_SHORT_COMMIT}', found 'PKG_GIT_SHORT_COMMIT:=$(strip $(PKG_GIT_SHORT_COMMIT))'"; \ exit 1; \
exit 1; \ fi
fi \
)
endef endef
BUILDTAGS:= BUILDTAGS:=
@@ -131,14 +112,12 @@ BUILDTAGS += selinux
endif endif
define Build/Compile define Build/Compile
( \ cd $(PKG_BUILD_DIR); \
cd $(PKG_BUILD_DIR); \ $(GO_PKG_VARS) \
$(GO_PKG_VARS) \ DOCKER_GITCOMMIT=$(PKG_GIT_SHORT_COMMIT) \
DOCKER_GITCOMMIT=$(PKG_GIT_SHORT_COMMIT) \ DOCKER_BUILDTAGS='$(BUILDTAGS)' \
DOCKER_BUILDTAGS='$(BUILDTAGS)' \ VERSION=$(PKG_VERSION) \
VERSION=$(PKG_VERSION) \ ./hack/make.sh binary
./hack/make.sh binary; \
)
endef endef
define Package/dockerd/install define Package/dockerd/install
@@ -146,11 +125,11 @@ define Package/dockerd/install
$(INSTALL_BIN) $(PKG_BUILD_DIR)/bundles/binary-daemon/dockerd $(1)/usr/bin/ $(INSTALL_BIN) $(PKG_BUILD_DIR)/bundles/binary-daemon/dockerd $(1)/usr/bin/
$(INSTALL_BIN) $(PKG_BUILD_DIR)/bundles/binary-daemon/docker-proxy $(1)/usr/bin/ $(INSTALL_BIN) $(PKG_BUILD_DIR)/bundles/binary-daemon/docker-proxy $(1)/usr/bin/
$(INSTALL_DIR) $(1)/opt/docker/
$(INSTALL_DIR) $(1)/etc/uci-defaults $(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_DATA) ./files/dockerd.uci-defaults $(1)/etc/uci-defaults/dockerd $(INSTALL_DATA) ./files/dockerd.uci-defaults $(1)/etc/uci-defaults/dockerd
$(INSTALL_DIR) $(1)/opt/docker/
ifeq ($(CONFIG_DOCKER_CHECK_CONFIG),y) ifeq ($(CONFIG_DOCKER_CHECK_CONFIG),y)
$(INSTALL_DIR) $(1)/usr/share/docker/ $(INSTALL_DIR) $(1)/usr/share/docker/
$(INSTALL_BIN) $(PKG_BUILD_DIR)/contrib/check-config.sh $(1)/usr/share/docker/ $(INSTALL_BIN) $(PKG_BUILD_DIR)/contrib/check-config.sh $(1)/usr/share/docker/
@@ -162,19 +141,10 @@ define Package/dockerd/install
$(INSTALL_DIR) $(1)/etc/config $(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/etc/config/dockerd $(1)/etc/config/dockerd $(INSTALL_CONF) ./files/etc/config/dockerd $(1)/etc/config/dockerd
$(INSTALL_DIR) $(1)/etc/hotplug.d/net # Must be after systcl 11-br-netfilter.conf from kmod-br-netfilter
$(INSTALL_DATA) ./files/etc/hotplug.d/net/br-netfilter \ $(INSTALL_DIR) $(1)/etc/sysctl.d
$(1)/etc/hotplug.d/net/10-docker-br-netfilter $(INSTALL_DATA) ./files/etc/sysctl.d/sysctl-br-netfilter-ip.conf \
$(1)/etc/sysctl.d/12-br-netfilter-ip.conf
$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_DATA) ./files/etc/uci-defaults/16_docker-netifd-migrate2 \
$(1)/etc/uci-defaults/16_docker-netifd-migrate2
$(INSTALL_DATA) ./files/etc/uci-defaults/17_docker-fw4 \
$(1)/etc/uci-defaults/17_docker-fw4
$(INSTALL_DIR) $(1)/lib/fstab.d
$(INSTALL_DATA) ./files/lib/fstab.d/dockerd.sh $(1)/lib/fstab.d/dockerd.sh
endef endef
define Package/dockerd/postinst define Package/dockerd/postinst
+2 -2
View File
@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=gately PKG_NAME:=gately
PKG_VERSION:=0.6.2 PKG_VERSION:=0.6.2.2
PKG_RELEASE:=20 PKG_RELEASE:=21
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/wangwanjie/gately/tar.gz/v$(PKG_VERSION)? PKG_SOURCE_URL:=https://codeload.github.com/wangwanjie/gately/tar.gz/v$(PKG_VERSION)?
+6 -6
View File
@@ -3,17 +3,17 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=honk PKG_NAME:=honk
PKG_VERSION:=0.1.0_beta4 PKG_VERSION:=415.3_pre1
PKG_RELEASE:=25 PKG_RELEASE:=26
# Raw upstream release tag. It keeps the dots in qualifiers (e.g. v0.0.1.beta.49) # Raw upstream release tag. It keeps the dots in qualifiers (e.g. v0.0.1.beta.49)
# because that is what the GitHub release asset name uses. # because that is what the GitHub release asset name uses.
HONK_RELEASE_TAG:=v0.1.0-beta.4 HONK_RELEASE_TAG:=debug-diag.issue-415.3
HONK_ASSET_TAG:=v0.1.0-beta.4 HONK_ASSET_TAG:=debug
# SHA256 of each release tarball, kept in sync by scripts/update_honk_version.sh. # SHA256 of each release tarball, kept in sync by scripts/update_honk_version.sh.
HONK_HASH_X86_64:=48f694b13b9c5200e8e884ee19431035bd67b0ef01d04caef007047ad11ba02c HONK_HASH_X86_64:=a40ff27b4a1d9b5aa8a76ed05c9def01f2e5612dde506f912dc9da6df8ccc1ca
HONK_HASH_AARCH64:=6cc92f862a1684ba9c49e9f9c08fb108f53444f338f1e9be84013e2f7fb7423d HONK_HASH_AARCH64:=8ffd3327d7039a430e0370a917fc4eaa75d85bb256f3d23753da0e1d06e57d72
PKG_LICENSE:=AGPL-3.0-only PKG_LICENSE:=AGPL-3.0-only
PKG_LICENSE_FILE:=LICENSE PKG_LICENSE_FILE:=LICENSE
+1 -2
View File
@@ -1,6 +1,5 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-2.0-only
PKG_LICENSE:=GPL-3.0-or-later
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
LUCI_NAME:=luci-app-adguardhome LUCI_NAME:=luci-app-adguardhome
+50
View File
@@ -0,0 +1,50 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-cf-optimizer
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=Dante <dante@seckv.com>
LUCI_TITLE:=LuCI support for Cloudflare Proxy and Tunnel Host Optimizer
LUCI_DEPENDS:=+luci-base +curl +ucode +ucode-mod-fs +ucode-mod-uci +ucode-mod-ubus
LUCI_PKGARCH:=all
define Package/$(PKG_NAME)/conffiles
/etc/config/cf-optimizer
endef
define Package/$(PKG_NAME)/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || {
rm -f /tmp/luci-indexcache.* /tmp/luci-modulecache/* 2>/dev/null
chmod 755 /etc/init.d/cf-optimizer /usr/sbin/cf-optimizer 2>/dev/null
/etc/init.d/cf-optimizer enable 2>/dev/null || true
/etc/init.d/rpcd restart
}
exit 0
endef
define Package/$(PKG_NAME)/prerm
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
/etc/init.d/cf-optimizer stop
/etc/init.d/cf-optimizer disable
/usr/sbin/cf-optimizer clean
fi
exit 0
endef
define Package/$(PKG_NAME)/postrm
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
rm -rf /tmp/run/cf-optimizer /tmp/cf-optimizer
rm -f /tmp/hosts/cf-optimizer /var/log/cf-optimizer.log*
killall -HUP dnsmasq 2>/dev/null
fi
exit 0
endef
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,101 @@
'use strict';
'require view';
'require rpc';
'require poll';
'require ui';
'require dom';
var callGetLogs = rpc.declare({
object: 'luci.cf-optimizer',
method: 'get_logs'
});
var callClearLogs = rpc.declare({
object: 'luci.cf-optimizer',
method: 'clear_logs'
});
return view.extend({
handleSaveApply: null,
handleSave: null,
handleReset: null,
load: function() {
return callGetLogs();
},
handleClearLogs: function(ev) {
return callClearLogs().then(function() {
var textarea = document.getElementById('cf-log-textarea');
if (textarea) textarea.value = '';
ui.addNotification(null, E('p', {}, _('日志已清空')), 'info');
}).catch(function(e) {
ui.addNotification(null, E('p', {}, e.message));
});
},
render: function(data) {
data = data || {};
var lines = Array.isArray(data.logs) ? data.logs : [];
var logText = lines.join('\n');
var textarea = E('textarea', {
'id': 'cf-log-textarea',
'class': 'cbi-input-textarea',
'style': 'width: 100%; height: 480px; font-family: monospace; font-size: 12px; line-height: 1.5; resize: vertical; box-sizing: border-box; padding: 10px; border-radius: 4px;',
'readonly': 'readonly',
'wrap': 'off'
}, logText);
// 默认滚动到底部
setTimeout(function() {
textarea.scrollTop = textarea.scrollHeight;
}, 100);
var autoRefreshCheckbox = E('input', {
'type': 'checkbox',
'id': 'cf-auto-refresh',
'checked': 'checked',
'style': 'margin-right: 6px; vertical-align: middle;'
});
var controls = E('div', {
'style': 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;'
}, [
E('label', { 'style': 'cursor: pointer; user-select: none; font-size: 13px;' }, [
autoRefreshCheckbox,
_('自动刷新 (每 4 秒)')
]),
E('button', {
'class': 'btn cbi-button cbi-button-remove',
'click': ui.createHandlerFn(this, 'handleClearLogs')
}, _('清空日志'))
]);
poll.add(function() {
var chk = document.getElementById('cf-auto-refresh');
if (!chk || !chk.checked) return;
return callGetLogs().then(function(res) {
res = res || {};
var ta = document.getElementById('cf-log-textarea');
if (ta && Array.isArray(res.logs)) {
var wasAtBottom = (ta.scrollHeight - ta.clientHeight <= ta.scrollTop + 50);
ta.value = res.logs.join('\n');
if (wasAtBottom) {
ta.scrollTop = ta.scrollHeight;
}
}
});
}, 4);
return E('div', { 'class': 'cbi-map' }, [
E('h2', {}, _('Cloudflare 优选加速') + ' - ' + _('运行日志')),
E('div', { 'class': 'cbi-map-descr' }, _('查看后台优选测速与 hosts 重载的实时运行记录。')),
E('fieldset', { 'class': 'cbi-section' }, [
controls,
textarea
])
]);
}
});
@@ -0,0 +1,244 @@
'use strict';
'require view';
'require rpc';
'require poll';
'require ui';
'require dom';
var callGetStatus = rpc.declare({
object: 'luci.cf-optimizer',
method: 'get_status'
});
var callRunOnce = rpc.declare({
object: 'luci.cf-optimizer',
method: 'run_once'
});
var callServiceAction = rpc.declare({
object: 'luci.cf-optimizer',
method: 'service_action',
params: [ 'action' ]
});
return view.extend({
handleSaveApply: null,
handleSave: null,
handleReset: null,
load: function() {
return callGetStatus();
},
handleRunOnce: function(ev) {
ui.showModal(_('执行优选'), [
E('p', { 'class': 'spinning' }, _('正在后台启动优选探测测速任务,请稍候...'))
]);
return callRunOnce().then(function() {
setTimeout(function() {
ui.hideModal();
window.location.reload();
}, 3000);
}).catch(function(e) {
ui.addNotification(null, E('p', {}, e.message));
ui.hideModal();
});
},
handleServiceAction: function(action, ev) {
ui.showModal(_('服务操作'), [
E('p', { 'class': 'spinning' }, _('正在执行操作,请稍候...'))
]);
return callServiceAction(action).then(function() {
setTimeout(function() {
ui.hideModal();
window.location.reload();
}, 1500);
}).catch(function(e) {
ui.addNotification(null, E('p', {}, e.message));
ui.hideModal();
});
},
render: function(data) {
data = data || {};
var container = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, _('Cloudflare 优选加速') + ' - ' + _('状态概览')),
E('div', { 'class': 'cbi-map-descr' }, _('监控当前生效的 Cloudflare 优选节点状态,展示各代理域名定向情况与最新测速排行榜。'))
]);
// 服务状态卡片
var isRunning = data.service_running === true;
var statusText = isRunning ? (_('运行中') + (data.service_pid ? ' [PID: ' + data.service_pid + ']' : '')) : _('已停止');
var statusColor = isRunning ? '#059669' : '#64748b';
var statusSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('服务状态')),
E('div', { 'class': 'cbi-section-node' }, [
E('table', { 'class': 'table cbi-section-table' }, [
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'width': '25%' }, _('服务状态')),
E('td', { 'class': 'td left' }, [
E('span', { 'id': 'cf-service-status', 'style': 'font-weight:bold; color:' + statusColor + '; margin-right:15px;' }, statusText),
E('button', {
'class': 'btn cbi-button cbi-button-action',
'style': 'margin-right:10px;',
'click': ui.createHandlerFn(this, 'handleRunOnce')
}, _('立即执行优选')),
E('button', {
'class': 'btn cbi-button cbi-button-reload',
'click': ui.createHandlerFn(this, 'handleServiceAction', 'restart')
}, _('重启服务'))
])
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('上次更新时间')),
E('td', { 'class': 'td left', 'id': 'cf-last-update' }, data.last_update || '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('生效优选 IP')),
E('td', { 'class': 'td left', 'id': 'cf-best-ip', 'style': 'font-weight:bold;' }, data.best_ip || '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('次优备选 IP')),
E('td', { 'class': 'td left', 'id': 'cf-standby-ip' }, data.standby_ip ? (data.standby_ip + ' (' + (data.standby_colo || '-') + ')') : _('暂无备选'))
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('健康看门狗')),
E('td', { 'class': 'td left', 'id': 'cf-watchdog-status', 'style': 'font-weight:bold; color:' + ((data.watchdog_status && data.watchdog_status.indexOf('倒换') !== -1) ? '#d97706' : '#059669') }, data.watchdog_status || _('未运行'))
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('数据中心')),
E('td', { 'class': 'td left', 'id': 'cf-best-colo' }, data.best_colo || '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('测速延迟')),
E('td', { 'class': 'td left', 'id': 'cf-best-latency' }, [
E('span', { 'style': 'color:#2563eb; font-weight:bold;' }, data.best_tcp_lat ? 'TCP: ' + data.best_tcp_lat : '-'),
E('span', { 'style': 'margin:0 8px; color:#cbd5e1;' }, '|'),
E('span', { 'style': 'color:#059669; font-weight:bold;' }, data.best_tls_lat ? 'TLS: ' + data.best_tls_lat : '-')
])
])
])
])
]);
// 域名定向列表
var domainRows = [];
if (Array.isArray(data.domains) && data.domains.length > 0) {
for (var i = 0; i < data.domains.length; i++) {
var d = data.domains[i];
domainRows.push(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td' }, E('strong', {}, d.domain)),
E('td', { 'class': 'td' }, d.ip || '-'),
E('td', { 'class': 'td' }, d.colo || '-'),
E('td', { 'class': 'td' }, d.tcp_lat || '-'),
E('td', { 'class': 'td' }, d.tls_lat || '-'),
E('td', { 'class': 'td' }, E('span', { 'style': 'color:#059669; font-weight:bold;' }, d.status || _('正常')))
]));
}
} else {
domainRows.push(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td', 'colspan': '6', 'style': 'text-align:center; color:#94a3b8;' }, _('暂无生效的域名定向记录,请在参数配置中添加域名并启动服务。'))
]));
}
var domainSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('域名定向列表')),
E('div', { 'class': 'cbi-section-node' }, [
E('table', { 'class': 'table cbi-section-table', 'id': 'cf-domain-table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, _('目标域名')),
E('th', { 'class': 'th' }, _('生效优选 IP')),
E('th', { 'class': 'th' }, _('数据中心')),
E('th', { 'class': 'th' }, _('TCP 延迟')),
E('th', { 'class': 'th' }, _('TLS 延迟')),
E('th', { 'class': 'th' }, _('状态'))
])
].concat(domainRows))
])
]);
// 优选候选排行榜
var rankingRows = [];
if (Array.isArray(data.rankings) && data.rankings.length > 0) {
for (var j = 0; j < data.rankings.length; j++) {
var r = data.rankings[j];
rankingRows.push(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td' }, r.rank),
E('td', { 'class': 'td' }, E('strong', {}, r.ip)),
E('td', { 'class': 'td' }, r.colo || '-'),
E('td', { 'class': 'td', 'style': 'color:#059669; font-weight:bold;' }, r.avg_lat),
E('td', { 'class': 'td' }, r.lat_range || '-'),
E('td', { 'class': 'td' }, r.loss_rate || '0.0%')
]));
}
} else {
rankingRows.push(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td', 'colspan': '6', 'style': 'text-align:center; color:#94a3b8;' }, _('暂无排行榜数据'))
]));
}
var rankingSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('优选候选节点排行榜')),
E('div', { 'class': 'cbi-section-node' }, [
E('table', { 'class': 'table cbi-section-table', 'id': 'cf-ranking-table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, _('排名')),
E('th', { 'class': 'th' }, _('候选 IP')),
E('th', { 'class': 'th' }, _('数据中心')),
E('th', { 'class': 'th' }, _('平均延迟')),
E('th', { 'class': 'th' }, _('时延范围')),
E('th', { 'class': 'th' }, _('丢包率'))
])
].concat(rankingRows))
])
]);
container.appendChild(statusSection);
container.appendChild(domainSection);
container.appendChild(rankingSection);
// 定时轮询更新
poll.add(function() {
return callGetStatus().then(function(res) {
res = res || {};
var running = res.service_running === true;
var stEl = document.getElementById('cf-service-status');
if (stEl) {
stEl.textContent = running ? (_('运行中') + (res.service_pid ? ' [PID: ' + res.service_pid + ']' : '')) : _('已停止');
stEl.style.color = running ? '#059669' : '#64748b';
}
var upEl = document.getElementById('cf-last-update');
if (upEl) upEl.textContent = res.last_update || '-';
var ipEl = document.getElementById('cf-best-ip');
if (ipEl) ipEl.textContent = res.best_ip || '-';
var sbEl = document.getElementById('cf-standby-ip');
if (sbEl) sbEl.textContent = res.standby_ip ? (res.standby_ip + ' (' + (res.standby_colo || '-') + ')') : _('暂无备选');
var wdEl = document.getElementById('cf-watchdog-status');
if (wdEl) {
wdEl.textContent = res.watchdog_status || _('未运行');
wdEl.style.color = (res.watchdog_status && res.watchdog_status.indexOf('倒换') !== -1) ? '#d97706' : '#059669';
}
var coloEl = document.getElementById('cf-best-colo');
if (coloEl) coloEl.textContent = res.best_colo || '-';
var latEl = document.getElementById('cf-best-latency');
if (latEl) {
dom.content(latEl, [
E('span', { 'style': 'color:#2563eb; font-weight:bold;' }, res.best_tcp_lat ? 'TCP: ' + res.best_tcp_lat : '-'),
E('span', { 'style': 'margin:0 8px; color:#cbd5e1;' }, '|'),
E('span', { 'style': 'color:#059669; font-weight:bold;' }, res.best_tls_lat ? 'TLS: ' + res.best_tls_lat : '-')
]);
}
});
}, 3);
return container;
}
});
@@ -0,0 +1,153 @@
'use strict';
'require view';
'require form';
'require uci';
'require fs';
'require ui';
return view.extend({
handleSaveApply: function(ev, mode) {
return this.handleSave(ev).then(function() {
ui.showModal(_('应用配置'), [
E('p', { 'class': 'spinning' }, _('正在下发配置并同步 hosts 域名定向,请稍候...'))
]);
return fs.exec_direct('/etc/init.d/cf-optimizer', ['reload']).catch(function() {}).then(function() {
setTimeout(function() {
ui.hideModal();
window.location.reload();
}, 1500);
});
});
},
render: function() {
var m, s, o;
m = new form.Map('cf-optimizer', _('Cloudflare 优选加速') + ' - ' + _('参数配置'),
_('配置待优选的 Cloudflare 代理域名、测速周期及采样算法参数。'));
s = m.section(form.NamedSection, 'settings', 'cf-optimizer', _('基础设置'));
s.anonymous = true;
o = s.option(form.Flag, 'enabled', _('启用服务'),
_('开启后守护进程将按设定周期自动执行优选测速,并通过 dnsmasq 内存级重载生效。'));
o.rmempty = false;
o = s.option(form.DynamicList, 'domains', _('加速域名列表'),
_('填入需要优选加速的域名,支持 Cloudflare Tunnel 穿透域名和小黄云代理域名。'));
o.datatype = 'host';
o.rmempty = false;
o = s.option(form.ListValue, 'interval', _('自动优选周期'),
_('两次自动测速优选之间的休眠间隔时间。'));
o.value('30', _('30 分钟'));
o.value('60', _('1 小时'));
o.value('120', _('2 小时'));
o.value('240', _('4 小时'));
o.value('360', _('6 小时'));
o.value('720', _('12 小时'));
o.value('1440', _('24 小时'));
o.default = '120';
s = m.section(form.NamedSection, 'settings', 'cf-optimizer', _('高级探测参数'));
s.anonymous = true;
o = s.option(form.Value, 'sample_count', _('采样探测次数'),
_('初筛后对候选节点进行连续采样的次数,用于计算平均时延与丢包率,推荐范围 3-10 次。'));
o.datatype = 'range(2, 20)';
o.default = '4';
o = s.option(form.ListValue, 'hysteresis_ms', _('防震荡迟滞阈值'),
_('新节点平均延迟相比在用节点必须降低超过此绝对时延(毫秒)才触发切换,避免网络微小波动导致局域网长连接频繁重连。'));
o.value('0', _('关闭 (任意优化均切换)'));
o.value('10', '10 ms');
o.value('20', '20 ms');
o.value('30', '30 ms');
o.value('50', '50 ms');
o.value('80', '80 ms');
o.default = '30';
o = s.option(form.Value, 'concurrency', _('初筛并发数'),
_('初筛阶段并发探测的进程数量,推荐范围 5-20。'));
o.datatype = 'range(2, 30)';
o.default = '10';
o = s.option(form.ListValue, 'ip_source', _('候选 IP 来源'),
_('选择优选测速的候选节点 IP 来源。'));
o.value('builtin', _('内置优选 IP 池'));
o.value('custom', _('自定义 IP 或网段'));
o.value('mixed', _('混合模式'));
o.default = 'builtin';
o = s.option(form.DynamicList, 'custom_ips', _('自定义候选 IP 列表'),
_('填入自定义的 Cloudflare Anycast IP 地址或网段。'));
o.datatype = 'or(ip4addr, ip4prefix)';
o.depends('ip_source', 'custom');
o.depends('ip_source', 'mixed');
s = m.section(form.NamedSection, 'settings', 'cf-optimizer', _('故障快速倒换'));
s.anonymous = true;
o = s.option(form.Flag, 'failover_enabled', _('启用快速故障倒换'),
_('开启后短周期看门狗将持续监控在用节点健康度,发生严重丢包或阻断时自动秒级倒换至次优备选节点。'));
o.rmempty = false;
o.default = '1';
o = s.option(form.ListValue, 'health_interval', _('健康巡检间隔'),
_('看门狗对在用节点执行极轻量 HTTP 探活的间隔时间。'));
o.value('30', _('30 秒'));
o.value('60', _('60 秒'));
o.value('120', _('120 秒'));
o.default = '60';
o.depends('failover_enabled', '1');
o = s.option(form.Value, 'fail_threshold', _('连续故障判定阈值'),
_('探活连续失败达到指定次数后触发次优节点核验与自动倒换,推荐设置为 2 次以避免偶发网络抖动误切换。'));
o.datatype = 'range(1, 10)';
o.default = '2';
o.depends('failover_enabled', '1');
o = s.option(form.ListValue, 'latency_multiplier', _('延迟劣化容忍倍数'),
_('当在用节点实测延迟超过基线延迟指定倍数且绝对恶化超过 50ms 时判定为信道拥塞,连续触发将倒换至次优节点。'));
o.value('1.5', _('1.5 倍'));
o.value('2.0', _('2.0 倍'));
o.value('3.0', _('3.0 倍'));
o.value('0', _('禁用'));
o.default = '2.0';
o.depends('failover_enabled', '1');
s = m.section(form.NamedSection, 'settings', 'cf-optimizer', _('机房区域过滤'));
s.anonymous = true;
o = s.option(form.ListValue, 'colo_filter_mode', _('区域过滤模式'),
_('设置对 Cloudflare 数据中心机房的筛选策略。'));
o.value('none', _('不限制'));
o.value('exclude', _('排除指定机房'));
o.value('include', _('仅限指定机房'));
o.default = 'none';
o = s.option(form.MultiValue, 'colo_list', _('目标机房列表'),
_('选择需要排除或仅保留的 Cloudflare 常用数据中心机房。'));
o.value('SIN', _('新加坡 SIN'));
o.value('HKG', _('香港 HKG'));
o.value('TPE', _('台北 TPE'));
o.value('NRT', _('东京 NRT'));
o.value('KIX', _('大阪 KIX'));
o.value('ICN', _('首尔 ICN'));
o.value('KUL', _('吉隆坡 KUL'));
o.value('BKK', _('曼谷 BKK'));
o.value('SJC', _('圣何塞 SJC'));
o.value('LAX', _('洛杉矶 LAX'));
o.value('FRA', _('法兰克福 FRA'));
o.value('LHR', _('伦敦 LHR'));
o.depends('colo_filter_mode', 'exclude');
o.depends('colo_filter_mode', 'include');
o = s.option(form.DynamicList, 'custom_colos', _('自定义机房代码'),
_('填入其他三位大写机场代码,例如 SFO、SEA 等。'));
o.depends('colo_filter_mode', 'exclude');
o.depends('colo_filter_mode', 'include');
return m.render();
}
});
@@ -0,0 +1,290 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8\n"
msgid "Cloudflare 优选加速"
msgstr "Cloudflare 优选加速"
msgid "状态概览"
msgstr "状态概览"
msgid "参数配置"
msgstr "参数配置"
msgid "运行日志"
msgstr "运行日志"
msgid "监控当前生效的 Cloudflare 优选节点状态,展示各代理域名定向情况与最新测速排行榜。"
msgstr "监控当前生效的 Cloudflare 优选节点状态,展示各代理域名定向情况与最新测速排行榜。"
msgid "配置待优选的 Cloudflare 代理域名、测速周期及采样算法参数。"
msgstr "配置待优选的 Cloudflare 代理域名、测速周期及采样算法参数。"
msgid "查看后台优选测速与 hosts 重载的实时运行记录。"
msgstr "查看后台优选测速与 hosts 重载的实时运行记录。"
msgid "服务状态"
msgstr "服务状态"
msgid "运行中"
msgstr "运行中"
msgid "已停止"
msgstr "已停止"
msgid "立即执行优选"
msgstr "立即执行优选"
msgid "重启服务"
msgstr "重启服务"
msgid "上次更新时间"
msgstr "上次更新时间"
msgid "生效优选 IP"
msgstr "生效优选 IP"
msgid "数据中心"
msgstr "数据中心"
msgid "测速延迟"
msgstr "测速延迟"
msgid "域名定向列表"
msgstr "域名定向列表"
msgid "目标域名"
msgstr "目标域名"
msgid "TCP 延迟"
msgstr "TCP 延迟"
msgid "TLS 延迟"
msgstr "TLS 延迟"
msgid "状态"
msgstr "状态"
msgid "正常"
msgstr "正常"
msgid "当前优选候选排行榜 (Top 5)"
msgstr "当前优选候选排行榜 (Top 5)"
msgid "排名"
msgstr "排名"
msgid "候选 IP"
msgstr "候选 IP"
msgid "平均延迟"
msgstr "平均延迟"
msgid "时延范围"
msgstr "时延范围"
msgid "丢包率"
msgstr "丢包率"
msgid "基础设置"
msgstr "基础设置"
msgid "启用服务"
msgstr "启用服务"
msgid "开启后守护进程将按设定周期自动执行优选测速,并通过 dnsmasq 内存级重载生效。"
msgstr "开启后守护进程将按设定周期自动执行优选测速,并通过 dnsmasq 内存级重载生效。"
msgid "加速域名列表"
msgstr "加速域名列表"
msgid "填入需要优选加速的域名,支持 Cloudflare Tunnel 穿透域名和小黄云代理域名。"
msgstr "填入需要优选加速的域名,支持 Cloudflare Tunnel 穿透域名和小黄云代理域名。"
msgid "自动优选周期"
msgstr "自动优选周期"
msgid "两次自动测速优选之间的休眠间隔时间。"
msgstr "两次自动测速优选之间的休眠间隔时间。"
msgid "高级探测参数"
msgstr "高级探测参数"
msgid "采样探测次数"
msgstr "采样探测次数"
msgid "初筛后对候选节点进行连续采样的次数,用于计算平均时延与丢包率,推荐范围 3-10 次。"
msgstr "初筛后对候选节点进行连续采样的次数,用于计算平均时延与丢包率,推荐范围 3-10 次。"
msgid "防震荡迟滞阈值"
msgstr "防震荡迟滞阈值"
msgid "新节点平均延迟相比在用节点必须降低超过此绝对时延(毫秒)才触发切换,避免网络微小波动导致局域网长连接频繁重连。"
msgstr ""
msgid "关闭 (任意优化均切换)"
msgstr ""
msgid "正在下发配置并同步 hosts 域名定向,请稍候..."
msgstr ""
msgid "初筛并发数"
msgstr "初筛并发数"
msgid "初筛阶段并发探测的进程数量,推荐范围 5-20。"
msgstr "初筛阶段并发探测的进程数量,推荐范围 5-20。"
msgid "候选 IP 来源"
msgstr "候选 IP 来源"
msgid "选择优选测速的候选节点 IP 来源。"
msgstr "选择优选测速的候选节点 IP 来源。"
msgid "内置优选 IP 池"
msgstr "内置优选 IP 池"
msgid "自定义 IP 或网段"
msgstr "自定义 IP 或网段"
msgid "混合模式"
msgstr "混合模式"
msgid "自定义候选 IP 列表"
msgstr "自定义候选 IP 列表"
msgid "填入自定义的 Cloudflare Anycast IP 地址或网段。"
msgstr "填入自定义的 Cloudflare Anycast IP 地址或网段。"
msgid "清空日志"
msgstr "清空日志"
msgid "日志已清空"
msgstr "日志已清空"
msgid "次优备选 IP"
msgstr ""
msgid "暂无备选"
msgstr ""
msgid "健康看门狗"
msgstr ""
msgid "未运行"
msgstr ""
msgid "故障快速倒换"
msgstr ""
msgid "启用快速故障倒换"
msgstr ""
msgid "开启后短周期看门狗将持续监控在用节点健康度,发生严重丢包或阻断时自动秒级倒换至次优备选节点。"
msgstr ""
msgid "健康巡检间隔"
msgstr ""
msgid "看门狗对在用节点执行极轻量 HTTP 探活的间隔时间。"
msgstr ""
msgid "30 秒"
msgstr ""
msgid "60 秒"
msgstr ""
msgid "120 秒"
msgstr ""
msgid "连续故障判定阈值"
msgstr ""
msgid "探活连续失败达到指定次数后触发次优节点核验与自动倒换,推荐设置为 2 次以避免偶发网络抖动误切换。"
msgstr ""
msgid "已倒换"
msgstr ""
msgid "延迟劣化容忍倍数"
msgstr ""
msgid "当在用节点实测延迟超过基线延迟指定倍数且绝对恶化超过 50ms 时判定为信道拥塞,连续触发将倒换至次优节点。"
msgstr ""
msgid "1.5 倍"
msgstr ""
msgid "2.0 倍"
msgstr ""
msgid "3.0 倍"
msgstr ""
msgid "禁用"
msgstr ""
msgid "机房区域过滤"
msgstr ""
msgid "区域过滤模式"
msgstr ""
msgid "设置对 Cloudflare 数据中心机房的筛选策略。"
msgstr ""
msgid "不限制"
msgstr ""
msgid "排除指定机房"
msgstr ""
msgid "仅限指定机房"
msgstr ""
msgid "目标机房列表"
msgstr ""
msgid "选择需要排除或仅保留的 Cloudflare 常用数据中心机房。"
msgstr ""
msgid "新加坡 SIN"
msgstr ""
msgid "香港 HKG"
msgstr ""
msgid "台北 TPE"
msgstr ""
msgid "东京 NRT"
msgstr ""
msgid "大阪 KIX"
msgstr ""
msgid "首尔 ICN"
msgstr ""
msgid "吉隆坡 KUL"
msgstr ""
msgid "曼谷 BKK"
msgstr ""
msgid "圣何塞 SJC"
msgstr ""
msgid "洛杉矶 LAX"
msgstr ""
msgid "法兰克福 FRA"
msgstr ""
msgid "伦敦 LHR"
msgstr ""
msgid "自定义机房代码"
msgstr ""
msgid "填入其他三位大写机场代码,例如 SFO、SEA 等。"
msgstr ""
+1
View File
@@ -0,0 +1 @@
zh_Hans
@@ -0,0 +1,290 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8\n"
msgid "Cloudflare 优选加速"
msgstr "Cloudflare 优选加速"
msgid "状态概览"
msgstr "状态概览"
msgid "参数配置"
msgstr "参数配置"
msgid "运行日志"
msgstr "运行日志"
msgid "监控当前生效的 Cloudflare 优选节点状态,展示各代理域名定向情况与最新测速排行榜。"
msgstr "监控当前生效的 Cloudflare 优选节点状态,展示各代理域名定向情况与最新测速排行榜。"
msgid "配置待优选的 Cloudflare 代理域名、测速周期及采样算法参数。"
msgstr "配置待优选的 Cloudflare 代理域名、测速周期及采样算法参数。"
msgid "查看后台优选测速与 hosts 重载的实时运行记录。"
msgstr "查看后台优选测速与 hosts 重载的实时运行记录。"
msgid "服务状态"
msgstr "服务状态"
msgid "运行中"
msgstr "运行中"
msgid "已停止"
msgstr "已停止"
msgid "立即执行优选"
msgstr "立即执行优选"
msgid "重启服务"
msgstr "重启服务"
msgid "上次更新时间"
msgstr "上次更新时间"
msgid "生效优选 IP"
msgstr "生效优选 IP"
msgid "数据中心"
msgstr "数据中心"
msgid "测速延迟"
msgstr "测速延迟"
msgid "域名定向列表"
msgstr "域名定向列表"
msgid "目标域名"
msgstr "目标域名"
msgid "TCP 延迟"
msgstr "TCP 延迟"
msgid "TLS 延迟"
msgstr "TLS 延迟"
msgid "状态"
msgstr "状态"
msgid "正常"
msgstr "正常"
msgid "当前优选候选排行榜 (Top 5)"
msgstr "当前优选候选排行榜 (Top 5)"
msgid "排名"
msgstr "排名"
msgid "候选 IP"
msgstr "候选 IP"
msgid "平均延迟"
msgstr "平均延迟"
msgid "时延范围"
msgstr "时延范围"
msgid "丢包率"
msgstr "丢包率"
msgid "基础设置"
msgstr "基础设置"
msgid "启用服务"
msgstr "启用服务"
msgid "开启后守护进程将按设定周期自动执行优选测速,并通过 dnsmasq 内存级重载生效。"
msgstr "开启后守护进程将按设定周期自动执行优选测速,并通过 dnsmasq 内存级重载生效。"
msgid "加速域名列表"
msgstr "加速域名列表"
msgid "填入需要优选加速的域名,支持 Cloudflare Tunnel 穿透域名和小黄云代理域名。"
msgstr "填入需要优选加速的域名,支持 Cloudflare Tunnel 穿透域名和小黄云代理域名。"
msgid "自动优选周期"
msgstr "自动优选周期"
msgid "两次自动测速优选之间的休眠间隔时间。"
msgstr "两次自动测速优选之间的休眠间隔时间。"
msgid "高级探测参数"
msgstr "高级探测参数"
msgid "采样探测次数"
msgstr "采样探测次数"
msgid "初筛后对候选节点进行连续采样的次数,用于计算平均时延与丢包率,推荐范围 3-10 次。"
msgstr "初筛后对候选节点进行连续采样的次数,用于计算平均时延与丢包率,推荐范围 3-10 次。"
msgid "防震荡迟滞阈值"
msgstr "防震荡迟滞阈值"
msgid "新节点平均延迟相比在用节点必须降低超过此绝对时延(毫秒)才触发切换,避免网络微小波动导致局域网长连接频繁重连。"
msgstr "新节点平均延迟相比在用节点必须降低超过此绝对时延(毫秒)才触发切换,避免网络微小波动导致局域网长连接频繁重连。"
msgid "关闭 (任意优化均切换)"
msgstr "关闭 (任意优化均切换)"
msgid "正在下发配置并同步 hosts 域名定向,请稍候..."
msgstr "正在下发配置并同步 hosts 域名定向,请稍候..."
msgid "初筛并发数"
msgstr "初筛并发数"
msgid "初筛阶段并发探测的进程数量,推荐范围 5-20。"
msgstr "初筛阶段并发探测的进程数量,推荐范围 5-20。"
msgid "候选 IP 来源"
msgstr "候选 IP 来源"
msgid "选择优选测速的候选节点 IP 来源。"
msgstr "选择优选测速的候选节点 IP 来源。"
msgid "内置优选 IP 池"
msgstr "内置优选 IP 池"
msgid "自定义 IP 或网段"
msgstr "自定义 IP 或网段"
msgid "混合模式"
msgstr "混合模式"
msgid "自定义候选 IP 列表"
msgstr "自定义候选 IP 列表"
msgid "填入自定义的 Cloudflare Anycast IP 地址或网段。"
msgstr "填入自定义的 Cloudflare Anycast IP 地址或网段。"
msgid "清空日志"
msgstr "清空日志"
msgid "日志已清空"
msgstr "日志已清空"
msgid "次优备选 IP"
msgstr "次优备选 IP"
msgid "暂无备选"
msgstr "暂无备选"
msgid "健康看门狗"
msgstr "健康看门狗"
msgid "未运行"
msgstr "未运行"
msgid "故障快速倒换"
msgstr "故障快速倒换"
msgid "启用快速故障倒换"
msgstr "启用快速故障倒换"
msgid "开启后短周期看门狗将持续监控在用节点健康度,发生严重丢包或阻断时自动秒级倒换至次优备选节点。"
msgstr "开启后短周期看门狗将持续监控在用节点健康度,发生严重丢包或阻断时自动秒级倒换至次优备选节点。"
msgid "健康巡检间隔"
msgstr "健康巡检间隔"
msgid "看门狗对在用节点执行极轻量 HTTP 探活的间隔时间。"
msgstr "看门狗对在用节点执行极轻量 HTTP 探活的间隔时间。"
msgid "30 秒"
msgstr "30 秒"
msgid "60 秒"
msgstr "60 秒"
msgid "120 秒"
msgstr "120 秒"
msgid "连续故障判定阈值"
msgstr "连续故障判定阈值"
msgid "探活连续失败达到指定次数后触发次优节点核验与自动倒换,推荐设置为 2 次以避免偶发网络抖动误切换。"
msgstr "探活连续失败达到指定次数后触发次优节点核验与自动倒换,推荐设置为 2 次以避免偶发网络抖动误切换。"
msgid "已倒换"
msgstr "已倒换"
msgid "延迟劣化容忍倍数"
msgstr "延迟劣化容忍倍数"
msgid "当在用节点实测延迟超过基线延迟指定倍数且绝对恶化超过 50ms 时判定为信道拥塞,连续触发将倒换至次优节点。"
msgstr "当在用节点实测延迟超过基线延迟指定倍数且绝对恶化超过 50ms 时判定为信道拥塞,连续触发将倒换至次优节点。"
msgid "1.5 倍"
msgstr "1.5 倍"
msgid "2.0 倍"
msgstr "2.0 倍"
msgid "3.0 倍"
msgstr "3.0 倍"
msgid "禁用"
msgstr "禁用"
msgid "机房区域过滤"
msgstr "机房区域过滤"
msgid "区域过滤模式"
msgstr "区域过滤模式"
msgid "设置对 Cloudflare 数据中心机房的筛选策略。"
msgstr "设置对 Cloudflare 数据中心机房的筛选策略。"
msgid "不限制"
msgstr "不限制"
msgid "排除指定机房"
msgstr "排除指定机房"
msgid "仅限指定机房"
msgstr "仅限指定机房"
msgid "目标机房列表"
msgstr "目标机房列表"
msgid "选择需要排除或仅保留的 Cloudflare 常用数据中心机房。"
msgstr "选择需要排除或仅保留的 Cloudflare 常用数据中心机房。"
msgid "新加坡 SIN"
msgstr "新加坡 SIN"
msgid "香港 HKG"
msgstr "香港 HKG"
msgid "台北 TPE"
msgstr "台北 TPE"
msgid "东京 NRT"
msgstr "东京 NRT"
msgid "大阪 KIX"
msgstr "大阪 KIX"
msgid "首尔 ICN"
msgstr "首尔 ICN"
msgid "吉隆坡 KUL"
msgstr "吉隆坡 KUL"
msgid "曼谷 BKK"
msgstr "曼谷 BKK"
msgid "圣何塞 SJC"
msgstr "圣何塞 SJC"
msgid "洛杉矶 LAX"
msgstr "洛杉矶 LAX"
msgid "法兰克福 FRA"
msgstr "法兰克福 FRA"
msgid "伦敦 LHR"
msgstr "伦敦 LHR"
msgid "自定义机房代码"
msgstr "自定义机房代码"
msgid "填入其他三位大写机场代码,例如 SFO、SEA 等。"
msgstr "填入其他三位大写机场代码,例如 SFO、SEA 等。"
@@ -0,0 +1,12 @@
config cf-optimizer 'settings'
option enabled '0'
option interval '120'
option sample_count '4'
option hysteresis_ms '30'
option concurrency '10'
option ip_source 'builtin'
option failover_enabled '1'
option health_interval '60'
option fail_threshold '2'
option latency_multiplier '2.0'
option colo_filter_mode 'none'
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh /etc/rc.common
START=95
STOP=10
USE_PROCD=1
PROG="/usr/sbin/cf-optimizer"
CONF="cf-optimizer"
start_service() {
config_load "$CONF"
local enabled
config_get_bool enabled settings enabled 0
[ "$enabled" -eq 1 ] || return 0
mkdir -p /var/run/cf-optimizer
"$PROG" sync-hosts >/dev/null 2>&1
procd_open_instance
procd_set_param command "$PROG" daemon
procd_set_param respawn 3600 5 0
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param pidfile /var/run/cf-optimizer/cf-optimizer.pid
procd_close_instance
}
stop_service() {
rm -f /var/run/cf-optimizer/cf-optimizer.pid
}
service_stopped() {
config_load "$CONF"
local enabled
config_get_bool enabled settings enabled 0
if [ "$enabled" != "1" ]; then
"$PROG" clean
fi
}
reload_service() {
"$PROG" sync-hosts >/dev/null 2>&1
stop
start
"$PROG" run >/dev/null 2>&1 &
}
service_triggers() {
procd_add_reload_trigger "$CONF"
}
@@ -0,0 +1,22 @@
#!/bin/sh
[ ! -f "/usr/share/ucitrack/luci-app-ucitrack.json" ] && {
cat > /usr/share/ucitrack/luci-app-ucitrack.json << EEOF
{
"config": "ucitrack",
"init": "ucitrack"
}
EEOF
}
/etc/init.d/cf-optimizer enable >/dev/null 2>&1 || true
uci -q get ucitrack.@cf-optimizer[0] >/dev/null || {
uci add ucitrack cf-optimizer >/dev/null
uci set ucitrack.@cf-optimizer[-1].init='cf-optimizer'
uci commit ucitrack
}
rm -f /tmp/luci-indexcache* 2>/dev/null
rm -f /tmp/luci-modulecache/* 2>/dev/null
exit 0
+835
View File
@@ -0,0 +1,835 @@
#!/bin/sh
CONF="cf-optimizer"
RUN_DIR="/tmp/run/cf-optimizer"
TMP_DIR="/tmp/cf-optimizer"
HOSTS_FILE="/tmp/hosts/cf-optimizer"
LOG_FILE="/var/log/cf-optimizer.log"
DEFAULT_IPS_FILE="/usr/share/cf-optimizer/default_ips.txt"
COLO_MAP_FILE="/usr/share/cf-optimizer/colo_map.json"
PID_FILE="$RUN_DIR/cf-optimizer.pid"
COLO_CACHE_FILE="$RUN_DIR/colo_cache.txt"
STATUS_JSON="$RUN_DIR/status.json"
CURRENT_IP_FILE="$RUN_DIR/current_ip.txt"
PRIMARY_IP_FILE="$RUN_DIR/primary_ip.txt"
STANDBY_IP_FILE="$RUN_DIR/standby_ip.txt"
BASELINE_TCP_FILE="$RUN_DIR/baseline_tcp.txt"
FAIL_STREAK_FILE="$RUN_DIR/fail_streak.txt"
log() {
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $1"
echo "$msg"
mkdir -p "$(dirname "$LOG_FILE")"
echo "$msg" >> "$LOG_FILE"
# 限制日志大小不超过 1000 行
if [ -f "$LOG_FILE" ] && [ $(wc -l < "$LOG_FILE") -gt 1500 ]; then
tail -n 800 "$LOG_FILE" > "$LOG_FILE.tmp" && mv -f "$LOG_FILE.tmp" "$LOG_FILE"
fi
}
get_colo_name() {
local code="$1"
if [ -f "$COLO_MAP_FILE" ] && [ -n "$code" ]; then
local name
name=$(grep -E "\"$code\"[[:space:]]*:" "$COLO_MAP_FILE" 2>/dev/null | head -n 1 | sed -E 's/.*:[[:space:]]*"([^"]+)".*/\1/')
if [ -n "$name" ]; then
echo "$name"
return
fi
fi
echo "${code:-未知}"
}
update_colo_cache() {
local ip="$1"
local colo="$2"
[ -z "$ip" ] || [ -z "$colo" ] && return
mkdir -p "$RUN_DIR"
touch "$COLO_CACHE_FILE"
grep -v "^$ip[[:space:]]" "$COLO_CACHE_FILE" > "$COLO_CACHE_FILE.tmp" 2>/dev/null
echo "$ip $colo" >> "$COLO_CACHE_FILE.tmp"
mv -f "$COLO_CACHE_FILE.tmp" "$COLO_CACHE_FILE"
}
get_cached_colo() {
local ip="$1"
[ ! -f "$COLO_CACHE_FILE" ] && return
grep "^$ip[[:space:]]" "$COLO_CACHE_FILE" 2>/dev/null | tail -n 1 | awk '{print $2}'
}
clean_hosts() {
mkdir -p "$RUN_DIR"
if [ -f "$HOSTS_FILE" ]; then
rm -f "$HOSTS_FILE"
killall -HUP dnsmasq 2>/dev/null
log "已清空优选 hosts 映射并重载 dnsmasq"
fi
rm -f "$CURRENT_IP_FILE" "$PRIMARY_IP_FILE" "$STANDBY_IP_FILE" "$BASELINE_TCP_FILE" "$FAIL_STREAK_FILE"
rm -rf "$RUN_DIR/task.lock"
cat <<EOF > "$STATUS_JSON"
{
"running": false,
"last_update": "",
"best_ip": "",
"best_colo": "",
"best_tcp_lat": 0,
"best_tls_lat": 0,
"standby_ip": "",
"standby_colo": "",
"standby_tcp_lat": 0,
"watchdog_status": "未运行",
"domains": [],
"rankings": []
}
EOF
}
sync_hosts() {
. /lib/functions.sh
config_load "$CONF"
local enabled
config_get_bool enabled settings enabled 0
if [ "$enabled" != "1" ]; then
clean_hosts
return 0
fi
local domains=""
append_sync_domain() {
local d="$1"
[ -n "$d" ] && domains="$domains $d"
}
config_list_foreach settings domains append_sync_domain
if [ -z "$domains" ]; then
clean_hosts
return 0
fi
local current_ip=""
[ -f "$CURRENT_IP_FILE" ] && current_ip=$(cat "$CURRENT_IP_FILE" 2>/dev/null)
if [ -z "$current_ip" ]; then
local cand_tmp="$TMP_DIR/cand_init.txt"
mkdir -p "$TMP_DIR"
load_candidate_ips "$cand_tmp"
current_ip=$(head -n 1 "$cand_tmp" 2>/dev/null)
rm -f "$cand_tmp"
fi
[ -z "$current_ip" ] && return 0
local hosts_tmp="$HOSTS_FILE.tmp"
mkdir -p "$(dirname "$HOSTS_FILE")"
> "$hosts_tmp"
echo "# Auto-generated by cf-optimizer sync on $(date '+%Y-%m-%d %H:%M:%S')" >> "$hosts_tmp"
for d in $domains; do
echo "$current_ip $d" >> "$hosts_tmp"
done
mv -f "$hosts_tmp" "$HOSTS_FILE"
killall -HUP dnsmasq 2>/dev/null
log "已即时同步域名配置至 hosts 并重载 dnsmasq (定向至: $current_ip)"
}
load_candidate_ips() {
local out_file="$1"
> "$out_file"
local ip_source
config_get ip_source settings ip_source "builtin"
if [ "$ip_source" = "builtin" ] || [ "$ip_source" = "mixed" ]; then
if [ -f "$DEFAULT_IPS_FILE" ]; then
grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' "$DEFAULT_IPS_FILE" >> "$out_file"
fi
fi
if [ "$ip_source" = "custom" ] || [ "$ip_source" = "mixed" ]; then
append_custom_ip() {
local item="$1"
[ -z "$item" ] && return
# 若为 CIDR 简单网段 (如 /24),取前 3 个 IP
case "$item" in
*/*)
local base_prefix=$(echo "$item" | cut -d'/' -f1 | cut -d'.' -f1-3)
for host in 1 2 3; do
echo "$base_prefix.$host" >> "$out_file"
done
;;
*)
echo "$item" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' >> "$out_file"
;;
esac
}
config_list_foreach settings custom_ips append_custom_ip
fi
# 兜底:若文件为空,填入稳定官方公共 IP
if [ ! -s "$out_file" ]; then
echo "104.16.0.1" >> "$out_file"
echo "104.17.0.1" >> "$out_file"
echo "104.18.0.1" >> "$out_file"
echo "172.64.0.1" >> "$out_file"
fi
# 排序去重
sort -u "$out_file" > "$out_file.tmp" && mv -f "$out_file.tmp" "$out_file"
}
# 粗筛单节点探测
probe_fast() {
local ip="$1"
local res_dir="$2"
# 仅测 TCP 握手
local connect_time
connect_time=$(curl -s -k -o /dev/null --connect-timeout 1 -m 2 -w "%{time_connect}\n" \
--resolve "speed.cloudflare.com:443:$ip" "https://speed.cloudflare.com/__down?bytes=0" 2>/dev/null)
if [ $? -eq 0 ] && [ -n "$connect_time" ] && [ "$connect_time" != "0.000000" ]; then
local ms=$(awk "BEGIN {printf \"%08.2f\", $connect_time * 1000}")
echo "$ms $ip" > "$res_dir/$ip"
fi
}
run_optimization() {
. /lib/functions.sh
config_load "$CONF"
local enabled
config_get_bool enabled settings enabled 0
if [ "$enabled" != "1" ]; then
log "服务未启用,退出任务"
clean_hosts
return 0
fi
local domains=""
append_domain() {
local d="$1"
[ -n "$d" ] && domains="$domains $d"
}
config_list_foreach settings domains append_domain
if [ -z "$domains" ]; then
log "未配置目标加速域名,跳过优选"
return 0
fi
local concurrency sample_count hysteresis_ms
config_get concurrency settings concurrency 10
config_get sample_count settings sample_count 4
config_get hysteresis_ms settings hysteresis_ms 30
local colo_filter_mode
config_get colo_filter_mode settings colo_filter_mode "none"
local target_colos=""
append_target_colo() {
local item="$1"
[ -z "$item" ] && return
for c in $item; do
local clean_c=$(echo "$c" | tr 'a-z' 'A-Z' | tr -d ' \t\r\n')
if [ -n "$clean_c" ]; then
case " $target_colos " in
*" $clean_c "*) ;;
*) target_colos="$target_colos $clean_c" ;;
esac
fi
done
}
if [ "$colo_filter_mode" != "none" ]; then
local multi_colos=""
config_get multi_colos settings colo_list ""
[ -n "$multi_colos" ] && append_target_colo "$multi_colos"
config_list_foreach settings colo_list append_target_colo
config_list_foreach settings custom_colos append_target_colo
fi
check_colo_allowed() {
local colo="$1"
[ -z "$colo" ] && return 0
[ "$colo_filter_mode" = "none" ] && return 0
local match=0
for c in $target_colos; do
if [ "$c" = "$colo" ]; then
match=1
break
fi
done
if [ "$colo_filter_mode" = "exclude" ]; then
[ "$match" -eq 1 ] && return 1 || return 0
elif [ "$colo_filter_mode" = "include" ]; then
[ "$match" -eq 1 ] && return 0 || return 1
fi
return 0
}
log "开始执行优选任务,加速域名数: $(echo $domains | wc -w),机房策略: $colo_filter_mode${target_colos:+ ($target_colos)}"
local lock_dir="$RUN_DIR/task.lock"
if ! mkdir "$lock_dir" 2>/dev/null; then
local lock_pid=""
[ -f "$lock_dir/pid" ] && lock_pid=$(cat "$lock_dir/pid" 2>/dev/null)
if [ -n "$lock_pid" ] && ! kill -0 "$lock_pid" 2>/dev/null; then
log "检测到残留任务锁 (PID: $lock_pid 已退出),自动释放死锁"
rm -rf "$lock_dir"
mkdir "$lock_dir" 2>/dev/null || return 0
else
log "已有优选任务正在运行中,跳过本次执行"
return 0
fi
fi
echo $$ > "$lock_dir/pid"
mkdir -p "$TMP_DIR/fast" "$TMP_DIR/deep" "$RUN_DIR"
rm -rf "$TMP_DIR/fast"/* "$TMP_DIR/deep"/*
local cand_file="$TMP_DIR/candidates.txt"
load_candidate_ips "$cand_file"
local total_cands=$(wc -l < "$cand_file")
log "阶段一:开始并发初筛,候选节点总数: $total_cands,并发度: $concurrency"
# 并发初筛(采用 POSIX 批次等待机制,确保在任何非交互式 Shell 环境下严格限流)
local running=0
while read -r ip; do
[ -z "$ip" ] && continue
probe_fast "$ip" "$TMP_DIR/fast" &
running=$((running + 1))
if [ "$running" -ge "$concurrency" ]; then
wait
running=0
fi
done < "$cand_file"
wait
# 汇总初筛候选,结合机房缓存预先剔除黑名单机房
local fast_sorted_file="$TMP_DIR/fast_sorted.txt"
cat "$TMP_DIR/fast"/* 2>/dev/null | sort -n | head -n 30 > "$fast_sorted_file"
local top_selected_file="$TMP_DIR/top_selected_ips.txt"
> "$top_selected_file"
local picked_count=0
while read -r _lat ip; do
[ -z "$ip" ] && continue
local cached_colo=$(get_cached_colo "$ip")
if [ -n "$cached_colo" ] && ! check_colo_allowed "$cached_colo"; then
log "初筛预过滤: 节点 $ip ($cached_colo) 命中机房排除策略,自动略过"
continue
fi
echo "$ip" >> "$top_selected_file"
picked_count=$((picked_count + 1))
[ "$picked_count" -ge 10 ] && break
done < "$fast_sorted_file"
local top_count=$(wc -l < "$top_selected_file")
if [ "$top_count" -eq 0 ]; then
log "阶段一初筛无可用候选节点(或全部被机房区域策略过滤),请检查配置"
rm -rf "$TMP_DIR"
rm -rf "$lock_dir"
return 1
fi
local probe_domain=$(echo "$domains" | awk '{print $1}')
[ -z "$probe_domain" ] && probe_domain="speed.cloudflare.com"
log "阶段一初筛完成,选出 $top_count 个候选节点进入精测采样,探活域名: $probe_domain(每节点轮询交叉采样 $sample_count 轮)"
# 阶段二:轮询交叉时序采样(跨越完整测试时段,精准捕获阶段性丢包与网络毛刺)
mkdir -p "$TMP_DIR/samples"
for seq in $(seq 1 "$sample_count"); do
while read -r ip; do
[ -z "$ip" ] && continue
[ -f "$TMP_DIR/blocked_${ip}" ] && continue
[ -f "$TMP_DIR/colo_excluded_${ip}" ] && continue
local raw_header="$TMP_DIR/header_${ip}_${seq}.txt"
local metrics
metrics=$(curl -s -k -I -o /dev/null --connect-timeout 2 -m 3 \
-A "Mozilla/5.0 (compatible; Cloudflare-Optimizer/1.0)" \
--resolve "$probe_domain:443:$ip" "https://$probe_domain/" \
-D "$raw_header" \
-w "%{time_connect} %{time_appconnect} %{http_code}\n" 2>/dev/null)
local tcp_sec=$(echo "$metrics" | awk '{print $1}')
local tls_sec=$(echo "$metrics" | awk '{print $2}')
local http_code=$(echo "$metrics" | awk '{print $3}')
if [ "$http_code" = "403" ]; then
touch "$TMP_DIR/blocked_${ip}"
rm -f "$raw_header"
continue
fi
local last_colo=""
if [ -f "$raw_header" ]; then
local cf_ray=$(grep -i '^cf-ray:' "$raw_header" 2>/dev/null | tr -d '\r\n')
if [ -n "$cf_ray" ]; then
last_colo=$(echo "$cf_ray" | sed -E 's/.*-([A-Za-z0-9]+).*/\1/' | tr 'a-z' 'A-Z')
fi
rm -f "$raw_header"
fi
if [ -n "$last_colo" ]; then
update_colo_cache "$ip" "$last_colo"
if ! check_colo_allowed "$last_colo"; then
log "节点 $ip 机房 ($last_colo) 不符合区域过滤策略 ($colo_filter_mode),已终止采样并排除"
touch "$TMP_DIR/colo_excluded_${ip}"
continue
fi
fi
if [ -n "$http_code" ] && [ "$http_code" != "000" ] && [ -n "$tcp_sec" ] && [ "$tcp_sec" != "0.000000" ]; then
local tcp_ms=$(awk "BEGIN {printf \"%.2f\", $tcp_sec * 1000}")
local tls_ms=$(awk "BEGIN {printf \"%.2f\", $tls_sec * 1000}")
echo "1|$tcp_ms|$tls_ms|$last_colo" >> "$TMP_DIR/samples/$ip"
else
echo "0|0|0|$last_colo" >> "$TMP_DIR/samples/$ip"
fi
done < "$top_selected_file"
done
# 汇总统计各节点采样指标
while read -r ip; do
[ -z "$ip" ] && continue
if [ -f "$TMP_DIR/blocked_${ip}" ]; then
log "节点 $ip 触发权限阻断 (HTTP 403/企业限制),已自动排除"
continue
fi
if [ -f "$TMP_DIR/colo_excluded_${ip}" ]; then
continue
fi
local sample_file="$TMP_DIR/samples/$ip"
[ ! -f "$sample_file" ] && continue
local success_count=0
local total_tcp=0
local total_tls=0
local min_tcp=99999
local max_tcp=0
local last_colo=""
while IFS='|' read -r ok tcp_ms tls_ms colo; do
[ -n "$colo" ] && last_colo="$colo"
if [ "$ok" = "1" ]; then
success_count=$((success_count + 1))
total_tcp=$(awk "BEGIN {printf \"%.2f\", $total_tcp + $tcp_ms}")
total_tls=$(awk "BEGIN {printf \"%.2f\", $total_tls + $tls_ms}")
min_tcp=$(awk "BEGIN {if ($tcp_ms < $min_tcp) print $tcp_ms; else print $min_tcp}")
max_tcp=$(awk "BEGIN {if ($tcp_ms > $max_tcp) print $tcp_ms; else print $max_tcp}")
fi
done < "$sample_file"
if [ -z "$last_colo" ]; then
last_colo=$(get_cached_colo "$ip")
fi
if ! check_colo_allowed "$last_colo"; then
log "节点 $ip 机房 ($last_colo) 最终区域校验未通过,舍弃"
continue
fi
local loss_rate=$(awk "BEGIN {printf \"%.1f\", (1 - $success_count / $sample_count) * 100}")
if [ "$success_count" -gt 0 ]; then
local avg_tcp=$(awk "BEGIN {printf \"%.2f\", $total_tcp / $success_count}")
local avg_tls=$(awk "BEGIN {printf \"%.2f\", $total_tls / $success_count}")
local jitter=$(awk "BEGIN {printf \"%.2f\", $max_tcp - $min_tcp}")
# 综合评分:平均时延 + 抖动*0.5 + 丢包率*10(采用固定宽度 0 填充)
local score=$(awk "BEGIN {printf \"%08.2f\", $avg_tcp + $jitter * 0.5 + $loss_rate * 10}")
local colo_disp=$(get_colo_name "$last_colo")
echo "$score|$ip|$avg_tcp|$min_tcp|$max_tcp|$avg_tls|$loss_rate%|$colo_disp|$last_colo" >> "$TMP_DIR/deep_results.txt"
fi
done < "$top_selected_file"
if [ ! -s "$TMP_DIR/deep_results.txt" ]; then
log "精测采样后无可用健康节点"
rm -rf "$TMP_DIR"
rm -rf "$lock_dir"
return 1
fi
# 排序:按 score 从低到高数值排序
sort -n "$TMP_DIR/deep_results.txt" > "$TMP_DIR/deep_sorted.txt"
local best_line=$(head -n 1 "$TMP_DIR/deep_sorted.txt")
local best_score=$(echo "$best_line" | cut -d'|' -f1)
local best_ip=$(echo "$best_line" | cut -d'|' -f2)
local best_avg_tcp=$(echo "$best_line" | cut -d'|' -f3)
local best_min_tcp=$(echo "$best_line" | cut -d'|' -f4)
local best_max_tcp=$(echo "$best_line" | cut -d'|' -f5)
local best_avg_tls=$(echo "$best_line" | cut -d'|' -f6)
local best_loss=$(echo "$best_line" | cut -d'|' -f7)
local best_colo=$(echo "$best_line" | cut -d'|' -f8)
log "测速完成,最优候选: $best_ip ($best_colo), 平均延迟: ${best_avg_tcp}ms, 丢包率: $best_loss"
# 迟滞逻辑判断
local current_ip=""
[ -f "$CURRENT_IP_FILE" ] && current_ip=$(cat "$CURRENT_IP_FILE")
local final_ip="$best_ip"
local final_colo="$best_colo"
local final_tcp="$best_avg_tcp"
local final_tls="$best_avg_tls"
if [ -n "$current_ip" ] && [ "$current_ip" != "$best_ip" ]; then
local cur_info=$(grep "|$current_ip|" "$TMP_DIR/deep_sorted.txt" 2>/dev/null)
local cur_colo_allowed=1
local cur_cached_colo=$(get_cached_colo "$current_ip")
if [ -n "$cur_cached_colo" ] && ! check_colo_allowed "$cur_cached_colo"; then
cur_colo_allowed=0
fi
if [ -n "$cur_info" ] && [ "$cur_colo_allowed" -eq 1 ]; then
local cur_tcp=$(echo "$cur_info" | cut -d'|' -f3)
local cur_loss=$(echo "$cur_info" | cut -d'|' -f7)
local cur_colo=$(echo "$cur_info" | cut -d'|' -f8)
if [ "$cur_loss" = "0.0%" ]; then
# 测算相比当前在用 IP 的绝对时延差 (ms)
local diff_ms=$(awk "BEGIN {if ($cur_tcp > $best_avg_tcp) printf \"%.2f\", $cur_tcp - $best_avg_tcp; else print 0}")
local is_better=$(awk "BEGIN {print ($diff_ms >= $hysteresis_ms) ? 1 : 0}")
if [ "$is_better" -eq 0 ]; then
log "新优选 IP 优化幅度 (${diff_ms}ms) 未达迟滞阈值 (${hysteresis_ms}ms),保持当前在用 IP: $current_ip"
final_ip="$current_ip"
final_colo="$cur_colo"
final_tcp="$cur_tcp"
final_tls=$(echo "$cur_info" | cut -d'|' -f6)
else
log "新优选 IP 性能显著提升 (时延改善 ${diff_ms}ms >= ${hysteresis_ms}ms),触发切换"
fi
else
log "当前在用 IP 发生丢包 ($cur_loss),强制切换至新最优 IP: $best_ip"
fi
else
if [ "$cur_colo_allowed" -eq 0 ]; then
log "当前在用 IP ($current_ip / $cur_cached_colo) 已被机房过滤策略排除,强制切换至新最优 IP: $best_ip"
fi
fi
fi
# 原子写入 /tmp/hosts/cf-optimizer
local hosts_tmp="$HOSTS_FILE.tmp"
mkdir -p "$(dirname "$HOSTS_FILE")"
> "$hosts_tmp"
echo "# Auto-generated by cf-optimizer on $(date '+%Y-%m-%d %H:%M:%S')" >> "$hosts_tmp"
for d in $domains; do
echo "$final_ip $d" >> "$hosts_tmp"
done
mv -f "$hosts_tmp" "$HOSTS_FILE"
echo "$final_ip" > "$CURRENT_IP_FILE"
echo "$final_ip" > "$PRIMARY_IP_FILE"
echo "0" > "$FAIL_STREAK_FILE"
# 选拔次优备选节点(排名紧随其后且 IP 不同的健康节点)
local standby_ip=""
local standby_colo="无备选"
local standby_tcp="-"
while IFS='|' read -r _sc _ip _atip _minip _maxip _attls _lrate _cdisp _craw; do
if [ -n "$_ip" ] && [ "$_ip" != "$final_ip" ]; then
standby_ip="$_ip"
standby_colo="$_cdisp"
standby_tcp="$_atip"
break
fi
done < "$TMP_DIR/deep_sorted.txt"
echo "$standby_ip|$standby_colo|$standby_tcp" > "$STANDBY_IP_FILE"
echo "$final_tcp" > "$BASELINE_TCP_FILE"
# 发送 HUP 信号通知 dnsmasq 毫秒级重载
killall -HUP dnsmasq 2>/dev/null
log "已更新 $HOSTS_FILE 并热重载 dnsmasq,在用主选: $final_ip ($final_colo),次优备选: ${standby_ip:-无} ($standby_colo)"
# 组装状态 JSON 给 LuCI 前端消费
local now_str=$(date '+%Y-%m-%d %H:%M:%S')
local domain_json="["
local first_d=1
for d in $domains; do
[ "$first_d" -eq 0 ] && domain_json="$domain_json,"
domain_json="$domain_json{\"domain\":\"$d\",\"ip\":\"$final_ip\",\"colo\":\"$final_colo\",\"tcp_lat\":\"$final_tcp ms\",\"tls_lat\":\"$final_tls ms\",\"status\":\"正常\"}"
first_d=0
done
domain_json="$domain_json]"
local rankings_json="["
local first_r=1
local rank_idx=1
while IFS='|' read -r sc ip atip minip maxip attls lrate cdisp craw; do
[ -z "$ip" ] && continue
[ "$first_r" -eq 0 ] && rankings_json="$rankings_json,"
rankings_json="$rankings_json{\"rank\":$rank_idx,\"ip\":\"$ip\",\"colo\":\"$cdisp\",\"avg_lat\":\"$atip ms\",\"lat_range\":\"$minip / $maxip ms\",\"loss_rate\":\"$lrate\"}"
first_r=0
rank_idx=$((rank_idx + 1))
[ "$rank_idx" -gt 5 ] && break
done < "$TMP_DIR/deep_sorted.txt"
rankings_json="$rankings_json]"
cat <<EOF > "$STATUS_JSON.tmp"
{
"running": true,
"last_update": "$now_str",
"best_ip": "$final_ip",
"best_colo": "$final_colo",
"best_tcp_lat": "$final_tcp ms",
"best_tls_lat": "$final_tls ms",
"standby_ip": "$standby_ip",
"standby_colo": "$standby_colo",
"standby_tcp_lat": "$standby_tcp ms",
"watchdog_status": "正常监控中",
"domains": $domain_json,
"rankings": $rankings_json
}
EOF
mv -f "$STATUS_JSON.tmp" "$STATUS_JSON"
rm -rf "$TMP_DIR"
rm -rf "$lock_dir"
return 0
}
apply_fast_failover() {
local target_ip="$1"
local target_colo="$2"
local target_tcp="${3:--}"
. /lib/functions.sh
config_load "$CONF"
local domains=""
append_failover_domain() {
local d="$1"
[ -n "$d" ] && domains="$domains $d"
}
config_list_foreach settings domains append_failover_domain
local hosts_tmp="$HOSTS_FILE.tmp"
mkdir -p "$(dirname "$HOSTS_FILE")"
> "$hosts_tmp"
echo "# Auto-generated by cf-optimizer failover on $(date '+%Y-%m-%d %H:%M:%S')" >> "$hosts_tmp"
for d in $domains; do
echo "$target_ip $d" >> "$hosts_tmp"
done
mv -f "$hosts_tmp" "$HOSTS_FILE"
echo "$target_ip" > "$CURRENT_IP_FILE"
echo "" > "$STANDBY_IP_FILE"
if [ -n "$target_tcp" ] && [ "$target_tcp" != "-" ]; then
echo "$target_tcp" > "$BASELINE_TCP_FILE"
else
rm -f "$BASELINE_TCP_FILE"
fi
killall -HUP dnsmasq 2>/dev/null
log "已将 hosts 紧急倒换至次优节点: $target_ip ($target_colo) 并重载 dnsmasq"
if [ -f "$STATUS_JSON" ]; then
local now_str=$(date '+%Y-%m-%d %H:%M:%S')
local domain_json="["
local first_d=1
for d in $domains; do
[ "$first_d" -eq 0 ] && domain_json="$domain_json,"
domain_json="$domain_json{\"domain\":\"$d\",\"ip\":\"$target_ip\",\"colo\":\"$target_colo\",\"tcp_lat\":\"$target_tcp ms\",\"tls_lat\":\"-\",\"status\":\"已倒换\"}"
first_d=0
done
domain_json="$domain_json]"
local tmp_status="$STATUS_JSON.tmp"
sed -E "s/\"best_ip\":[[:space:]]*\"[^\"]*\"/\"best_ip\": \"$target_ip\"/" "$STATUS_JSON" | \
sed -E "s/\"best_colo\":[[:space:]]*\"[^\"]*\"/\"best_colo\": \"$target_colo\"/" | \
sed -E "s/\"standby_ip\":[[:space:]]*\"[^\"]*\"/\"standby_ip\": \"\"/" | \
sed -E "s/\"standby_colo\":[[:space:]]*\"[^\"]*\"/\"standby_colo\": \"已倒换转正\"/" | \
sed -E "s/\"last_update\":[[:space:]]*\"[^\"]*\"/\"last_update\": \"$now_str (已触发倒换)\"/" | \
sed -E "s/\"watchdog_status\":[[:space:]]*\"[^\"]*\"/\"watchdog_status\": \"已触发故障倒换\"/" | \
sed -E "s/\"domains\":[[:space:]]*\[[^]]*\]/\"domains\": $domain_json/" > "$tmp_status" 2>/dev/null
[ -s "$tmp_status" ] && mv -f "$tmp_status" "$STATUS_JSON"
fi
}
check_health_and_failover() {
[ ! -f "$CURRENT_IP_FILE" ] && return 0
local current_ip=$(cat "$CURRENT_IP_FILE" 2>/dev/null)
[ -z "$current_ip" ] && return 0
. /lib/functions.sh
config_load "$CONF"
local failover_enabled fail_threshold latency_multiplier
config_get_bool failover_enabled settings failover_enabled 1
[ "$failover_enabled" != "1" ] && return 0
config_get fail_threshold settings fail_threshold 2
config_get latency_multiplier settings latency_multiplier "2.0"
local domains=""
append_check_domain() {
local d="$1"
[ -n "$d" ] && domains="$domains $d"
}
config_list_foreach settings domains append_check_domain
local probe_domain=$(echo "$domains" | awk '{print $1}')
[ -z "$probe_domain" ] && probe_domain="speed.cloudflare.com"
local metrics
metrics=$(curl -s -k -I -o /dev/null --connect-timeout 2 -m 3 \
-A "Mozilla/5.0 (compatible; Cloudflare-Optimizer/1.0)" \
--resolve "$probe_domain:443:$current_ip" "https://$probe_domain/" \
-w "%{time_connect} %{http_code}\n" 2>/dev/null)
local tcp_sec=$(echo "$metrics" | awk '{print $1}')
local http_code=$(echo "$metrics" | awk '{print $2}')
local cur_tcp_ms=0
if [ -n "$tcp_sec" ] && [ "$tcp_sec" != "0.000000" ]; then
cur_tcp_ms=$(awk "BEGIN {printf \"%.2f\", $tcp_sec * 1000}")
fi
local baseline_tcp=0
[ -f "$BASELINE_TCP_FILE" ] && baseline_tcp=$(cat "$BASELINE_TCP_FILE" 2>/dev/null)
baseline_tcp=${baseline_tcp:-0}
local streak=0
[ -f "$FAIL_STREAK_FILE" ] && streak=$(cat "$FAIL_STREAK_FILE" 2>/dev/null)
streak=${streak:-0}
local is_abnormal=0
local abnormal_reason=""
if [ -z "$http_code" ] || [ "$http_code" = "000" ] || [ "$http_code" = "403" ]; then
is_abnormal=1
abnormal_reason="HTTP ${http_code:-000} 阻断或超时"
elif [ "$latency_multiplier" != "0" ] && [ "$latency_multiplier" != "0.0" ]; then
local is_degraded
is_degraded=$(awk "BEGIN {
if ($baseline_tcp > 0 && $cur_tcp_ms >= ($baseline_tcp * $latency_multiplier) && ($cur_tcp_ms - $baseline_tcp) >= 50)
print 1;
else
print 0;
}")
if [ "$is_degraded" -eq 1 ]; then
is_abnormal=1
abnormal_reason="延迟严重劣化 (${cur_tcp_ms}ms >= 基线 ${baseline_tcp}ms x ${latency_multiplier})"
fi
fi
if [ "$is_abnormal" -eq 0 ]; then
if [ "$streak" -gt 0 ]; then
log "主用优选节点 $current_ip 探活恢复正常 (HTTP $http_code, TCP ${cur_tcp_ms}ms)"
echo "0" > "$FAIL_STREAK_FILE"
fi
return 0
fi
streak=$((streak + 1))
echo "$streak" > "$FAIL_STREAK_FILE"
log "在用优选节点 $current_ip 探活异常 [$abnormal_reason],连续异常: $streak/$fail_threshold"
if [ "$streak" -ge "$fail_threshold" ]; then
echo "0" > "$FAIL_STREAK_FILE"
local standby_info=""
[ -f "$STANDBY_IP_FILE" ] && standby_info=$(cat "$STANDBY_IP_FILE" 2>/dev/null)
local standby_ip=$(echo "$standby_info" | cut -d'|' -f1)
local standby_colo=$(echo "$standby_info" | cut -d'|' -f2)
if [ -n "$standby_ip" ] && [ "$standby_ip" != "$current_ip" ]; then
log "正在核验次优备选节点健康度: $standby_ip ($standby_colo)"
local s_metrics
s_metrics=$(curl -s -k -I -o /dev/null --connect-timeout 2 -m 3 \
-A "Mozilla/5.0 (compatible; Cloudflare-Optimizer/1.0)" \
--resolve "$probe_domain:443:$standby_ip" "https://$probe_domain/" \
-w "%{time_connect} %{http_code}\n" 2>/dev/null)
local s_tcp_sec=$(echo "$s_metrics" | awk '{print $1}')
local s_code=$(echo "$s_metrics" | awk '{print $2}')
local s_tcp_ms="-"
[ -n "$s_tcp_sec" ] && [ "$s_tcp_sec" != "0.000000" ] && s_tcp_ms=$(awk "BEGIN {printf \"%.2f\", $s_tcp_sec * 1000}")
if [ -n "$s_code" ] && [ "$s_code" != "000" ] && [ "$s_code" != "403" ]; then
log "主用节点异常,次优备选节点验证健康 (HTTP $s_code, TCP ${s_tcp_ms}ms),秒级倒换至次优节点"
apply_fast_failover "$standby_ip" "$standby_colo" "$s_tcp_ms"
return 0
else
log "次优备选节点同样不可用 (HTTP ${s_code:-000}),提前拉起全网节点紧急重测"
fi
else
log "未配置次优备选节点,提前拉起全网节点紧急重测"
fi
run_optimization
fi
}
daemon_loop() {
echo $$ > "$PID_FILE"
trap 'rm -f "$PID_FILE"; exit 0' INT TERM EXIT
log "守护进程启动 [PID: $$]"
local last_full_run=0
while true; do
. /lib/functions.sh
config_load "$CONF"
local enabled
config_get_bool enabled settings enabled 0
if [ "$enabled" != "1" ]; then
log "配置已禁用,清理并退出守护进程"
clean_hosts
break
fi
local interval failover_enabled health_interval
config_get interval settings interval 120
[ "$interval" -lt 10 ] && interval=10
local full_interval_sec=$((interval * 60))
config_get_bool failover_enabled settings failover_enabled 1
config_get health_interval settings health_interval 60
[ "$health_interval" -lt 15 ] && health_interval=15
local now=$(date +%s)
if [ "$last_full_run" -eq 0 ] || [ $((now - last_full_run)) -ge "$full_interval_sec" ]; then
run_optimization
last_full_run=$(date +%s)
elif [ "$failover_enabled" = "1" ]; then
check_health_and_failover
fi
local sleep_sec="$health_interval"
if [ "$failover_enabled" != "1" ]; then
sleep_sec=30
fi
local elapsed=0
while [ "$elapsed" -lt "$sleep_sec" ]; do
sleep 5 &
wait $!
elapsed=$((elapsed + 5))
done
done
}
case "$1" in
run)
run_optimization
;;
sync-hosts)
sync_hosts
;;
daemon)
daemon_loop
;;
clean)
clean_hosts
;;
status)
if [ -f "$STATUS_JSON" ]; then
cat "$STATUS_JSON"
else
echo '{"running":false,"domains":[],"rankings":[]}'
fi
;;
*)
echo "用法: $0 {run|sync-hosts|daemon|clean|status}"
exit 1
;;
esac
@@ -0,0 +1,58 @@
{
"HKG": "香港 HKG",
"TPE": "台北 TPE",
"KHH": "高雄 KHH",
"NRT": "东京 NRT",
"HND": "东京羽田 HND",
"KIX": "大阪 KIX",
"FUK": "福冈 FUK",
"ICN": "首尔 ICN",
"SIN": "新加坡 SIN",
"BKK": "曼谷 BKK",
"KUL": "吉隆坡 KUL",
"JHB": "新山 JHB",
"MNL": "马尼拉 MNL",
"SGN": "胡志明市 SGN",
"HAN": "河内 HAN",
"CGK": "雅加达 CGK",
"DPS": "巴厘岛 DPS",
"SJC": "圣何塞 SJC",
"SFO": "旧金山 SFO",
"LAX": "洛杉矶 LAX",
"SEA": "西雅图 SEA",
"PDX": "波特兰 PDX",
"DEN": "丹佛 DEN",
"PHX": "菲尼克斯 PHX",
"DFW": "达拉斯 DFW",
"IAH": "休斯顿 IAH",
"ORD": "芝加哥 ORD",
"ATL": "亚特兰大 ATL",
"MIA": "迈阿密 MIA",
"IAD": "华盛顿 IAD",
"EWR": "纽瓦克 EWR",
"JFK": "纽约 JFK",
"BOS": "波士顿 BOS",
"YVR": "温哥华 YVR",
"YYZ": "多伦多 YYZ",
"YUL": "蒙特利尔 YUL",
"FRA": "法兰克福 FRA",
"LHR": "伦敦 LHR",
"AMS": "阿姆斯特丹 AMS",
"CDG": "巴黎 CDG",
"MAD": "马德里 MAD",
"MXP": "米兰 MXP",
"ZRH": "苏黎世 ZRH",
"VIE": "维也纳 VIE",
"ARN": "斯德哥尔摩 ARN",
"HEL": "赫尔辛基 HEL",
"CPH": "哥本哈根 CPH",
"WAW": "华沙 WAW",
"PRG": "布拉格 PRG",
"SYD": "悉尼 SYD",
"MEL": "墨尔本 MEL",
"BNE": "布里斯班 BNE",
"PER": "珀斯 PER",
"AKL": "奥克兰 AKL",
"DXB": "迪拜 DXB",
"DOH": "多哈 DOH"
}
@@ -0,0 +1,102 @@
104.16.0.1
104.16.1.1
104.16.8.1
104.16.16.1
104.16.24.1
104.16.32.1
104.16.48.1
104.16.64.1
104.16.80.1
104.16.96.1
104.16.112.1
104.16.128.1
104.16.144.1
104.16.160.1
104.16.176.1
104.16.192.1
104.16.208.1
104.16.224.1
104.16.240.1
104.17.0.1
104.17.16.1
104.17.32.1
104.17.64.1
104.17.96.1
104.17.128.1
104.17.160.1
104.17.192.1
104.17.224.1
104.18.0.1
104.18.16.1
104.18.32.1
104.18.64.1
104.18.96.1
104.18.128.1
104.18.160.1
104.18.192.1
104.18.224.1
104.19.0.1
104.19.16.1
104.19.32.1
104.19.64.1
104.19.96.1
104.19.128.1
104.19.160.1
104.19.192.1
104.19.224.1
104.20.0.1
104.20.32.1
104.20.64.1
104.20.96.1
104.20.128.1
104.20.160.1
104.20.192.1
104.21.0.1
104.21.16.1
104.21.32.1
104.21.64.1
104.21.96.1
104.22.0.1
104.22.32.1
104.22.64.1
104.24.0.1
104.24.32.1
104.25.0.1
104.25.32.1
104.26.0.1
104.26.32.1
104.27.0.1
104.27.32.1
172.64.0.1
172.64.16.1
172.64.32.1
172.64.64.1
172.64.96.1
172.64.128.1
172.64.160.1
172.64.192.1
172.65.0.1
172.65.32.1
172.66.0.1
172.66.32.1
172.67.0.1
172.67.32.1
172.67.64.1
172.67.128.1
162.158.0.1
162.158.16.1
162.158.32.1
162.158.64.1
162.158.128.1
108.162.192.1
108.162.208.1
108.162.224.1
141.101.64.1
141.101.96.1
141.101.120.1
190.93.240.1
197.234.240.1
198.41.128.1
198.41.160.1
198.41.192.1
198.41.224.1
@@ -0,0 +1,37 @@
{
"admin/services/cf-optimizer": {
"title": "Cloudflare 优选加速",
"order": 65,
"action": {
"type": "firstchild"
},
"depends": {
"acl": [ "luci-app-cf-optimizer" ],
"uci": { "cf-optimizer": true }
}
},
"admin/services/cf-optimizer/overview": {
"title": "状态概览",
"order": 10,
"action": {
"type": "view",
"path": "cf-optimizer/overview"
}
},
"admin/services/cf-optimizer/settings": {
"title": "参数配置",
"order": 20,
"action": {
"type": "view",
"path": "cf-optimizer/settings"
}
},
"admin/services/cf-optimizer/log": {
"title": "运行日志",
"order": 30,
"action": {
"type": "view",
"path": "cf-optimizer/log"
}
}
}
@@ -0,0 +1,28 @@
{
"luci-app-cf-optimizer": {
"description": "Grant access to cf-optimizer LuCI procedures",
"read": {
"ubus": {
"luci.cf-optimizer": [
"get_status",
"get_logs"
]
},
"uci": [
"cf-optimizer"
]
},
"write": {
"ubus": {
"luci.cf-optimizer": [
"run_once",
"clear_logs",
"service_action"
]
},
"uci": [
"cf-optimizer"
]
}
}
}
@@ -0,0 +1,130 @@
'use strict';
import { connect } from 'ubus';
import { access, readfile, writefile, stat, popen } from 'fs';
function exec(cmd) {
let p = popen(cmd, 'r');
if (!p) return { code: -1, stdout: [] };
let out = [];
while (true) {
let line = p.read('line');
if (line == null) break;
push(out, rtrim(line, '\r\n'));
}
let code = p.close();
return { code: code, stdout: out };
}
const _trim = trim;
function trim_str(s) {
if (s == null) return '';
return _trim('' + s);
}
function get_procd_status() {
let u = connect();
if (!u) return { running: false };
let res = u.call('service', 'list', { name: 'cf-optimizer' });
if (res && res['cf-optimizer'] && res['cf-optimizer'].instances) {
let instances = res['cf-optimizer'].instances;
for (let name in instances) {
let inst = instances[name];
if (inst && inst.running)
return { running: true, pid: inst.pid };
}
}
let pid_res = exec('pidof cf-optimizer 2>/dev/null');
if (pid_res.code == 0 && length(pid_res.stdout) > 0) {
let pids = split(trim_str(join(' ', pid_res.stdout)), /\s+/);
if (length(pids) > 0 && pids[0] != '')
return { running: true, pid: +pids[0] };
}
return { running: false };
}
const methods = {};
methods.get_status = {
call: function() {
let proc_st = get_procd_status();
let status_data = null;
const status_file = '/tmp/run/cf-optimizer/status.json';
if (access(status_file)) {
let content = readfile(status_file);
if (content) {
try {
status_data = json(content);
} catch(e) {}
}
}
if (!status_data) {
status_data = {
running: proc_st.running,
last_update: '',
best_ip: '',
best_colo: '',
best_tcp_lat: '',
best_tls_lat: '',
domains: [],
rankings: []
};
}
status_data.service_running = proc_st.running;
status_data.service_pid = proc_st.pid || null;
return status_data;
}
};
methods.get_logs = {
call: function() {
const log_file = '/var/log/cf-optimizer.log';
let logs = [];
if (access(log_file)) {
let res = exec('tail -n 120 ' + log_file + ' 2>/dev/null');
if (res.code == 0) logs = res.stdout;
}
return { logs: logs };
}
};
methods.clear_logs = {
call: function() {
const log_file = '/var/log/cf-optimizer.log';
writefile(log_file, '');
return { success: true };
}
};
methods.run_once = {
call: function() {
exec('/usr/sbin/cf-optimizer run >/dev/null 2>&1 &');
return { success: true };
}
};
methods.service_action = {
args: { action: 'action' },
call: function(req) {
let action = req.args?.action;
if (!action) return { success: false, error: 'Missing action parameter' };
if (action == 'restart') {
exec('/etc/init.d/cf-optimizer restart >/dev/null 2>&1');
} else if (action == 'start') {
exec('/etc/init.d/cf-optimizer start >/dev/null 2>&1');
} else if (action == 'stop') {
exec('/etc/init.d/cf-optimizer stop >/dev/null 2>&1');
}
return { success: true };
}
};
return { 'luci.cf-optimizer': methods };
@@ -16,9 +16,11 @@ function exec(cmd) {
return { code: code, stdout: out }; return { code: code, stdout: out };
} }
const _trim = trim;
function trim(s) { function trim(s) {
if (s == null) return ''; if (s == null) return '';
return replace('' + s, /^[\s\r\n]+|[\s\r\n]+$/g, ''); return _trim('' + s);
} }
function get_procd_status(instance_name) { function get_procd_status(instance_name) {
+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.385 PKG_VERSION:=0.1.388
PKG_RELEASE:=56 PKG_RELEASE:=57
PKG_MAINTAINER:=liandu2024 PKG_MAINTAINER:=liandu2024
PKG_LICENSE:=GPL-3.0-or-later PKG_LICENSE:=GPL-3.0-or-later
+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.10.29 PKG_VERSION:=26.10.29
PKG_RELEASE:=361 PKG_RELEASE:=362
PKG_PO_VERSION:=$(PKG_VERSION) PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \ PKG_CONFIG_DEPENDS:= \
@@ -5,6 +5,9 @@ status="$2"
manager="$3" manager="$3"
force="$4" force="$4"
acl_file="/usr/share/rpcd/acl.d/luci-app-${CONFIG}.json"
acl_md5_before=$(md5sum "$acl_file" 2>/dev/null | awk '{print $1}')
if [ "$manager" = apk ]; then if [ "$manager" = apk ]; then
extra="" extra=""
[ "$force" = 1 ] && extra="--force-reinstall --force-overwrite" [ "$force" = 1 ] && extra="--force-reinstall --force-overwrite"
@@ -20,7 +23,10 @@ fi
rm -rf "$dir" rm -rf "$dir"
if [ "$result" = 0 ]; then if [ "$result" = 0 ]; then
rm -f "/tmp/etc/${CONFIG}_tmp/${CONFIG}_version" /tmp/luci-indexcache /tmp/luci-indexcache.* rm -f "/tmp/etc/${CONFIG}_tmp/${CONFIG}_version" /tmp/luci-indexcache /tmp/luci-indexcache.*
if [ -x /etc/init.d/rpcd ]; then acl_md5_after=$(md5sum "$acl_file" 2>/dev/null | awk '{print $1}')
# rpcd only reads ACL definitions at startup. Restart it only when the ACL
# file actually changed, so in-place updates no longer drop the web session.
if [ "$acl_md5_before" != "$acl_md5_after" ] && [ -x /etc/init.d/rpcd ]; then
/etc/init.d/rpcd restart >/dev/null 2>&1 /etc/init.d/rpcd restart >/dev/null 2>&1
fi fi
if [ -x /etc/init.d/uhttpd ]; then if [ -x /etc/init.d/uhttpd ]; then
+1 -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.29 PKG_VERSION:=26.10.29
PKG_RELEASE:=162 PKG_RELEASE:=163
PKG_PO_VERSION:=$(PKG_VERSION) PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \ PKG_CONFIG_DEPENDS:= \
@@ -5,6 +5,9 @@ status="$2"
manager="$3" manager="$3"
force="$4" force="$4"
acl_file="/usr/share/rpcd/acl.d/luci-app-${CONFIG}.json"
acl_md5_before=$(md5sum "$acl_file" 2>/dev/null | awk '{print $1}')
[ "$manager" = apk ] && extension=apk || extension=ipk [ "$manager" = apk ] && extension=apk || extension=ipk
set -- "$dir/luci-app-${CONFIG}.${extension}" set -- "$dir/luci-app-${CONFIG}.${extension}"
for package in "$dir"/luci-i18n-${CONFIG}-*.${extension}; do for package in "$dir"/luci-i18n-${CONFIG}-*.${extension}; do
@@ -26,7 +29,10 @@ fi
rm -rf "$dir" rm -rf "$dir"
if [ "$result" = 0 ]; then if [ "$result" = 0 ]; then
rm -f "/tmp/etc/${CONFIG}_tmp/${CONFIG}_version" /tmp/luci-indexcache /tmp/luci-indexcache.* rm -f "/tmp/etc/${CONFIG}_tmp/${CONFIG}_version" /tmp/luci-indexcache /tmp/luci-indexcache.*
if [ -x /etc/init.d/rpcd ]; then acl_md5_after=$(md5sum "$acl_file" 2>/dev/null | awk '{print $1}')
# rpcd only reads ACL definitions at startup. Restart it only when the ACL
# file actually changed, so in-place updates no longer drop the web session.
if [ "$acl_md5_before" != "$acl_md5_after" ] && [ -x /etc/init.d/rpcd ]; then
/etc/init.d/rpcd restart >/dev/null 2>&1 /etc/init.d/rpcd restart >/dev/null 2>&1
fi fi
if [ -x /etc/init.d/uhttpd ]; then if [ -x /etc/init.d/uhttpd ]; then
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-trafficctl PKG_NAME:=luci-app-trafficctl
PKG_VERSION:=1.25.1 PKG_VERSION:=1.25.2
PKG_RELEASE:=17 PKG_RELEASE:=18
PKG_LICENSE:=Apache-2.0 PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=Denis Iusupov <yusdyr@gmail.com> PKG_MAINTAINER:=Denis Iusupov <yusdyr@gmail.com>
@@ -738,6 +738,27 @@
vertical-align: middle; margin-right: 6px; vertical-align: middle; margin-right: 6px;
} }
/* The poll indicator (#106). It must never take part in layout: a fixed-size
slot that is always present, faded in rather than shown, because the banner
it replaces reflowed the page every couple of seconds — which is what made
the rate chips below it hard to hit. */
.tc-poll-spinner {
display: inline-block; flex: 0 0 12px; width: 12px; height: 12px;
border: 2px solid var(--tc-border);
border-top-color: var(--tc-faint);
border-radius: 50%;
animation: tc-spin .7s linear infinite;
opacity: 0; transition: opacity .25s ease;
}
.tc-poll-spinner.is-busy { opacity: 1; }
/* A refresh that failed is the one thing the poll has to say. Same slot, so
saying it still cannot move anything; the message is in the title. */
.tc-poll-spinner.is-error {
opacity: 1; animation: none;
border-color: var(--tc-err); background: var(--tc-err);
cursor: help;
}
/* ── Link style ──────────────────────────────────────────────────── */ /* ── Link style ──────────────────────────────────────────────────── */
.tc-link { color: inherit; text-decoration: none; border-bottom: 1px dotted var(--tc-faint); } .tc-link { color: inherit; text-decoration: none; border-bottom: 1px dotted var(--tc-faint); }
.tc-link:hover { border-bottom-style: solid; } .tc-link:hover { border-bottom-style: solid; }
@@ -1397,6 +1397,13 @@ function setStatus(el, type, msg) {
el.innerHTML = type === 'loading' ? '<span class="tc-spinner"></span>'+escHtml(msg) : escHtml(msg); el.innerHTML = type === 'loading' ? '<span class="tc-spinner"></span>'+escHtml(msg) : escHtml(msg);
} }
// setStatus rewrites className wholesale, which drops the tc-hidden the status
// element starts with — so putting it back has to be explicit.
function clearStatus(el) {
el.className = 'tc-hidden';
el.innerHTML = '';
}
function updateUrlParams(opts) { function updateUrlParams(opts) {
var params = new URLSearchParams(); var params = new URLSearchParams();
if (opts.lastIp && opts.lastIp !== '__all__') params.set('ip', opts.lastIp); if (opts.lastIp && opts.lastIp !== '__all__') params.set('ip', opts.lastIp);
@@ -2091,6 +2098,39 @@ return view.extend({
}); });
var statusDiv = E('div', { 'class': 'tc-hidden' }); var statusDiv = E('div', { 'class': 'tc-hidden' });
// #106: a refresh that runs on a timer must not announce itself on the
// status line. It gets its own indicator, which cannot disturb the
// page: a fixed-size slot that is always in the layout and is only
// faded in, never displayed or hidden (tc-hidden is display:none, which
// is exactly the reflow being fixed). It waits before showing, so on a
// router that answers quickly it never appears at all — a spinner that
// blinks for 80ms every two seconds is still a flash.
//
// A refresh reports its own health HERE and nowhere else. The status
// line belongs to what the operator did: every other poller in this
// view discards its errors outright, and these two were the only ones
// writing to a shared element, which is the whole of #106.
var pollSpinner = E('span', { 'class': 'tc-poll-spinner' });
var _pollBusy = 0, _pollShowTimer = null;
function pollStart() {
_pollBusy++;
if (_pollShowTimer === null) {
_pollShowTimer = setTimeout(function() {
_pollShowTimer = null;
if (_pollBusy > 0) pollSpinner.classList.add('is-busy');
}, 250);
}
}
// err: the message when this refresh failed, nothing when it worked.
function pollEnd(err) {
if (_pollBusy > 0) _pollBusy--;
if (_pollBusy > 0) return;
if (_pollShowTimer !== null) { clearTimeout(_pollShowTimer); _pollShowTimer = null; }
pollSpinner.classList.remove('is-busy');
pollSpinner.classList.toggle('is-error', !!err);
pollSpinner.title = err || '';
}
var statsDiv = E('div', { 'style': 'margin:8px 0', 'class': opts.showStats === false ? 'tc-hidden' : '' }); var statsDiv = E('div', { 'style': 'margin:8px 0', 'class': opts.showStats === false ? 'tc-hidden' : '' });
var connsDiv = E('div', { 'class': opts.showConns === false ? 'tc-hidden' : '' }); var connsDiv = E('div', { 'class': opts.showConns === false ? 'tc-hidden' : '' });
@@ -3043,11 +3083,11 @@ return view.extend({
self._queryGen++; self._queryGen++;
var gen = self._queryGen; var gen = self._queryGen;
setStatus(statusDiv, 'loading', _('Running…')); pollStart();
callDevice(ip, proto).then(function(data) { callDevice(ip, proto).then(function(data) {
if (!data || data.error) { if (!data || data.error) {
setStatus(statusDiv, 'error', (data && data.error) || _('Unknown error')); pollEnd((data && data.error) || _('Unknown error'));
return; return;
} }
@@ -3188,9 +3228,9 @@ return view.extend({
} }
} }
} }
setStatus(statusDiv, 'ok', '✓ ' + _('Done')); pollEnd();
}) })
.catch(function(e) { setStatus(statusDiv, 'error', '✗ '+e.message); }); .catch(function(e) { pollEnd(e.message); });
} }
self._tableFilter = null; self._tableFilter = null;
@@ -3368,7 +3408,7 @@ return view.extend({
} }
function runAll() { function runAll() {
setStatus(statusDiv, 'loading', _('Scanning all devices…')); pollStart();
callTrafficctl().then(function(rows) { callTrafficctl().then(function(rows) {
if (!Array.isArray(rows)) rows = []; if (!Array.isArray(rows)) rows = [];
@@ -3385,10 +3425,10 @@ return view.extend({
netifyMap = m; netifyMap = m;
}).catch(function() {}); }).catch(function() {});
} }
setStatus(statusDiv, 'ok', '✓ ' + _('Done')); pollEnd();
self._startBytesPoll(); self._startBytesPoll();
}) })
.catch(function(e) { setStatus(statusDiv, 'error', '✗ '+e.message); }); .catch(function(e) { pollEnd(e.message); });
} }
function updateExtendedStats() { function updateExtendedStats() {
@@ -3405,6 +3445,11 @@ return view.extend({
function runQuery() { function runQuery() {
var ip = searchSelect.getValue(); var ip = searchSelect.getValue();
// The poll used to wipe the status line on every tick, which also
// disposed of a result belonging to the device just left. Now that
// it does not, switching devices has to do it — otherwise
// "Limiting 192.168.0.50…" lingers over another device's page.
if (self._statusIp !== ip) { clearStatus(statusDiv); self._statusIp = ip; }
var o = loadOpts(); o.lastIp = ip; saveOpts(o); var o = loadOpts(); o.lastIp = ip; saveOpts(o);
updateUrlParams(o); updateUrlParams(o);
updateModeUI(); updateModeUI();
@@ -3464,12 +3509,21 @@ return view.extend({
var action = inetBtn._action; var action = inetBtn._action;
var fn = action === 'block' ? callBlock : callUnblock; var fn = action === 'block' ? callBlock : callUnblock;
fn(ip, '').then(function(res) { fn(ip, '').then(function(res) {
// A block without a known MAC covers IPv4 only, and the backend // The result is shown whatever it says, like every other action.
// says so in msg. Swallowing it here would put the UI back to // Reporting it only when res.ipv6 === false discarded the rest
// reporting a block that does not block (issue #67). // of what the backend qualifies: "NOT PERSISTENT" (#105) and the
if (res && res.ipv6 === false) { // NSS note (#81) are appended to msg on a SUCCESSFUL block, so
setStatus(statusDiv, 'error', (res && res.msg) || '?'); // blocking a device — the one path that mattered to the reporter
// of #105 — said nothing at all.
var state;
if (!res || !res.ok || res.ipv6 === false) {
// A failure, or a block that does not block over v6 (#67).
state = 'error';
} else {
// The device is now cut off, so not green.
state = (action === 'block') ? 'action' : 'ok';
} }
setStatus(statusDiv, state, (res && res.msg) || '?');
runQuery(); runQuery();
}).catch(function(e) { }).catch(function(e) {
setStatus(statusDiv, 'error', e.message); setStatus(statusDiv, 'error', e.message);
@@ -4113,7 +4167,7 @@ return view.extend({
offloadBanner, offloadBanner,
cutPanel, cutPanel,
E('div', {'style':'margin-bottom:10px'}, [ E('div', {'style':'margin-bottom:10px'}, [
E('div', {'style':'display:flex;align-items:center;gap:10px;flex-wrap:wrap'}, [searchSelect.el, actionRow]), E('div', {'style':'display:flex;align-items:center;gap:10px;flex-wrap:wrap'}, [searchSelect.el, actionRow, pollSpinner]),
quickBar quickBar
]), ]),
statusDiv, statusDiv,
@@ -304,9 +304,6 @@ msgstr ""
msgid "Distinct remote clients" msgid "Distinct remote clients"
msgstr "" msgstr ""
msgid "Done"
msgstr ""
msgid "Download" msgid "Download"
msgstr "" msgstr ""
@@ -835,9 +832,6 @@ msgstr ""
msgid "Routed via %s" msgid "Routed via %s"
msgstr "" msgstr ""
msgid "Running…"
msgstr ""
msgid "Sampled. Byte counts come from conntrack, which only reports flows that are alive when it is read — a connection that both starts and finishes between two samples is not counted. Shorten the sampling interval to narrow that gap." msgid "Sampled. Byte counts come from conntrack, which only reports flows that are alive when it is read — a connection that both starts and finishes between two samples is not counted. Shorten the sampling interval to narrow that gap."
msgstr "" msgstr ""
@@ -850,9 +844,6 @@ msgstr ""
msgid "Saving…" msgid "Saving…"
msgstr "" msgstr ""
msgid "Scanning all devices…"
msgstr ""
msgid "Search device (name, IP, MAC)…" msgid "Search device (name, IP, MAC)…"
msgstr "" msgstr ""
+3 -3
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.11 PKG_VERSION:=1.0.12
PKG_RELEASE:=18 PKG_RELEASE:=19
PKG_LICENSE:=GPL-3.0-or-later PKG_LICENSE:=GPL-3.0-or-later
LUCI_TITLE:=Vantage dashboard LUCI_TITLE:=Vantage dashboard
@@ -26,7 +26,7 @@ LUCI_DESCRIPTION:=Live network dashboard (landing page under admin/dashboard): d
# Optional at runtime, degrade when absent: rpcd-mod-rrdns (reverse DNS # Optional at runtime, degrade when absent: rpcd-mod-rrdns (reverse DNS
# names), umdns (mDNS names), hostapd ubus objects (WPS names, client # names), umdns (mDNS names), hostapd ubus objects (WPS names, client
# capabilities). # capabilities).
LUCI_DEPENDS:=+luci-base +rpcd +rpcd-mod-iwinfo +rpcd-mod-ucode +ucode-mod-uci +ucode-mod-ubus +ucode-mod-fs LUCI_DEPENDS:=+luci-base +rpcd +rpcd-mod-iwinfo +rpcd-mod-ucode +ucode-mod-uci +ucode-mod-ubus +ucode-mod-fs +ucode-mod-rtnl
LUCI_PKGARCH:=all LUCI_PKGARCH:=all
LUCI_MAINTAINER:=anothaDev <10740588+anothaDev@users.noreply.github.com> LUCI_MAINTAINER:=anothaDev <10740588+anothaDev@users.noreply.github.com>
LUCI_URL:=https://github.com/anothaDev/luci-theme-vantage LUCI_URL:=https://github.com/anothaDev/luci-theme-vantage
@@ -338,12 +338,17 @@ html[data-darkmode="true"] .vt-app {
.vt-app .vt-edit-hidden:empty { display: none; } .vt-app .vt-edit-hidden:empty { display: none; }
.vt-app .vt-edit-hidden .vt-fchip .vt-ico { width: 13px; height: 13px; } .vt-app .vt-edit-hidden .vt-fchip .vt-ico { width: 13px; height: 13px; }
.vt-app .vt-editbar-actions { display: flex; gap: 8px; margin-left: auto; } .vt-app .vt-editbar-actions { display: flex; gap: 8px; margin-left: auto; }
/* the chart and network path picks wrap as one group */
.vt-app .vt-edit-picks { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px 16px; min-width: 0; }
.vt-app .vt-edit-field { display: flex; align-items: center; gap: 8px; min-width: 0; } .vt-app .vt-edit-field { display: flex; align-items: center; gap: 8px; min-width: 0; }
.vt-app .vt-edit-field label { font-size: var(--vt-fs-sm); color: var(--vt-text-2); white-space: nowrap; } .vt-app .vt-edit-field label { font-size: var(--vt-fs-sm); color: var(--vt-text-2); white-space: nowrap; }
/* narrow: the label above, the select across the bar, so a long choice /* narrow: the two picks side by side, each label above its select, so
("Mirrored, scale per direction") is not cut */ the sticky bar stays short */
@container vt (max-width: 699px) { @container vt (max-width: 699px) {
.vt-app .vt-edit-field { flex: 1 1 100%; flex-direction: column; align-items: stretch; gap: 4px; } .vt-app .vt-edit-picks { flex: 1 1 100%; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.vt-app .vt-edit-field { flex-direction: column; align-items: stretch; gap: 4px; }
.vt-app .vt-edit-field label { white-space: normal; }
.vt-app .vt-edit-field > .vt-select { width: 100%; min-width: 0; }
} }
.vt-app .vt-select { .vt-app .vt-select {
appearance: none; -webkit-appearance: none; min-width: 0; max-width: 100%; appearance: none; -webkit-appearance: none; min-width: 0; max-width: 100%;
@@ -622,7 +627,7 @@ h2, h3{
.vt-app .vt-kpi + .vt-kpi { box-shadow: -1px 0 0 var(--vt-line); border-radius: 0 var(--vt-r-sm) var(--vt-r-sm) 0; } .vt-app .vt-kpi + .vt-kpi { box-shadow: -1px 0 0 var(--vt-line); border-radius: 0 var(--vt-r-sm) var(--vt-r-sm) 0; }
.vt-app .vt-kpi-label { font-size: var(--vt-fs-micro); font-weight: 650; color: var(--vt-text-3); text-transform: uppercase; letter-spacing: .05em; } .vt-app .vt-kpi-label { font-size: var(--vt-fs-micro); font-weight: 650; color: var(--vt-text-3); text-transform: uppercase; letter-spacing: .05em; }
.vt-app .vt-kpi-value { font-size: var(--vt-fs-xl); font-weight: 700; letter-spacing: -.02em; line-height: 1.2; white-space: nowrap; } .vt-app .vt-kpi-value { font-size: var(--vt-fs-xl); font-weight: 700; letter-spacing: -.02em; line-height: 1.2; white-space: nowrap; }
.vt-app .vt-kpi-sub { font-size: var(--vt-fs-sm); color: var(--vt-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .vt-app .vt-kpi-sub { max-width: 100%; font-size: var(--vt-fs-sm); color: var(--vt-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vt-app .vt-kpi-warn .vt-kpi-value { color: var(--vt-warn); } .vt-app .vt-kpi-warn .vt-kpi-value { color: var(--vt-warn); }
@container vthero (max-width: 700px) { @container vthero (max-width: 700px) {
.vt-app .vt-kpis { grid-template-columns: 1fr 1fr; } .vt-app .vt-kpis { grid-template-columns: 1fr 1fr; }
@@ -635,6 +640,10 @@ h2, h3{
-webkit-mask-image: linear-gradient(180deg, rgba(0,0,0,.55), transparent 85%); -webkit-mask-image: linear-gradient(180deg, rgba(0,0,0,.55), transparent 85%);
mask-image: linear-gradient(180deg, rgba(0,0,0,.55), transparent 85%); mask-image: linear-gradient(180deg, rgba(0,0,0,.55), transparent 85%);
} }
/* the backdrop (vantage/backdrop.js): a canvas over the dot grid and
under the wires and nodes, drawing over the path's height only */
.vt-app .vt-backdrop { position: absolute; left: 0; top: 0; pointer-events: none; border-radius: inherit; }
.vt-app .vt-backdrop[hidden] { display: none; }
.vt-app .vt-path { .vt-app .vt-path {
position: relative; position: relative;
display: grid; display: grid;
@@ -725,7 +734,8 @@ h2, h3{
.vt-app .vt-link-up { font-size: var(--vt-fs-sm); } .vt-app .vt-link-up { font-size: var(--vt-fs-sm); }
.vt-app .vt-link-up b { font-size: var(--vt-fs-base); font-weight: 650; color: var(--vt-text-2); } .vt-app .vt-link-up b { font-size: var(--vt-fs-base); font-weight: 650; color: var(--vt-text-2); }
.vt-app .vt-link-up .vt-ico { color: var(--vt-text-3); } .vt-app .vt-link-up .vt-ico { color: var(--vt-text-3); }
.vt-app .vt-link-meta { font-size: var(--vt-fs-micro); color: var(--vt-text-3); font-family: var(--vt-mono); white-space: nowrap; margin-top: 2px; } .vt-app .vt-link-meta { font-size: var(--vt-fs-micro); color: var(--vt-text-3); font-family: var(--vt-mono); white-space: nowrap; margin-top: 2px;
padding: 0 6px; border-radius: 6px; background: var(--vt-surface); }
/* the branches spread over the height the tile gets from its row, so a /* the branches spread over the height the tile gets from its row, so a
tall neighbour leaves no empty band above or below the diagram */ tall neighbour leaves no empty band above or below the diagram */
@@ -933,6 +943,9 @@ h2, h3{
/* ----------------------------------------------------------- 9. clients */ /* ----------------------------------------------------------- 9. clients */
.vt-app .vt-clients { padding-bottom: 8px; } .vt-app .vt-clients { padding-bottom: 8px; }
/* a jump to a tile (the path's SSID, Wired and port nodes) stops below
the sticky top bar */
.vt-app .vt-tile { scroll-margin-top: calc(var(--v-stick-top, 48px) + 12px); }
.vt-app .vt-count { font-size: var(--vt-fs-sm); color: var(--vt-text-3); font-weight: 560; } .vt-app .vt-count { font-size: var(--vt-fs-sm); color: var(--vt-text-3); font-weight: 560; }
.vt-app .vt-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 10px; } .vt-app .vt-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 10px; }
.vt-app .vt-filter { .vt-app .vt-filter {
@@ -957,17 +970,60 @@ h2, h3{
} }
.vt-app .vt-filter-clear:hover { background: var(--vt-surface-3); color: var(--vt-text); } .vt-app .vt-filter-clear:hover { background: var(--vt-surface-3); color: var(--vt-text); }
.vt-app .vt-filter-clear .vt-ico { width: 14px; height: 14px; } .vt-app .vt-filter-clear .vt-ico { width: 14px; height: 14px; }
.vt-app .vt-fchips { display: flex; flex-wrap: wrap; gap: 6px; } /* Wi-Fi or wired, in the tile head */
.vt-app .vt-kinds { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--vt-line); border-radius: 999px; background: var(--vt-surface-2); }
.vt-app .vt-kind {
display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 11px; margin: 0; min-width: 0;
border: 0; border-radius: 999px; background: transparent; box-shadow: none; text-transform: none;
color: var(--vt-text-2); font-size: var(--vt-fs-sm); font-weight: 600; cursor: pointer;
}
.vt-app .vt-kind:hover { color: var(--vt-text); }
.vt-app .vt-kind.vt-kind-on { background: var(--vt-surface); color: var(--vt-text); box-shadow: var(--vt-shadow); }
.vt-app .vt-kind-n { font-size: var(--vt-fs-micro); color: var(--vt-text-3); }
/* quick filters: a button at the end of the search box, a menu under it,
and the filter in force as a tag in the box */
.vt-app .vt-filter-wrap { position: relative; display: flex; flex: 1 1 260px; max-width: 380px; min-width: 0; }
.vt-app .vt-filter-wrap > .vt-filter { flex: 1 1 auto; max-width: none; min-width: 0; }
.vt-app .vt-filter-btn {
position: relative; display: inline-flex; align-items: center; justify-content: center; flex: none;
width: calc(var(--vt-ctl) - 10px); height: calc(var(--vt-ctl) - 10px); padding: 0; margin: 0 -2px 0 0; min-width: 0;
border: 0; border-radius: var(--vt-r-ctl); background: transparent; box-shadow: none; color: var(--vt-text-3); cursor: pointer;
}
.vt-app .vt-filter-btn:hover, .vt-app .vt-filter-btn[aria-expanded="true"] { background: var(--vt-surface-3); color: var(--vt-text); }
.vt-app .vt-filter-btn.vt-filter-btn-on { color: var(--vt-accent); }
.vt-app .vt-filter-btn .vt-ico { width: 16px; height: 16px; }
.vt-app .vt-ftag {
display: inline-flex; align-items: center; gap: 4px; flex: none; max-width: 45%; height: 24px; padding: 0 6px 0 9px; margin: 0; min-width: 0;
border: 0; border-radius: 999px; background: var(--vt-accent-soft); box-shadow: none; text-transform: none;
color: var(--vt-text); font-size: var(--vt-fs-sm); font-weight: 600; cursor: pointer;
}
.vt-app .vt-ftag span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vt-app .vt-ftag .vt-ico { width: 12px; height: 12px; flex: none; color: var(--vt-text-3); }
.vt-app .vt-ftag:hover .vt-ico { color: var(--vt-text); }
.vt-app .vt-fmenu {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 6;
display: flex; flex-direction: column; min-width: 210px; padding: 5px;
border: 1px solid var(--vt-line-2); border-radius: var(--vt-r); background: var(--vt-surface); box-shadow: var(--vt-shadow-pop);
}
.vt-app .vt-fmenu[hidden] { display: none; }
.vt-app .vt-fmenu-item {
display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; align-items: center; gap: 8px;
height: 34px; padding: 0 10px 0 8px; margin: 0; min-width: 0; width: 100%;
border: 0; border-radius: var(--vt-r-ctl); background: transparent; box-shadow: none; text-align: left; text-transform: none;
color: var(--vt-text); font-size: var(--vt-fs-sm); font-weight: 560; cursor: pointer;
}
.vt-app .vt-fmenu-item:hover, .vt-app .vt-fmenu-item:focus-visible { background: var(--vt-surface-2); outline: none; }
.vt-app .vt-fmenu-check { display: inline-flex; color: var(--vt-accent); }
.vt-app .vt-fmenu-check .vt-ico { width: 14px; height: 14px; }
.vt-app .vt-fmenu-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vt-app .vt-fmenu-n { color: var(--vt-text-3); font-size: var(--vt-fs-micro); font-weight: 600; }
.vt-app .vt-fmenu-warn .vt-fmenu-n { color: var(--vt-warn); }
.vt-app .vt-fchip { .vt-app .vt-fchip {
display: inline-flex; align-items: center; gap: 6px; height: var(--vt-ctl); padding: 0 12px; margin: 0; display: inline-flex; align-items: center; gap: 6px; height: var(--vt-ctl); padding: 0 12px; margin: 0;
border: 1px solid var(--vt-line); border-radius: 999px; background: var(--vt-surface); border: 1px solid var(--vt-line); border-radius: 999px; background: var(--vt-surface);
color: var(--vt-text-2); font-size: var(--vt-fs-sm); font-weight: 600; cursor: pointer; box-shadow: none; min-width: 0; text-transform: none; color: var(--vt-text-2); font-size: var(--vt-fs-sm); font-weight: 600; cursor: pointer; box-shadow: none; min-width: 0; text-transform: none;
} }
.vt-app .vt-fchip:hover { border-color: var(--vt-line-2); color: var(--vt-text); background: var(--vt-surface-2); } .vt-app .vt-fchip:hover { border-color: var(--vt-line-2); color: var(--vt-text); background: var(--vt-surface-2); }
.vt-app .vt-fchip-n { font-size: var(--vt-fs-micro); color: var(--vt-text-3); font-weight: 600; }
.vt-app .vt-fchip-warn .vt-fchip-n { color: var(--vt-warn); }
.vt-app .vt-fchip.vt-fchip-on { background: var(--vt-text); border-color: var(--vt-text); color: var(--vt-surface); }
.vt-app .vt-fchip.vt-fchip-on .vt-fchip-n { color: color-mix(in oklab, var(--vt-surface) 70%, transparent); }
.vt-app .vt-table-wrap { overflow-x: auto; margin: 0 -18px; padding: 0 18px; } .vt-app .vt-table-wrap { overflow-x: auto; margin: 0 -18px; padding: 0 18px; }
.vt-app .vt-ctable { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: auto; min-width: 800px; } .vt-app .vt-ctable { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: auto; min-width: 800px; }
@@ -1038,7 +1094,9 @@ h2, h3{
font-size: var(--vt-fs-micro); line-height: 1.35; color: var(--vt-text-3); max-width: 170px; font-size: var(--vt-fs-micro); line-height: 1.35; color: var(--vt-text-3); max-width: 170px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
} }
.vt-app .vt-td-time { white-space: nowrap; color: var(--vt-text-2); } .vt-app .vt-td-time, .vt-app .vt-td-seen { white-space: nowrap; color: var(--vt-text-2); }
.vt-app .vt-td-link { white-space: nowrap; }
.vt-app .vt-td-link .vt-phy-up { padding-left: 0; }
/* the Graphite shell tags numeric-looking cells with .vt-num (monospace); /* the Graphite shell tags numeric-looking cells with .vt-num (monospace);
the dashboard sets its own type */ the dashboard sets its own type */
.vt-app td.vt-num { font-family: inherit; font-size: inherit; letter-spacing: inherit; } .vt-app td.vt-num { font-family: inherit; font-size: inherit; letter-spacing: inherit; }
@@ -1253,8 +1311,7 @@ h2, h3{
.vt-app .vt-tile { padding: 14px 14px 16px; } .vt-app .vt-tile { padding: 14px 14px 16px; }
.vt-app .vt-filter { max-width: none; flex-basis: 100%; } .vt-app .vt-filter { max-width: none; flex-basis: 100%; }
.vt-app .vt-key-hint { display: none; } .vt-app .vt-key-hint { display: none; }
.vt-app .vt-fchips { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin: 0 -14px; padding: 0 14px; } .vt-app .vt-filter-wrap { max-width: none; flex-basis: 100%; }
.vt-app .vt-fchip { flex: none; }
.vt-app .vt-head { gap: 10px; } .vt-app .vt-head { gap: 10px; }
.vt-app .vt-headline { font-size: var(--vt-fs-lg); } .vt-app .vt-headline { font-size: var(--vt-fs-lg); }
.vt-app .vt-orb { width: 32px; height: 32px; } .vt-app .vt-orb { width: 32px; height: 32px; }
@@ -1297,13 +1354,14 @@ h2, h3{
.vt-app .vt-row:hover { background: var(--vt-surface-2); } .vt-app .vt-row:hover { background: var(--vt-surface-2); }
.vt-app .vt-ctable tr.vt-row-sel { background: var(--vt-accent-soft); border-color: var(--vt-accent-line); } .vt-app .vt-ctable tr.vt-row-sel { background: var(--vt-accent-soft); border-color: var(--vt-accent-line); }
.vt-app .vt-ctable td.vt-td-name { grid-column: 1; grid-row: 1; min-width: 0; box-shadow: none !important; } .vt-app .vt-ctable td.vt-td-name { grid-column: 1; grid-row: 1; min-width: 0; box-shadow: none !important; }
.vt-app .vt-ctable td.vt-td-signal { grid-column: 2; grid-row: 1; justify-self: end; align-self: start; padding-top: 2px; } .vt-app .vt-ctable td.vt-td-signal, .vt-app .vt-ctable td.vt-td-link { grid-column: 2; grid-row: 1; justify-self: end; align-self: start; padding-top: 2px; text-align: right; }
.vt-app .vt-ctable td.vt-td-net { grid-column: 1; grid-row: 2; padding-left: 44px; } .vt-app .vt-ctable td.vt-td-net { grid-column: 1; grid-row: 2; padding-left: 44px; }
.vt-app .vt-net { flex-direction: row; align-items: center; gap: 6px; flex-wrap: wrap; } .vt-app .vt-net { flex-direction: row; align-items: center; gap: 6px; flex-wrap: wrap; }
.vt-app .vt-ctable td.vt-td-live { grid-column: 2; grid-row: 2; justify-self: end; text-align: right; } .vt-app .vt-ctable td.vt-td-live { grid-column: 2; grid-row: 2; justify-self: end; text-align: right; }
.vt-app .vt-live-cell { min-width: 0; } .vt-app .vt-live-cell { min-width: 0; }
.vt-app .vt-live-cell .vt-mini, .vt-app .vt-live-split { display: none; } .vt-app .vt-live-cell .vt-mini, .vt-app .vt-live-split { display: none; }
.vt-app .vt-ctable td.vt-td-exp { grid-column: 1 / -1; grid-row: 3; padding-left: 44px; } .vt-app .vt-ctable td.vt-td-exp, .vt-app .vt-ctable td.vt-td-seen { grid-column: 1 / -1; grid-row: 3; padding-left: 44px; }
.vt-app .vt-ctable td.vt-td-seen { font-size: var(--vt-fs-sm); color: var(--vt-text-3); }
.vt-app .vt-exp-text span { max-width: none; -webkit-line-clamp: 1; } .vt-app .vt-exp-text span { max-width: none; -webkit-line-clamp: 1; }
.vt-app .vt-exp { gap: 8px; } .vt-app .vt-exp { gap: 8px; }
.vt-app .vt-exp-text { flex-direction: row; align-items: baseline; gap: 6px; min-width: 0; } .vt-app .vt-exp-text { flex-direction: row; align-items: baseline; gap: 6px; min-width: 0; }
@@ -0,0 +1,438 @@
'use strict';
'require baseclass';
/* The network path's backdrop: one canvas behind the nodes and wires, in
the style the viewer picked under Customize. The two styles are the
login page's (luci-theme-vantage login.js), here driven by the live
uplink rate:
grid packets on the tile's dot grid: downloads enter from the
gateway's side and run right, uploads come back, now and then
a packet steps a row up or down; how many and how fast
follows the rate
stars a starfield streaming out of this device's node, faster with
more traffic
none the dot grid alone
It draws over the path's height only (not under the numbers below),
20 frames a second while the traffic is light and up to 30 as it
grows (it is the frames, not the drawing, that cost), and stops
while another window has the focus (as the login page does), the
tab is hidden, or the tile is scrolled out of view or folded. With
reduced motion it draws one still frame. No libraries; the pure
parts are exported for tests. */
var STYLES = [ 'grid', 'stars', 'none' ];
var GRID = 18, DOT = 1; /* app.css .vt-hero::before: 18 px tiles, a dot at 1 px */
function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
/* bit/s -> 0 (10 kbit/s or less) .. 1 (1 Gbit/s or more), one fifth per
decade */
function level(bps) {
return bps > 1e4 ? clamp(Math.log(bps / 1e4) / Math.LN10 / 5, 0, 1) : 0;
}
/* the share of packets going right (downloads), kept between a sixth and
nine tenths so either direction stays visible; 0.7 before any rate */
function downShare(down, up) {
var d = +down || 0, u = +up || 0;
return d + u > 0 ? clamp(d / (d + u), 0.16, 0.9) : 0.7;
}
/* packets in a w x h area at a level: a couple when idle, about one per
14,000 px² at the top (six to 36) */
function packetCount(w, h, lvl) {
var top = clamp(Math.round(w * h / 14000), 6, 36);
return Math.round(2 + (top - 2) * clamp(lvl, 0, 1));
}
/* px per second along the grid */
function packetSpeed(lvl) { return 45 + 175 * clamp(lvl, 0, 1); }
/* depth per second of the starfield */
function starSpeed(lvl) { return 0.03 + 0.32 * clamp(lvl, 0, 1); }
/* ms between frames: 50 (20 a second) up to 100 kbit/s, where a packet
moves a few px a frame, down to 33 (30 a second) from 10 Mbit/s */
function frameMs(lvl) { return Math.round(50 - 17 * clamp((lvl - .2) / .4, 0, 1)); }
/* ---------------------------------------------------------------- grid */
function packets(env) {
var SHADES = 5, ALPHA = [ .6, .36, .2, .1, .04 ];
var DIRS = [ [ 1, 0 ], [ 0, 1 ], [ -1, 0 ], [ 0, -1 ] ];
var list = [];
/* lattice lines inside the band: x = DOT + GRID * i */
function line(v) { return DOT + GRID * v; }
function firstIn(lo) { return Math.ceil((lo - DOT) / GRID); }
function lastIn(hi) { return Math.floor((hi - DOT) / GRID); }
function spawn(p, anywhere) {
var b = env.box, r0 = firstIn(b.t + 4), r1 = lastIn(b.b - 4), c0 = firstIn(0), c1 = lastIn(env.w);
if (r1 < r0) r1 = r0;
var main = Math.random() < env.share ? 0 : 2;
var row = r0 + Math.floor(Math.random() * (r1 - r0 + 1));
var col = anywhere ? c0 + Math.floor(Math.random() * (c1 - c0 + 1)) : main === 0 ? c0 - 2 : c1 + 2;
p.main = main; p.d = main; p.x = line(col); p.y = line(row); p.pts = [ [ p.x, p.y ] ];
p.v = env.speed * (0.7 + Math.random() * 0.6);
p.len = clamp(p.v * 0.45, 28, 120);
p.next = GRID; p.retire = false;
return p;
}
/* drops the corners behind the end of the trail; returns its tail */
function trim(p) {
var left = p.len, hx = p.x, hy = p.y;
for (var k = p.pts.length - 1; k >= 0; k--) {
var q = p.pts[k], seg = Math.abs(hx - q[0]) + Math.abs(hy - q[1]);
if (seg >= left) {
if (k > 0) p.pts.splice(0, k);
return [ hx + (q[0] - hx) * left / (seg || 1), hy + (q[1] - hy) * left / (seg || 1) ];
}
left -= seg; hx = q[0]; hy = q[1];
}
return [ hx, hy ];
}
/* at a crossing: off the main line now and then, back onto it soon;
never out of the band */
function turn(p) {
var b = env.box;
if (p.d === 0 || p.d === 2) {
if (Math.random() < 0.16) {
var d = Math.random() < 0.5 ? 1 : 3, ny = p.y + DIRS[d][1] * GRID;
if (ny < b.t + 4 || ny > b.b - 4) d = 4 - d;
p.pts.push([ p.x, p.y ]); p.d = d;
}
}
else {
var ny2 = p.y + DIRS[p.d][1] * GRID;
if (Math.random() < 0.55 || ny2 < b.t + 4 || ny2 > b.b - 4) { p.pts.push([ p.x, p.y ]); p.d = p.main; }
}
}
return {
/* the count follows the level: new packets enter at an edge,
extra ones finish their run; a finishing one is called back
before a new one is made, so the list never outgrows the
largest count, also while nothing moves */
resize: function(initial) {
var n = env.count, live = 0, i;
list.forEach(function(p) { if (!p.retire) live++; });
if (initial) { list.length = 0; live = 0; }
for (i = 0; i < list.length && live > n; i++) if (!list[i].retire) { list[i].retire = true; live--; }
for (i = 0; i < list.length && live < n; i++) if (list[i].retire) { list[i].retire = false; live++; }
while (live < n) { list.push(spawn({}, !!initial)); live++; }
},
step: function(dt) {
var b = env.box, m = 3 * GRID;
for (var i = 0; i < list.length; i++) {
var p = list[i], go = p.v * dt;
while (go > 0) {
var s = Math.min(go, p.next);
p.x += DIRS[p.d][0] * s; p.y += DIRS[p.d][1] * s; p.next -= s; go -= s;
if (p.next <= 0) { p.next = GRID; turn(p); }
}
var tail = trim(p);
if ((p.x < -m || p.x > env.w + m) && (tail[0] < -m || tail[0] > env.w + m)) {
if (p.retire) { list.splice(i, 1); i--; }
else spawn(p, false);
}
/* a band that shrank under a packet */
else if (p.y < b.t - GRID || p.y > b.b + GRID) spawn(p, false);
}
},
draw: function(ctx) {
var shade = [], heads = new Path2D(), glow = new Path2D();
for (var k = 0; k < SHADES; k++) shade.push(new Path2D());
for (var i = 0; i < list.length; i++) {
var p = list[i], hx = p.x, hy = p.y, run = 0, piece = p.len / SHADES;
for (var j = p.pts.length - 1, n = 0; j >= 0 && n < SHADES; j--) {
var q = p.pts[j], seg = Math.abs(hx - q[0]) + Math.abs(hy - q[1]), at = 0;
while (at < seg && n < SHADES) {
var take = Math.min(seg - at, piece * (n + 1) - run), t0 = at / (seg || 1), t1 = (at + take) / (seg || 1);
shade[n].moveTo(hx + (q[0] - hx) * t0, hy + (q[1] - hy) * t0);
shade[n].lineTo(hx + (q[0] - hx) * t1, hy + (q[1] - hy) * t1);
at += take; run += take;
if (run >= piece * (n + 1) - .01) n++;
}
hx = q[0]; hy = q[1];
}
heads.rect(p.x - 1.25, p.y - 1.25, 2.5, 2.5);
glow.rect(p.x - 3.5, p.y - 3.5, 7, 7);
}
ctx.strokeStyle = ctx.fillStyle = env.colour;
ctx.lineWidth = 1.25;
for (var s = 0; s < SHADES; s++) { ctx.globalAlpha = ALPHA[s]; ctx.stroke(shade[s]); }
ctx.globalAlpha = .12; ctx.fill(glow);
ctx.globalAlpha = .8; ctx.fill(heads);
ctx.globalAlpha = 1;
},
size: function() { return list.length; },
/* for tests */
items: function() { return list; }
};
}
/* --------------------------------------------------------------- stars */
function starfield(env) {
var TRAIL = .1, WIDTH = [ .8, 1, 1.3 ], ALPHA = [ .2, .32, .45 ];
/* it starts fast and settles to the rate's speed, as on the login page */
var list = [], speed = 0.45, f = 1;
function place(s, z) {
var a = Math.random() * 2 * Math.PI, r = .03 + Math.sqrt(Math.random()) * .97;
s.x = Math.cos(a) * r; s.y = Math.sin(a) * r; s.z = z; s.c = Math.random() < .25 ? 1 : 0;
return s;
}
return {
resize: function() {
f = Math.max(env.w, env.box.b - env.box.t) * .12;
var n = clamp(Math.round(env.w * (env.box.b - env.box.t) / 7000), 24, 160);
while (list.length < n) list.push(place({}, .05 + Math.random() * .95));
list.length = n;
},
step: function(dt) {
speed += (env.speed - speed) * Math.min(1, dt * 1.5);
var o = env.origin, b = env.box;
for (var i = 0; i < list.length; i++) {
var s = list[i];
s.z -= speed * dt;
var px = o.x + s.x * f / s.z, py = o.y + s.y * f / s.z;
if (s.z <= .02 || px < -20 || px > env.w + 20 || py < b.t - 20 || py > b.b + 20) place(s, .8 + Math.random() * .2);
}
},
draw: function(ctx) {
var o = env.origin, paths = [ new Path2D(), new Path2D(), new Path2D(), new Path2D(), new Path2D(), new Path2D() ];
for (var i = 0; i < list.length; i++) {
var s = list[i], z0 = Math.min(1, s.z + speed * TRAIL + .004);
var p = paths[(s.z > .5 ? 0 : s.z > .2 ? 1 : 2) * 2 + s.c];
p.moveTo(o.x + s.x * f / z0, o.y + s.y * f / z0);
p.lineTo(o.x + s.x * f / s.z, o.y + s.y * f / s.z);
}
ctx.lineCap = 'round';
for (var k = 0; k < 6; k++) {
ctx.globalAlpha = ALPHA[k >> 1];
ctx.lineWidth = WIDTH[k >> 1];
ctx.strokeStyle = (k & 1) ? env.colour : env.muted;
ctx.stroke(paths[k]);
}
ctx.globalAlpha = 1;
},
size: function() { return list.length; },
/* for tests */
items: function() { return list; }
};
}
/* ---------------------------------------------------------- the canvas */
/* host: the tile (the canvas covers its padding box, as the dot grid);
band: the element whose height it draws over; style: one of STYLES */
function mount(host, band, style) {
var cv = document.createElement('canvas'), ctx = cv.getContext && cv.getContext('2d');
var env = { w: 0, h: 0, box: { t: 0, b: 0 }, origin: { x: 0, y: 0 }, count: 2, speed: packetSpeed(0), share: 0.7, frame: frameMs(0), colour: '#7a9a3a', muted: '#8a9099' };
var down = null, up = null, originEl = null;
var scene = null, cur = 'none', raf = 0, timer = 0, last = 0, dpr = 1, inView = true, hidden = false, still = false, mq = null;
var focused = typeof document.hasFocus === 'function' ? document.hasFocus() : true;
var ro = null, io = null, onMq = null, stillRated = false, mo = null, scheme = null, onScheme = null;
cv.className = 'vt-backdrop';
cv.setAttribute('aria-hidden', 'true');
if (!ctx || typeof Path2D !== 'function') return { style: function() {}, update: function() {}, stop: function() {}, canvas: null };
host.insertBefore(cv, host.firstChild);
if (window.matchMedia) {
mq = window.matchMedia('(prefers-reduced-motion: reduce)');
still = !!mq.matches;
onMq = function() { still = !!mq.matches; run(); paint(); };
if (mq.addEventListener) mq.addEventListener('change', onMq);
}
/* a colour token of the app (set on .vt-app, often a color-mix())
as the canvas takes it: the computed colour of the canvas element,
kept only when the context parses it */
function token(name, fallback) {
var v = '';
try { cv.style.color = 'var(' + name + ')'; v = getComputedStyle(cv).color; } catch (e) { v = ''; }
if (!v || !cv.isConnected) return fallback;
ctx.fillStyle = '#010203';
ctx.fillStyle = v;
return ctx.fillStyle !== '#010203' ? v : fallback;
}
function colours() {
env.colour = token('--vt-accent-line', env.colour);
env.muted = token('--vt-text-3', env.muted);
}
function measure() {
var hr = host.getBoundingClientRect(), br = band.getBoundingClientRect();
var ox = hr.left + host.clientLeft, oy = hr.top + host.clientTop;
env.w = host.clientWidth; env.h = host.clientHeight;
env.box = { t: Math.max(0, br.top - oy), b: Math.min(env.h, br.bottom - oy) };
if (originEl) {
var r = originEl.getBoundingClientRect();
env.origin = { x: r.left + r.width / 2 - ox, y: r.top + r.height / 2 - oy };
}
else env.origin = { x: env.w / 2, y: (env.box.t + env.box.b) / 2 };
}
function load() {
var lvl = level((+down || 0) + (+up || 0));
env.count = packetCount(env.w, env.box.b - env.box.t, lvl);
env.speed = cur === 'stars' ? starSpeed(lvl) : packetSpeed(lvl);
env.share = downShare(down, up);
env.frame = frameMs(lvl);
}
function usable() { return cur !== 'none' && env.w > 40 && env.box.b - env.box.t > 40; }
/* the scene to the size and level: built across the band the first
time it has room and run for a moment, so the trails are there;
adjusted after that. A still frame is built once more when the
first rate is in, and then left alone. */
function fit(initial) {
if (!scene || !usable()) return;
var rated = down != null || up != null;
if (initial || !scene.size() || (still && rated && !stillRated)) {
stillRated = still && rated;
scene.resize(true);
for (var i = 0; i < 12; i++) scene.step(.05);
}
else if (!still) scene.resize(false);
}
function size(initial) {
measure();
dpr = Math.min(window.devicePixelRatio || 1, 1.5);
cv.width = Math.round(env.w * dpr); cv.height = Math.round(env.h * dpr);
cv.style.width = env.w + 'px'; cv.style.height = env.h + 'px';
load();
fit(initial);
paint();
}
function paint() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, env.w, env.h);
if (!scene || !usable()) return;
ctx.save();
ctx.beginPath(); ctx.rect(0, env.box.t, env.w, env.box.b - env.box.t); ctx.clip();
scene.draw(ctx);
ctx.restore();
}
/* one frame, then a wait: the browser is asked only for the frames
that are drawn (not one per display refresh), and they stay on its
frame clock; the clock starts when the loop does */
function tick(t) {
raf = 0;
if (!running()) return;
scene.step(clamp((t - last) / 1000, 0, .1));
last = t;
paint();
/* the wait ends a little early, the browser's next frame is the one */
timer = setTimeout(again, Math.max(0, env.frame - 12));
}
function again() { timer = 0; if (running() && !raf) raf = requestAnimationFrame(tick); }
function running() { return !!scene && !still && !hidden && focused && inView && usable(); }
function halt() {
if (raf) cancelAnimationFrame(raf);
if (timer) clearTimeout(timer);
raf = timer = 0;
}
function run() {
if (running()) { if (!raf && !timer) { last = performance.now(); raf = requestAnimationFrame(tick); } }
else halt();
}
function setStyle(s) {
s = STYLES.indexOf(s) >= 0 ? s : 'grid';
if (s === cur && scene) return;
cur = s;
scene = s === 'grid' ? packets(env) : s === 'stars' ? starfield(env) : null;
cv.hidden = !scene;
colours();
size(true);
run();
}
if (typeof ResizeObserver === 'function') {
ro = new ResizeObserver(function() { size(false); run(); });
ro.observe(host);
ro.observe(band);
}
if (typeof IntersectionObserver === 'function') {
io = new IntersectionObserver(function(es) { inView = es.some(function(e) { return e.isIntersecting; }); run(); });
io.observe(host);
}
/* a theme switch (the theme's data-darkmode / data-theme, or the
system's scheme): the new colours at once, not at the next poll */
onScheme = function() { colours(); if (!running()) paint(); };
if (typeof MutationObserver === 'function') {
mo = new MutationObserver(onScheme);
mo.observe(document.documentElement, { attributes: true, attributeFilter: [ 'data-darkmode', 'data-theme' ] });
}
if (window.matchMedia) {
scheme = window.matchMedia('(prefers-color-scheme: dark)');
if (scheme.addEventListener) scheme.addEventListener('change', onScheme);
}
var onVis = function() { hidden = !!document.hidden; run(); };
document.addEventListener('visibilitychange', onVis);
/* another window has the focus: the last frame stays */
var onBlur = function() { focused = false; run(); }, onFocus = function() { focused = true; run(); };
window.addEventListener('blur', onBlur);
window.addEventListener('focus', onFocus);
setStyle(style);
return {
canvas: cv,
style: setStyle,
/* each poll: the uplink's rate (bit/s each way) and the node the
stars stream from; the colours follow a theme switch */
update: function(o) {
o = o || {};
down = o.down; up = o.up;
if (o.origin !== undefined) originEl = o.origin;
colours();
measure();
load();
fit(false);
if (still) paint();
run();
},
running: running,
stop: function() {
halt(); scene = null;
if (ro) ro.disconnect();
if (io) io.disconnect();
document.removeEventListener('visibilitychange', onVis);
window.removeEventListener('blur', onBlur);
window.removeEventListener('focus', onFocus);
if (mq && onMq && mq.removeEventListener) mq.removeEventListener('change', onMq);
if (scheme && scheme.removeEventListener) scheme.removeEventListener('change', onScheme);
if (mo) mo.disconnect();
if (cv.parentNode) cv.parentNode.removeChild(cv);
}
};
}
return baseclass.extend({
STYLES: STYLES,
GRID: GRID,
DOT: DOT,
level: level,
downShare: downShare,
packetCount: packetCount,
packetSpeed: packetSpeed,
starSpeed: starSpeed,
frameMs: frameMs,
packets: packets,
starfield: starfield,
mount: mount
});
@@ -13,8 +13,8 @@
var TILES = [ 'hero', 'chart', 'radios', 'clients', 'sys', 'talk', 'ssids' ]; var TILES = [ 'hero', 'chart', 'radios', 'clients', 'sys', 'talk', 'ssids' ];
/* tiles that only exist with Wi-Fi */ /* tiles that only exist with Wi-Fi (Clients lists wired hosts too) */
var WIFI = { radios: true, clients: true, talk: true, ssids: true }; var WIFI = { radios: true, talk: true, ssids: true };
var COLS = { wide: 4, mid: 2, narrow: 1 }; var COLS = { wide: 4, mid: 2, narrow: 1 };
@@ -37,9 +37,9 @@ var DEFAULTS = {
narrow: [ [ 'clients', 0, 1 ], [ 'talk', 0, 1 ], [ 'radios', 0, 1 ], [ 'hero', 0, 1 ], [ 'ssids', 0, 1 ], [ 'chart', 0, 1 ], [ 'sys', 0, 1 ] ] narrow: [ [ 'clients', 0, 1 ], [ 'talk', 0, 1 ], [ 'radios', 0, 1 ], [ 'hero', 0, 1 ], [ 'ssids', 0, 1 ], [ 'chart', 0, 1 ], [ 'sys', 0, 1 ] ]
}; };
var DEFAULTS_WIRED = { var DEFAULTS_WIRED = {
wide: [ [ 'hero', 0, 4 ], [ 'chart', 0, 3 ], [ 'sys', 3, 1 ] ], wide: [ [ 'hero', 0, 4 ], [ 'chart', 0, 3 ], [ 'sys', 3, 1 ], [ 'clients', 0, 4 ] ],
mid: [ [ 'hero', 0, 2 ], [ 'chart', 0, 2 ], [ 'sys', 0, 2 ] ], mid: [ [ 'hero', 0, 2 ], [ 'chart', 0, 2 ], [ 'sys', 0, 2 ], [ 'clients', 0, 2 ] ],
narrow: [ [ 'hero', 0, 1 ], [ 'chart', 0, 1 ], [ 'sys', 0, 1 ] ] narrow: [ [ 'hero', 0, 1 ], [ 'clients', 0, 1 ], [ 'chart', 0, 1 ], [ 'sys', 0, 1 ] ]
}; };
function own(o, k) { return o != null && Object.prototype.hasOwnProperty.call(o, k); } function own(o, k) { return o != null && Object.prototype.hasOwnProperty.call(o, k); }
@@ -193,10 +193,14 @@ return baseclass.extend({
wan/wan6/*wan* priority only on a tie. A softwire (DS-Lite, MAP-E, wan/wan6/*wan* priority only on a tie. A softwire (DS-Lite, MAP-E,
464XLAT) carries IPv4 over the IPv6 uplink, so it ranks after IPv6: 464XLAT) carries IPv4 over the IPv6 uplink, so it ranks after IPv6:
the uplink shown is the line, not the tunnel. Fall back to WAN names, then the uplink shown is the line, not the tunnel. Fall back to WAN names, then
the first up interface. A bridge is followed to its wired member the first up interface. A bridge, or a VLAN on one (br-lan.10), is
(wireless members skipped; netifd 'ethernet' preferred); DSA with followed to its wired member (wireless members skipped): the port
hardware offload uses the conduit's counters. */ given as `via` (where the bridge learned the gateway), else one with
resolveUplink: function(ifaces, devs, wirelessNames) { a link, netifd 'ethernet' preferred. `port` is the member known to lead to the
gateway: `via`, or the bridge's only wired member; null when it has
several and the gateway's port is unknown. DSA with hardware
offload uses the conduit's counters. */
resolveUplink: function(ifaces, devs, wirelessNames, via) {
var self = this; var self = this;
ifaces = arr(ifaces); ifaces = arr(ifaces);
devs = obj(devs); devs = obj(devs);
@@ -243,13 +247,19 @@ return baseclass.extend({
var pick = routed.length ? routed[0].i : preferredWan(up) || up[0]; var pick = routed.length ? routed[0].i : preferredWan(up) || up[0];
if (!pick) return null; if (!pick) return null;
var l3 = pick.l3_device || pick.device, dev = l3, d = devs[dev]; var l3 = pick.l3_device || pick.device, dev = l3, d = devs[dev], port = dev;
if (d && (d.type === 'bridge' || d.devtype === 'bridge') && Array.isArray(d['bridge-members'])) { var isBridge = function(x) { return !!x && (x.type === 'bridge' || x.devtype === 'bridge') && Array.isArray(x['bridge-members']); };
var members = d['bridge-members']; var vlanOf = /^(.+)\.\d{1,4}$/.exec(dev);
var br = isBridge(d) ? d : (vlanOf && isBridge(devs[vlanOf[1]])) ? devs[vlanOf[1]] : null;
if (br) {
var members = br['bridge-members'];
var wired = members.filter(function(m) { return !self.isWireless(m, devs[m], wirelessNames); }); var wired = members.filter(function(m) { return !self.isWireless(m, devs[m], wirelessNames); });
var eth = wired.filter(function(m) { return devs[m] && devs[m].devtype === 'ethernet'; }); var eth = wired.filter(function(m) { return devs[m] && devs[m].devtype === 'ethernet'; });
var next = eth[0] || wired[0] || members[0]; var lit = function(m) { return devs[m] && devs[m].carrier === true; };
var known = typeof via === 'string' && wired.indexOf(via) >= 0 ? via : null;
var next = known || eth.filter(lit)[0] || wired.filter(lit)[0] || eth[0] || wired[0] || members[0];
if (next) { dev = next; d = devs[dev] || null; } if (next) { dev = next; d = devs[dev] || null; }
port = known || (wired.length === 1 ? wired[0] : null);
} }
var statsDev = dev; var statsDev = dev;
@@ -261,6 +271,7 @@ return baseclass.extend({
iface: pick.interface, iface: pick.interface,
l3dev: l3, l3dev: l3,
dev: dev, dev: dev,
port: port,
statsDev: statsDev, statsDev: statsDev,
isWan: /wan/i.test(pick.interface), isWan: /wan/i.test(pick.interface),
proto: pick.proto || null, proto: pick.proto || null,
@@ -368,6 +379,7 @@ return baseclass.extend({
/* raw: { board, info, ifaces, devs, wifi, iwinfo: { ifname: info }, /* raw: { board, info, ifaces, devs, wifi, iwinfo: { ifname: info },
assoc: { ifname: [ station ] }, hapd: { ifname: { clients } }, assoc: { ifname: [ station ] }, hapd: { ifname: { clients } },
hapdStatus: { ifname: status }, hints, aliases, rdns, mdns, hapdStatus: { ifname: status }, hints, aliases, rdns, mdns,
fdb: [ luci.vantage wired host ] (-> wiredHosts),
vendor: fn(mac) -> name|null } */ vendor: fn(mac) -> name|null } */
build: function(raw) { build: function(raw) {
raw = raw || {}; raw = raw || {};
@@ -382,13 +394,20 @@ return baseclass.extend({
var firstIf = ifs.filter(function(i) { return i.ifname && own(iw, i.ifname); })[0]; var firstIf = ifs.filter(function(i) { return i.ifname && own(iw, i.ifname); })[0];
var info0 = firstIf ? obj(iw[firstIf.ifname]) : {}; var info0 = firstIf ? obj(iw[firstIf.ifname]) : {};
var band = wifi.bandKey(cfg.band, info0.frequency || rIw.frequency); var band = wifi.bandKey(cfg.band, info0.frequency || rIw.frequency);
var ht = wifi.htmode(info0.htmode || cfg.htmode); /* a radio read from the config (luci.vantage wireless without
netifd: vendor drivers such as mtkwifi) has no run state, and
its driver may report less than it runs: mtkwifi's iwinfo
stops at HE with EHT configured. Elsewhere iwinfo wins. */
var fromCfg = r.source === 'config', cfgHt = wifi.htmode(cfg.htmode);
var htmode = fromCfg && cfgHt.std === 'EHT' ? cfg.htmode : (info0.htmode || cfg.htmode || null);
var ht = wifi.htmode(htmode);
var chan = num(info0.channel || rIw.channel || cfg.channel) || null; var chan = num(info0.channel || rIw.channel || cfg.channel) || null;
var live = ifs.some(function(i) { return typeof i.ifname === 'string' && own(iw, i.ifname) && num(obj(iw[i.ifname]).channel) > 0; });
var radio = { var radio = {
id: rname, id: rname,
band: band, band: band,
bandLabel: wifi.bandLabel(band), bandLabel: wifi.bandLabel(band),
up: r.up === true, up: r.up === true || (fromCfg && live),
disabled: r.disabled === true || cfg.disabled === true || cfg.disabled === '1', disabled: r.disabled === true || cfg.disabled === true || cfg.disabled === '1',
pending: r.pending === true, pending: r.pending === true,
channel: chan, channel: chan,
@@ -398,7 +417,7 @@ return baseclass.extend({
width: ht.width, width: ht.width,
std: ht.std, std: ht.std,
gen: wifi.genName(ht.std, band), gen: wifi.genName(ht.std, band),
htmode: info0.htmode || cfg.htmode || null, htmode: htmode,
txpower: finite(info0.txpower) ? info0.txpower : (finite(rIw.txpower) ? rIw.txpower : null), txpower: finite(info0.txpower) ? info0.txpower : (finite(rIw.txpower) ? rIw.txpower : null),
txpowerCfg: cfg.txpower != null && isFinite(+cfg.txpower) ? +cfg.txpower : null, txpowerCfg: cfg.txpower != null && isFinite(+cfg.txpower) ? +cfg.txpower : null,
noise: wifi.noise(finite(info0.noise) ? info0.noise : rIw.noise), noise: wifi.noise(finite(info0.noise) ? info0.noise : rIw.noise),
@@ -458,6 +477,41 @@ return baseclass.extend({
if (mac) everyone.push({ mac: mac, ips: self.hintAddrs(hintOf(mac), subnets) }); if (mac) everyone.push({ mac: mac, ips: self.hintAddrs(hintOf(mac), subnets) });
}); });
}); });
/* wired hosts: what the bridges learned on ports that are not
Wi-Fi, once per MAC (the entry heard last, a learned one before
a static one), stations left out */
var staMacs = Object.create(null), fdb = Object.create(null), wiredMacs = [];
everyone.forEach(function(e) { staMacs[e.mac] = true; });
arr(raw.fdb).forEach(function(h) {
var mac = h && names.normMac(h.mac);
if (!mac || staMacs[mac] || typeof h.port !== 'string' || self.isWireless(h.port, devs[h.port], wlNames)) return;
var seen = finite(h.seen) && h.seen >= 0 ? h.seen : null, prev = fdb[mac];
if (prev && (seen == null || (prev.seen != null && prev.seen <= seen))) return;
if (!prev) wiredMacs.push(mac);
fdb[mac] = { port: h.port, bridge: typeof h.bridge === 'string' ? h.bridge : null, vlan: finite(h.vlan) ? h.vlan : null, seen: seen };
});
/* the gateway, by the address the host hints give it; on a bridge
with several ports the uplink is the port it was learned on */
var gwMac = null;
if (uplink && uplink.gateway) Object.keys(hints).forEach(function(k) {
var mac = names.normMac(k);
if (mac && arr(obj(hints[k]).ipaddrs).concat(arr(obj(hints[k]).ip6addrs)).indexOf(uplink.gateway) >= 0) gwMac = mac;
});
if (uplink && gwMac && fdb[gwMac] && uplink.port !== fdb[gwMac].port) {
var via = this.resolveUplink(raw.ifaces, devs, wlNames, fdb[gwMac].port);
if (via && via.port === fdb[gwMac].port) uplink = via;
}
/* the network on each bridge (or bridge VLAN: br-lan.10), and its
IPv4 subnets for the hosts' addresses */
var lans = Object.create(null);
arr(raw.ifaces).forEach(function(i) {
var d = i && (i.l3_device || i.device);
if (typeof d === 'string' && i.interface && !lans[d])
lans[d] = { name: String(i.interface), ipv4: arr(i['ipv4-address']).map(function(a) { return a && a.address ? a.address + '/' + a.mask : null; }).filter(Boolean) };
});
function lanOf(h) { return h.bridge ? (h.vlan != null && lans[h.bridge + '.' + h.vlan]) || lans[h.bridge] || null : null; }
function wiredSubnets(mac) { var l = lanOf(fdb[mac]); return l && l.ipv4.length ? l.ipv4 : subnets; }
wiredMacs.forEach(function(mac) { everyone.push({ mac: mac, ips: self.hintAddrs(hintOf(mac), wiredSubnets(mac)) }); });
var mdns = names.mdnsForStations(raw.mdns, everyone); var mdns = names.mdnsForStations(raw.mdns, everyone);
ssids.forEach(function(ss) { ssids.forEach(function(ss) {
@@ -525,6 +579,43 @@ return baseclass.extend({
}); });
}); });
/* A host alone on its port has the port's link and traffic to
itself; hosts sharing one (behind a switch or another access
point) only share the port's. The gateway is found by the
address the host hints give it (above). */
var perPort = Object.create(null);
wiredMacs.forEach(function(mac) { var p = fdb[mac].port; perPort[p] = (perPort[p] || 0) + 1; });
var wired = wiredMacs.map(function(mac) {
var h = fdb[mac], d = devs[h.port], hint = hintOf(mac), ips = self.hintAddrs(hint, wiredSubnets(mac));
var alias = raw.aliases && raw.aliases[mac];
var vendor = typeof raw.vendor === 'function' ? raw.vendor(names.ouiKey(mac)) : null;
var nm = names.resolve({ mac: mac, alias: alias, ips: ips, rdns: raw.rdns, mdns: mdns, hint: hint, wps: null, vendor: vendor });
return {
mac: mac,
macLabel: fmt.mac(mac),
name: nm.name,
nameSource: nm.source,
nameSourceLabel: nm.sourceLabel,
icon: nm.icon,
secondary: nm.secondary,
isPrivate: nm.isPrivate,
alias: alias ? alias.name : null,
aliasSid: alias ? alias.sid : null,
vendor: nm.source === 'vendor' ? vendor : null,
ips: ips,
port: h.port,
bridge: h.bridge,
network: lanOf(h) ? lanOf(h).name : null,
vlan: h.vlan,
seen: h.seen,
peers: perPort[h.port],
shared: perPort[h.port] > 1,
speed: d ? self.parseSpeed(d.speed) : null,
carrier: d ? d.carrier !== false : null,
gateway: mac === gwMac
};
});
var mem = this.memory(info.memory); var mem = this.memory(info.memory);
var release = obj(board.release); var release = obj(board.release);
return { return {
@@ -547,7 +638,8 @@ return baseclass.extend({
networks: this.networks(raw.ifaces, uplink), networks: this.networks(raw.ifaces, uplink),
radios: radios, radios: radios,
ssids: ssids, ssids: ssids,
clients: clients clients: clients,
wiredHosts: wired
}; };
}, },
@@ -12,6 +12,7 @@
'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'; 'require vantage.wires as wires';
'require vantage.backdrop as backdrop';
/* 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
@@ -36,6 +37,8 @@ var callDevs = rpc.declare({ object: 'network.device', method: 'status', expect:
without keys or RADIUS secrets, and the validated alias write */ without keys or RADIUS secrets, and the validated alias write */
var callWifi = rpc.declare({ object: 'luci.vantage', method: 'wireless', expect: { '': {} } }); var callWifi = rpc.declare({ object: 'luci.vantage', method: 'wireless', expect: { '': {} } });
var callThermal = rpc.declare({ object: 'luci.vantage', method: 'thermal', expect: { zones: [] } }); var callThermal = rpc.declare({ object: 'luci.vantage', method: 'thermal', expect: { zones: [] } });
/* { hosts, crowded }: crowded when a bridge holds too many entries to list */
var callWired = rpc.declare({ object: 'luci.vantage', method: 'wired', expect: { '': {} } });
var callHints = rpc.declare({ object: 'luci-rpc', method: 'getHostHints', expect: { '': {} } }); var callHints = rpc.declare({ object: 'luci-rpc', method: 'getHostHints', expect: { '': {} } });
var callIwInfo = rpc.declare({ object: 'iwinfo', method: 'info', params: [ 'device' ], expect: { '': {} } }); var callIwInfo = rpc.declare({ object: 'iwinfo', method: 'info', params: [ 'device' ], expect: { '': {} } });
var callAssoc = rpc.declare({ object: 'iwinfo', method: 'assoclist', params: [ 'device' ], expect: { results: [] } }); var callAssoc = rpc.declare({ object: 'iwinfo', method: 'assoclist', params: [ 'device' ], expect: { results: [] } });
@@ -75,6 +78,7 @@ function reducedMotion() {
/* ------------------------------------------------------------- SVG kit */ /* ------------------------------------------------------------- SVG kit */
var ICONS = { var ICONS = {
filter: [ 'M4 5.5h16l-6.2 7.3v5.2l-3.6 1.8v-7Z' ],
globe: [ 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18Z', 'M3.4 9.2h17.2M3.4 14.8h17.2', 'M12 3c2.4 2.5 3.6 5.5 3.6 9s-1.2 6.5-3.6 9c-2.4-2.5-3.6-5.5-3.6-9s1.2-6.5 3.6-9Z' ], globe: [ 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18Z', 'M3.4 9.2h17.2M3.4 14.8h17.2', 'M12 3c2.4 2.5 3.6 5.5 3.6 9s-1.2 6.5-3.6 9c-2.4-2.5-3.6-5.5-3.6-9s1.2-6.5 3.6-9Z' ],
ap: [ 'M4.5 15h15a1.5 1.5 0 0 1 1.5 1.5v1.5a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18v-1.5A1.5 1.5 0 0 1 4.5 15Z', 'M7 17.25h.01', 'M8.6 10.6a4.8 4.8 0 0 1 6.8 0', 'M6.1 8.1a8.4 8.4 0 0 1 11.8 0', 'M11.3 12.9a1 1 0 0 1 1.4 0' ], ap: [ 'M4.5 15h15a1.5 1.5 0 0 1 1.5 1.5v1.5a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18v-1.5A1.5 1.5 0 0 1 4.5 15Z', 'M7 17.25h.01', 'M8.6 10.6a4.8 4.8 0 0 1 6.8 0', 'M6.1 8.1a8.4 8.4 0 0 1 11.8 0', 'M11.3 12.9a1 1 0 0 1 1.4 0' ],
radio: [ 'M12 12.5v8', 'M8.5 20.5h7', 'M9.2 8.7a4 4 0 0 0 0 5.6', 'M14.8 8.7a4 4 0 0 1 0 5.6', 'M6.4 5.9a8 8 0 0 0 0 11.2', 'M17.6 5.9a8 8 0 0 1 0 11.2', 'M12 10.3a1.2 1.2 0 1 1 0 2.4a1.2 1.2 0 1 1 0-2.4Z' ], radio: [ 'M12 12.5v8', 'M8.5 20.5h7', 'M9.2 8.7a4 4 0 0 0 0 5.6', 'M14.8 8.7a4 4 0 0 1 0 5.6', 'M6.4 5.9a8 8 0 0 0 0 11.2', 'M17.6 5.9a8 8 0 0 1 0 11.2', 'M12 10.3a1.2 1.2 0 1 1 0 2.4a1.2 1.2 0 1 1 0-2.4Z' ],
@@ -438,6 +442,23 @@ function writeChartStyle(v) {
} catch (e) {} } catch (e) {}
} }
/* The network path's backdrop the viewer picked (backdrop.STYLES), in
this browser */
var BACKDROP_KEY = 'vantage.backdrop.v1';
function readBackdrop() {
var v = null;
try { v = window.localStorage.getItem(BACKDROP_KEY); } catch (e) { v = null; }
return backdrop.STYLES.indexOf(v) >= 0 ? v : backdrop.STYLES[0];
}
function writeBackdrop(v) {
try {
if (v === backdrop.STYLES[0]) window.localStorage.removeItem(BACKDROP_KEY);
else window.localStorage.setItem(BACKDROP_KEY, v);
} catch (e) {}
}
/* ================================================================= view */ /* ================================================================= view */
return view.extend({ return view.extend({
@@ -484,9 +505,11 @@ return view.extend({
/* null when the call fails: no answer is not "no radios" */ /* null when the call fails: no answer is not "no radios" */
L.resolveDefault(callWifi(), null), L.resolveDefault(callWifi(), null),
L.resolveDefault(callHints(), {}), L.resolveDefault(callHints(), {}),
L.resolveDefault(callThermal(), []) L.resolveDefault(callThermal(), []),
L.resolveDefault(callWired(), {})
]).then(function(r) { ]).then(function(r) {
var wifiDevs = (r[4] && typeof r[4] === 'object') ? r[4] : null; var wifiDevs = (r[4] && typeof r[4] === 'object') ? r[4] : null;
var wiredReply = (r[7] && typeof r[7] === 'object') ? r[7] : {};
var ifnames = []; var ifnames = [];
Object.keys(wifiDevs || {}).forEach(function(rn) { Object.keys(wifiDevs || {}).forEach(function(rn) {
var ifs = (wifiDevs[rn] && Array.isArray(wifiDevs[rn].interfaces)) ? wifiDevs[rn].interfaces : []; var ifs = (wifiDevs[rn] && Array.isArray(wifiDevs[rn].interfaces)) ? wifiDevs[rn].interfaces : [];
@@ -513,6 +536,7 @@ return view.extend({
else iwinfo[p[1]] = p[2]; else iwinfo[p[1]] = p[2];
}); });
return { info: r[0] || {}, stat: r[1] || '', ifaces: r[2] || [], devs: r[3] || {}, wifi: wifiDevs, hints: r[5] || {}, thermal: r[6] || [], return { info: r[0] || {}, stat: r[1] || '', ifaces: r[2] || [], devs: r[3] || {}, wifi: wifiDevs, hints: r[5] || {}, thermal: r[6] || [],
fdb: Array.isArray(wiredReply.hosts) ? wiredReply.hosts : [], fdbCrowded: wiredReply.crowded === true,
assoc: assoc, hapd: hapd, hapdStatus: hapdStatus, iwinfo: iwinfo, at: Date.now(), started: started }; assoc: assoc, hapd: hapd, hapdStatus: hapdStatus, iwinfo: iwinfo, at: Date.now(), started: started };
}); });
}); });
@@ -548,12 +572,13 @@ return view.extend({
wireless: null, wireless: null,
lastOk: 0, lastErr: null, paused: false lastOk: 0, lastErr: null, paused: false
}; };
this.ui = { sort: 'live', dir: -1, filter: '', chip: 'all', drawer: null, editing: null, hover: null }; this.ui = { sorts: { wifi: { key: 'live', dir: -1 }, wired: { key: 'seen', dir: 1 } }, kind: 'wifi', filter: '', chip: 'all', drawer: null, editing: null, hover: null };
this.folds = readFolds(); this.folds = readFolds();
this.foldEls = {}; this.foldEls = {};
this.foldBtns = {}; this.foldBtns = {};
this.layouts = readLayouts(); this.layouts = readLayouts();
this.chartStyle = readChartStyle(); this.chartStyle = readChartStyle();
this.backdropStyle = readBackdrop();
this.editing = false; this.editing = false;
this.drag = null; this.drag = null;
this.applyMdns(data.mdns); this.applyMdns(data.mdns);
@@ -585,7 +610,7 @@ return view.extend({
var m = model.build({ var m = model.build({
board: this.board, info: raw.info, ifaces: raw.ifaces, devs: raw.devs, wifi: raw.wifi, iwinfo: raw.iwinfo, board: this.board, info: raw.info, ifaces: raw.ifaces, devs: raw.devs, wifi: raw.wifi, iwinfo: raw.iwinfo,
assoc: raw.assoc, hapd: raw.hapd, hapdStatus: raw.hapdStatus, hints: raw.hints, assoc: raw.assoc, hapd: raw.hapd, hapdStatus: raw.hapdStatus, hints: raw.hints, fdb: raw.fdb,
aliases: this.aliases, rdns: st.rdns, mdns: st.mdns, aliases: this.aliases, rdns: st.rdns, mdns: st.mdns,
vendor: st.oui ? function(k) { return k ? st.oui.lookup(k) : null; } : null vendor: st.oui ? function(k) { return k ? st.oui.lookup(k) : null; } : null
}); });
@@ -692,18 +717,19 @@ return view.extend({
}, },
/* names beyond what one poll carries: reverse DNS once per new /* names beyond what one poll carries: reverse DNS once per new
address, mDNS every minute when present, the vendor table once */ address, mDNS every minute when present, the vendor table once;
for Wi-Fi clients and wired hosts alike */
resolveMore: function(m) { resolveMore: function(m) {
var st = this.st, self = this; var st = this.st, self = this;
var ips = []; var ips = [], all = m.clients.concat(m.wiredHosts || []);
/* answers are kept for the session, but only for addresses still /* answers are kept for the session, but only for addresses still
in use once there are many */ in use once there are many */
if (Object.keys(st.rdns).length > 1024) { if (Object.keys(st.rdns).length > 1024) {
var keep = Object.create(null); var keep = Object.create(null);
m.clients.forEach(function(c) { c.ips.forEach(function(ip) { if (ip in st.rdns) keep[ip] = st.rdns[ip]; }); }); all.forEach(function(c) { c.ips.forEach(function(ip) { if (ip in st.rdns) keep[ip] = st.rdns[ip]; }); });
st.rdns = keep; st.rdns = keep;
} }
m.clients.forEach(function(c) { all.forEach(function(c) {
c.ips.forEach(function(ip) { if (!(ip in st.rdns) && ips.indexOf(ip) < 0 && ips.length < 64) ips.push(ip); }); c.ips.forEach(function(ip) { if (!(ip in st.rdns) && ips.indexOf(ip) < 0 && ips.length < 64) ips.push(ip); });
}); });
if (ips.length && !st.rdnsBusy && !st.rdnsOff) { if (ips.length && !st.rdnsBusy && !st.rdnsOff) {
@@ -726,7 +752,7 @@ return view.extend({
st.mdnsAt = Date.now(); st.mdnsAt = Date.now();
L.resolveDefault(callMdns(), null).then(function(r) { self.applyMdns(r); }); L.resolveDefault(callMdns(), null).then(function(r) { self.applyMdns(r); });
} }
var wantVendor = m.clients.some(function(c) { return c.nameSource === 'mac'; }); var wantVendor = all.some(function(c) { return c.nameSource === 'mac'; });
if (wantVendor && !st.oui && !st.ouiLoading) { if (wantVendor && !st.oui && !st.ouiLoading) {
st.ouiLoading = true; st.ouiLoading = true;
L.require('vantage.oui').then(function(mod) { st.oui = mod; self.rerender(); }).catch(function() {}); L.require('vantage.oui').then(function(mod) { st.oui = mod; self.rerender(); }).catch(function() {});
@@ -739,7 +765,7 @@ return view.extend({
var st = this.st, raw = this.lastRaw; var st = this.st, raw = this.lastRaw;
var m = model.build({ var m = model.build({
board: this.board, info: raw.info, ifaces: raw.ifaces, devs: raw.devs, wifi: raw.wifi, iwinfo: raw.iwinfo, board: this.board, info: raw.info, ifaces: raw.ifaces, devs: raw.devs, wifi: raw.wifi, iwinfo: raw.iwinfo,
assoc: raw.assoc, hapd: raw.hapd, hapdStatus: raw.hapdStatus, hints: raw.hints, assoc: raw.assoc, hapd: raw.hapd, hapdStatus: raw.hapdStatus, hints: raw.hints, fdb: raw.fdb,
aliases: this.aliases, rdns: st.rdns, mdns: st.mdns, aliases: this.aliases, rdns: st.rdns, mdns: st.mdns,
vendor: st.oui ? function(k) { return k ? st.oui.lookup(k) : null; } : null vendor: st.oui ? function(k) { return k ? st.oui.lookup(k) : null; } : null
}); });
@@ -1049,6 +1075,22 @@ return view.extend({
return E('div', { 'class': 'vt-edit-field' }, [ E('label', { 'for': 'vt-chart-style' }, [ _('Throughput chart') ]), pick ]); return E('div', { 'class': 'vt-edit-field' }, [ E('label', { 'for': 'vt-chart-style' }, [ _('Throughput chart') ]), pick ]);
}, },
/* the network path's backdrop, picked under Customize */
buildBackdropPick: function() {
var self = this, names = { 'grid': _('Grid packets'), 'stars': _('Starfield'), 'none': _('None') };
var pick = E('select', { 'class': 'vt-select', 'id': 'vt-backdrop-style', 'change': function(ev) { self.setBackdrop(ev.target.value); } },
backdrop.STYLES.map(function(k) { return E('option', { 'value': k }, [ names[k] ]); }));
pick.value = this.backdropStyle;
return E('div', { 'class': 'vt-edit-field' }, [ E('label', { 'for': 'vt-backdrop-style' }, [ _('Network path background') ]), pick ]);
},
setBackdrop: function(v) {
if (backdrop.STYLES.indexOf(v) < 0 || v === this.backdropStyle) return;
this.backdropStyle = v;
writeBackdrop(v);
if (this.backdrop) this.backdrop.style(v);
},
setChartStyle: function(v) { setChartStyle: function(v) {
if (series.CHART_STYLES.indexOf(v) < 0 || v === this.chartStyle) return; if (series.CHART_STYLES.indexOf(v) < 0 || v === this.chartStyle) return;
this.chartStyle = v; this.chartStyle = v;
@@ -1065,7 +1107,7 @@ return view.extend({
el.editBar = E('div', { 'class': 'vt-editbar', 'role': 'region', 'aria-label': _('Customize the dashboard'), 'hidden': 'hidden' }, [ el.editBar = E('div', { 'class': 'vt-editbar', 'role': 'region', 'aria-label': _('Customize the dashboard'), 'hidden': 'hidden' }, [
E('div', { 'class': 'vt-editbar-text' }, [ E('strong', {}, [ _('Customize the dashboard') ]), el.editHint ]), E('div', { 'class': 'vt-editbar-text' }, [ E('strong', {}, [ _('Customize the dashboard') ]), el.editHint ]),
el.editHidden, el.editHidden,
this.buildChartPick(), E('div', { 'class': 'vt-edit-picks' }, [ this.buildChartPick(), this.buildBackdropPick() ]),
E('div', { 'class': 'vt-editbar-actions' }, [ E('div', { 'class': 'vt-editbar-actions' }, [
E('button', { 'type': 'button', 'class': 'vt-btn', 'click': function() { E('button', { 'type': 'button', 'class': 'vt-btn', 'click': function() {
var l = self.lay; var l = self.lay;
@@ -1316,11 +1358,14 @@ return view.extend({
this.wireMap = Object.create(null); this.wireMap = Object.create(null);
this.hero = 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') }, [ var tile = 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')) ]),
el.path, el.path,
el.kpis el.kpis
])); ]));
/* packets or stars behind the path, moved by the uplink's rate */
this.backdrop = backdrop.mount(tile, el.path, this.backdropStyle);
return tile;
}, },
/* the nodes the path shows: rebuilt only when this changes (a radio, /* the nodes the path shows: rebuilt only when this changes (a radio,
@@ -1330,7 +1375,28 @@ return view.extend({
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'); 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');
return 'r\n' + m.radios.map(function(r) { return 'r\n' + m.radios.map(function(r) {
return r.id + '=' + m.ssids.filter(function(s) { return s.radio === r.id; }).map(function(s) { return s.id; }).join(','); return r.id + '=' + m.ssids.filter(function(s) { return s.radio === r.id; }).map(function(s) { return s.id; }).join(',');
}).join('\n'); }).join('\n') + '\nwired=' + this.wiredPorts().join(',');
},
/* the ports with wired hosts downstream of this device, at most 8:
hosts on the uplink's port are the way to the gateway, not clients
(on a bridge with several ports whose gateway port is unknown, only
the gateway is left out) */
wiredPorts: function() {
var m = this.m, up = m.uplink, out = [];
(m.wiredHosts || []).forEach(function(c) {
if (c.gateway || (up && up.port && c.port === up.port)) return;
if (out.indexOf(c.port) < 0) out.push(c.port);
});
return out.sort().slice(0, 8);
},
/* the Clients tile on its wired list, on one port's hosts or all */
showWired: function(port) {
this.ui.kind = 'wired';
this.ui.chip = port ? 'port:' + port : 'all';
this.setFilter('');
this.el.clients.scrollIntoView({ behavior: reducedMotion() ? 'auto' : 'smooth', block: 'start' });
}, },
paintHero: function() { paintHero: function() {
@@ -1407,6 +1473,26 @@ return view.extend({
h.radios.push(b); h.radios.push(b);
}); });
if (!h.radios.length) h.branches.appendChild(E('div', { 'class': 'vt-empty-inline' }, [ _('No radios reported') ])); if (!h.radios.length) h.branches.appendChild(E('div', { 'class': 'vt-empty-inline' }, [ _('No radios reported') ]));
/* wired hosts downstream: one branch beside the radios, a node
per port */
var ports = this.wiredPorts();
if (ports.length) {
var wb = { id: 'wired', wired: true, ssids: [], title: span('vt-node-title vt-tn'), sub: span('vt-node-sub vt-tn') };
wb.el = E('button', { 'type': 'button', 'class': 'vt-node vt-node-radio', 'click': function() { self.showWired(null); } }, [
E('span', { 'class': 'vt-node-body' }, [ E('span', { 'class': 'vt-node-kicker' }, [ _('Wired') ]), wb.title, wb.sub ])
]);
var wcol = E('div', { 'class': 'vt-branch-ssids' });
ports.forEach(function(p) {
var sn = { id: 'port:' + p, port: p, name: E('span'), count: span('vt-tn') };
sn.el = E('button', { 'type': 'button', 'class': 'vt-node vt-node-ssid', 'click': function() { self.showWired(p); } }, [
E('span', { 'class': 'vt-node-body' }, [ E('span', { 'class': 'vt-node-title vt-ssid-name' }, [ sn.name ]), E('span', { 'class': 'vt-node-sub' }, [ sn.count ]) ])
]);
wb.ssids.push(sn);
wcol.appendChild(sn.el);
});
wb.wrap = E('div', { 'class': 'vt-branch' }, [ wb.el, wcol ]);
h.radios.push(wb);
}
} }
h.radios.forEach(function(b) { h.radios.forEach(function(b) {
h.branches.appendChild(b.wrap); h.branches.appendChild(b.wrap);
@@ -1429,9 +1515,13 @@ return view.extend({
/* this poll's text, classes and flow state on the nodes in place */ /* this poll's text, classes and flow state on the nodes in place */
updateHero: function() { updateHero: function() {
var self = this, m = this.m, st = this.st, h = this.hero, up = m.uplink, upR = st.uplinkRate, d = m.device; var self = this, m = this.m, st = this.st, h = this.hero, up = m.uplink, upR = st.uplinkRate, d = m.device;
/* a gateway the bridge hears (an access point's router) shows its
name once one is known beyond its vendor */
var gwHost = (m.wiredHosts || []).filter(function(c) { return c.gateway; })[0];
var gwName = gwHost && /^(alias|dhcp|mdns|dns)$/.test(gwHost.nameSource) ? gwHost.name : null;
txt(h.gwKicker, up && up.isWan ? _('Internet') : _('Gateway')); txt(h.gwKicker, up && up.isWan ? _('Internet') : _('Gateway'));
txt(h.gwTitle, up && up.gateway ? up.gateway : up && up.defaultRoute ? up.l3dev : _('No default route', 'vantage')); txt(h.gwTitle, gwName || (up && up.gateway ? up.gateway : up && up.defaultRoute ? up.l3dev : _('No default route', 'vantage')));
txt(h.gwSub, up && up.dns.length ? _('DNS %s').format(up.dns[0]) : _('Reachability not probed')); txt(h.gwSub, gwName ? up.gateway : 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.dnVal, upR ? fmt.bitsParts(upR.rx).v : fmt.DASH);
txt(h.dnUnit, upR ? fmt.bitsParts(upR.rx).u : ''); txt(h.dnUnit, upR ? fmt.bitsParts(upR.rx).u : '');
@@ -1445,6 +1535,7 @@ return view.extend({
txt(h.apUp, d.uptime != null ? _('up %s').format(fmt.duration(d.uptime)) : ''); txt(h.apUp, d.uptime != null ? _('up %s').format(fmt.duration(d.uptime)) : '');
h.upActive = !!(upR && upR.rx + upR.tx > 50e3); h.upActive = !!(upR && upR.rx + upR.tx > 50e3);
h.upRate = upR ? upR.rx + upR.tx : 0; h.upRate = upR ? upR.rx + upR.tx : 0;
if (this.backdrop) this.backdrop.update({ down: upR ? upR.rx : null, up: upR ? upR.tx : null, origin: h.ap });
if (m.wired) { if (m.wired) {
var nets = Object.create(null); var nets = Object.create(null);
@@ -1468,6 +1559,7 @@ return view.extend({
m.radios.forEach(function(r) { radios[r.id] = r; }); m.radios.forEach(function(r) { radios[r.id] = r; });
m.ssids.forEach(function(s) { ssids[s.id] = s; }); m.ssids.forEach(function(s) { ssids[s.id] = s; });
h.radios.forEach(function(b) { h.radios.forEach(function(b) {
if (b.wired) { self.updateWiredBranch(b); return; }
var r = radios[b.id], rr = st.radioRates[b.id]; var r = radios[b.id], rr = st.radioRates[b.id];
if (!r) return; if (!r) return;
setClass(b.el, 'vt-node vt-node-radio' + (r.up ? '' : ' vt-node-off')); setClass(b.el, 'vt-node vt-node-radio' + (r.up ? '' : ' vt-node-off'));
@@ -1505,6 +1597,26 @@ return view.extend({
}); });
}, },
/* the Wired branch of the path: its hosts, and per port the one host
with its link speed or how many share the port */
updateWiredBranch: function(b) {
var st = this.st, hosts = this.m.wiredHosts || [], n = 0, rx = 0, tx = 0, seen = false;
b.ssids.forEach(function(sn) {
var on = hosts.filter(function(c) { return c.port === sn.port; }), r = st.devRates[sn.port], one = on.length === 1 ? on[0] : null;
n += on.length;
if (r) { rx += r.rx; tx += r.tx; seen = true; }
setClass(sn.el, 'vt-node vt-node-ssid' + (on.length ? '' : ' vt-node-idle'));
setTitle(sn.el, _('Show the devices on %s').format(sn.port));
txt(sn.name, one ? sn.port + ' · ' + one.name : sn.port);
txt(sn.count, one ? (one.speed ? fmt.phyRate(one.speed.mbps * 1000) : fmt.DASH) : on.length === 1 ? _('1 device') : _('%d devices').format(on.length));
sn.active = !!(r && r.rx + r.tx > 50e3);
});
txt(b.title, n === 1 ? _('1 device') : _('%d devices').format(n));
txt(b.sub, seen ? '↓ ' + fmt.bits(tx) : fmt.DASH);
b.active = !!(seen && rx + tx > 50e3);
b.rate = seen ? rx + tx : 0;
},
/* 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. The cells stay; only a change of shape (tone, or who is worst off. The cells stay; only a change of shape (tone, or
whether a cell links somewhere) rebuilds them. */ whether a cell links somewhere) rebuilds them. */
@@ -1536,11 +1648,14 @@ return view.extend({
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(' · ');
/* wired hosts downstream count as clients too */
var ports = this.wiredPorts(), wiredN = (m.wiredHosts || []).filter(function(c) { return ports.indexOf(c.port) >= 0; }).length;
if (wiredN) bandText = [ bandText, _('%d wired').format(wiredN) ].filter(Boolean).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 [ return [
{ label: _('Clients'), value: String(m.clients.length), sub: bandText || _('none connected'), tone: 'plain', { label: _('Clients'), value: String(m.clients.length + wiredN), sub: bandText || _('none connected'), tone: 'plain',
act: function() { self.el.clients.scrollIntoView({ behavior: reducedMotion() ? 'auto' : 'smooth', block: 'start' }); } }, act: function() { self.el.clients.scrollIntoView({ behavior: reducedMotion() ? 'auto' : 'smooth', block: 'start' }); } },
{ 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', { 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',
act: function() { self.openDrawer('uplink', 'uplink'); } }, act: function() { self.openDrawer('uplink', 'uplink'); } },
@@ -2128,30 +2243,69 @@ return view.extend({
/* ----------------------------------------------------------- clients */ /* ----------------------------------------------------------- clients */
buildClients: function() { buildClients: function() {
var self = this, el = this.el; var self = this, el = this.el, ui = this.ui;
el.filter = E('input', { 'type': 'text', 'class': 'vt-filter-input', 'placeholder': _('Filter by name, address, SSID…'), el.filter = E('input', { 'type': 'text', 'class': 'vt-filter-input', 'placeholder': _('Filter by name, address, SSID…'),
'aria-label': _('Filter clients'), 'autocomplete': 'off', 'spellcheck': 'false', 'aria-label': _('Filter clients'), 'autocomplete': 'off', 'spellcheck': 'false',
'input': function(ev) { self.ui.filter = ev.target.value; self.paintClients(); } }); 'input': function(ev) { self.ui.filter = ev.target.value; self.paintClients(); } });
el.filterClear = E('button', { 'type': 'button', 'class': 'vt-filter-clear', 'aria-label': _('Clear filter', 'vantage'), 'hidden': 'hidden', el.filterClear = E('button', { 'type': 'button', 'class': 'vt-filter-clear', 'aria-label': _('Clear filter', 'vantage'), 'hidden': 'hidden',
'click': function() { self.setFilter(''); el.filter.focus(); } }, [ icon('close') ]); 'click': function() { self.setFilter(''); el.filter.focus(); } }, [ icon('close') ]);
el.chips = E('div', { 'class': 'vt-fchips', 'role': 'group', 'aria-label': _('Quick filters') }); /* the quick filters sit in a menu at the end of the search box; the
one in force shows as a tag in the box, so none is ever hidden */
el.filterTag = E('button', { 'type': 'button', 'class': 'vt-ftag', 'hidden': 'hidden',
'click': function(ev) { ev.preventDefault(); ui.chip = 'all'; self.paintClients(); el.filter.focus(); } });
el.filterBtn = E('button', { 'type': 'button', 'class': 'vt-filter-btn', 'aria-haspopup': 'true', 'aria-expanded': 'false',
'aria-label': _('Quick filters'), 'title': _('Quick filters'),
'click': function(ev) { ev.preventDefault(); self.filterMenu(); } }, [ icon('filter') ]);
el.filterMenu = E('div', { 'class': 'vt-fmenu', 'role': 'menu', 'aria-label': _('Quick filters'), 'hidden': 'hidden',
'keydown': function(ev) { self.filterMenuKey(ev); } });
/* Wi-Fi or wired clients, on a device that has both */
el.kinds = E('div', { 'class': 'vt-kinds', 'role': 'group', 'aria-label': _('Kind of client'), 'hidden': 'hidden' });
el.count = E('span', { 'class': 'vt-count vt-tn' }); el.count = E('span', { 'class': 'vt-count vt-tn' });
el.thead = E('thead'); el.thead = E('thead');
el.tbody = E('tbody'); el.tbody = E('tbody');
el.clientsEmpty = E('div', { 'class': 'vt-empty', 'hidden': 'hidden' }); el.clientsEmpty = E('div', { 'class': 'vt-empty', 'hidden': 'hidden' });
this.rowMap = new Map(); this.rowMap = new Map();
el.clients = E('section', { 'class': 'vt-tile vt-clients', 'id': 'vt-clients', 'aria-label': _('Clients') }, [ el.clients = E('section', { 'class': 'vt-tile vt-clients', 'id': 'vt-clients', 'aria-label': _('Clients') }, [
tileHead('users', _('Clients'), [ el.count ]), tileHead('users', _('Clients'), [ el.kinds, el.count ]),
E('div', { 'class': 'vt-toolbar' }, [ E('div', { 'class': 'vt-toolbar' }, [
E('label', { 'class': 'vt-filter' }, [ icon('search'), el.filter, E('kbd', { 'class': 'vt-key-hint' }, [ 'F' ]), el.filterClear ]), E('div', { 'class': 'vt-filter-wrap' }, [
el.chips E('label', { 'class': 'vt-filter' }, [ icon('search'), el.filterTag, el.filter, E('kbd', { 'class': 'vt-key-hint' }, [ 'F' ]), el.filterClear, el.filterBtn ]),
el.filterMenu
])
]), ]),
E('div', { 'class': 'vt-table-wrap' }, [ E('table', { 'class': 'vt-ctable' }, [ el.thead, el.tbody ]) ]), E('div', { 'class': 'vt-table-wrap' }, [ E('table', { 'class': 'vt-ctable' }, [ el.thead, el.tbody ]) ]),
el.clientsEmpty el.clientsEmpty
]); ]);
document.addEventListener('click', function(ev) {
if (!el.filterMenu.hidden && !el.filterMenu.contains(ev.target) && !el.filterBtn.contains(ev.target)) self.filterMenu(false);
});
return el.clients; return el.clients;
}, },
/* open (true), close (false) or toggle the quick filter menu */
filterMenu: function(open) {
var el = this.el;
if (open == null) open = el.filterMenu.hidden;
el.filterMenu.hidden = !open;
el.filterBtn.setAttribute('aria-expanded', String(!!open));
if (open) {
var on = el.filterMenu.querySelector('[aria-checked="true"]') || el.filterMenu.querySelector('button');
if (on) on.focus();
}
},
filterMenuKey: function(ev) {
var items = Array.prototype.slice.call(this.el.filterMenu.querySelectorAll('button')), n = items.length, i = items.indexOf(document.activeElement);
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); this.filterMenu(false); this.el.filterBtn.focus(); }
else if ((ev.key === 'ArrowDown' || ev.key === 'ArrowUp') && n) {
ev.preventDefault();
if (i < 0) i = ev.key === 'ArrowDown' ? n - 1 : 0;
items[(i + (ev.key === 'ArrowDown' ? 1 : n - 1)) % n].focus();
}
else if ((ev.key === 'Home' || ev.key === 'End') && n) { ev.preventDefault(); items[ev.key === 'Home' ? 0 : n - 1].focus(); }
else if (ev.key === 'Tab') this.filterMenu(false);
},
setFilter: function(v) { setFilter: function(v) {
this.ui.filter = v; this.ui.filter = v;
this.el.filter.value = v; this.el.filter.value = v;
@@ -2159,13 +2313,19 @@ return view.extend({
}, },
filterSsid: function(ssid, band) { filterSsid: function(ssid, band) {
this.ui.kind = 'wifi';
this.ui.chip = 'all'; this.ui.chip = 'all';
this.setFilter(ssid); this.setFilter(ssid);
this.el.clients.scrollIntoView({ behavior: reducedMotion() ? 'auto' : 'smooth', block: 'start' }); this.el.clients.scrollIntoView({ behavior: reducedMotion() ? 'auto' : 'smooth', block: 'start' });
}, },
/* a wired host's traffic: its port's, when it has the port to itself */
portRate: function(c) {
return c.shared ? null : this.st.devRates[c.port] || null;
},
sortValue: function(c, key) { sortValue: function(c, key) {
var r = this.st.staRates[c.mac]; var r = typeof c.port === 'string' ? this.portRate(c) : this.st.staRates[c.mac];
switch (key) { switch (key) {
case 'name': return String(c.name).toLowerCase(); case 'name': return String(c.name).toLowerCase();
case 'signal': return c.signal == null ? -999 : c.signal; case 'signal': return c.signal == null ? -999 : c.signal;
@@ -2173,77 +2333,154 @@ return view.extend({
case 'rate': return Math.max(c.tx && c.tx.rate || 0, c.rx && c.rx.rate || 0); case 'rate': return Math.max(c.tx && c.tx.rate || 0, c.rx && c.rx.rate || 0);
case 'exp': return c.exp ? c.exp.score : -1; case 'exp': return c.exp ? c.exp.score : -1;
case 'time': return c.connected || 0; case 'time': return c.connected || 0;
case 'link': return c.shared || !c.speed ? -1 : c.speed.mbps;
case 'net': return ((c.network || c.bridge || '') + ' ' + (c.vlan == null ? '' : c.vlan)).toLowerCase();
case 'seen': return c.seen == null ? Infinity : c.seen;
default: return r ? r.rx + r.tx : -1; default: return r ? r.rx + r.tx : -1;
} }
}, },
/* a client (Wi-Fi station or wired host, which has a port) in the
list as filtered */
matches: function(c) { matches: function(c) {
var chip = this.ui.chip, q = String(this.ui.filter || '').trim().toLowerCase(); var chip = this.ui.chip, q = String(this.ui.filter || '').trim().toLowerCase(), hay;
if (chip === 'weak' && !(c.signal != null && c.signal < insight.WEAK_DBM)) return false; if (typeof c.port === 'string') {
if (chip === 'new' && !c.isNew) return false; if (/^port:/.test(chip) && c.port !== chip.slice(5)) return false;
if (chip === 'active' && !(this.st.staRates[c.mac] && this.st.staRates[c.mac].rx + this.st.staRates[c.mac].tx > 8000)) return false; hay = [ c.name, c.macLabel, c.mac, c.vendor, c.port, c.network, c.nameSourceLabel ];
if (/^band:/.test(chip) && c.band !== chip.slice(5)) return false; }
else {
if (chip === 'weak' && !(c.signal != null && c.signal < insight.WEAK_DBM)) return false;
if (chip === 'new' && !c.isNew) return false;
if (chip === 'active' && !(this.st.staRates[c.mac] && this.st.staRates[c.mac].rx + this.st.staRates[c.mac].tx > 8000)) return false;
if (/^band:/.test(chip) && c.band !== chip.slice(5)) return false;
hay = [ c.name, c.macLabel, c.mac, c.ssid, c.bandLabel, c.gen, c.vendor, c.wps, c.nameSourceLabel ];
}
if (!q) return true; if (!q) return true;
var hay = [ c.name, c.macLabel, c.mac, c.ssid, c.bandLabel, c.gen, c.vendor, c.wps, c.nameSourceLabel ].concat(c.ips || []).join(' ').toLowerCase(); hay = hay.concat(c.ips || []).join(' ').toLowerCase();
return q.split(/\s+/).every(function(w) { return hay.indexOf(w) >= 0; }); return q.split(/\s+/).every(function(w) { return hay.indexOf(w) >= 0; });
}, },
/* the quick filters of the list shown: [ id, label, count, warn ] */
filterDefs: function() {
var m = this.m, st = this.st, out;
if (this.ui.kind === 'wired') {
var hosts = m.wiredHosts, ports = [];
out = [ [ 'all', _('All'), hosts.length ] ];
hosts.forEach(function(h) { if (ports.indexOf(h.port) < 0) ports.push(h.port); });
if (ports.length > 1) ports.sort().forEach(function(p) { out.push([ 'port:' + p, p, hosts.filter(function(h) { return h.port === p; }).length ]); });
return out;
}
var bands = [];
out = [ [ 'all', _('All'), m.clients.length ] ];
m.clients.forEach(function(c) { if (c.band && bands.indexOf(c.band) < 0) bands.push(c.band); });
bands.sort().forEach(function(b) { out.push([ 'band:' + b, wifi.bandLabel(b), m.clients.filter(function(c) { return c.band === b; }).length ]); });
out.push([ 'active', _('Active'), m.clients.filter(function(c) { var r = st.staRates[c.mac]; return r && r.rx + r.tx > 8000; }).length ]);
out.push([ 'weak', _('Weak signal'), m.clients.filter(function(c) { return c.signal != null && c.signal < insight.WEAK_DBM; }).length, true ]);
out.push([ 'new', _('New', 'vantage'), m.clients.filter(function(c) { return c.isNew; }).length ]);
return out;
},
/* the quick filter menu, its button and the tag of the one in force */
paintFilters: function(defs) {
var self = this, el = this.el, ui = this.ui;
var on = defs.filter(function(d) { return d[0] === ui.chip; })[0];
el.filterBtn.hidden = defs.length < 2;
if (el.filterBtn.hidden && !el.filterMenu.hidden) this.filterMenu(false);
el.filterTag.hidden = !on || on[0] === 'all';
el.filterBtn.classList.toggle('vt-filter-btn-on', !el.filterTag.hidden);
var tag = el.filterTag.hidden ? '' : on[1];
if (this.tagSig !== tag) {
this.tagSig = tag;
dom.content(el.filterTag, tag ? [ E('span', {}, [ tag ]), icon('close') ] : []);
el.filterTag.setAttribute('aria-label', tag ? _('Remove filter: %s').format(tag) : '');
}
/* new counts go into the open menu in place: rebuilding it would
take the focus away */
var ids = defs.map(function(d) { return d[0]; }).concat(ui.chip).join('\n'), items = el.filterMenu.children;
if (this.filterIds === ids) {
defs.forEach(function(d, i) {
if (!items[i]) return;
txt(items[i].querySelector('.vt-fmenu-n'), d[2]);
items[i].classList.toggle('vt-fmenu-warn', !!(d[3] && d[2]));
});
return;
}
this.filterIds = ids;
dom.content(el.filterMenu, defs.map(function(d) {
var sel = ui.chip === d[0];
return E('button', { 'type': 'button', 'role': 'menuitemradio', 'aria-checked': String(sel), 'class': 'vt-fmenu-item' + (d[3] && d[2] ? ' vt-fmenu-warn' : ''),
'click': function() { ui.chip = d[0]; self.filterMenu(false); self.paintClients(); el.filterBtn.focus(); } }, [
E('span', { 'class': 'vt-fmenu-check', 'aria-hidden': 'true' }, [ sel ? icon('check') : '' ]),
E('span', { 'class': 'vt-fmenu-label' }, [ d[1] ]),
E('span', { 'class': 'vt-fmenu-n vt-tn' }, [ String(d[2]) ])
]);
}));
},
paintClients: function() { paintClients: function() {
var self = this, m = this.m, st = this.st, el = this.el, ui = this.ui; var self = this, m = this.m, st = this.st, el = this.el, ui = this.ui;
/* quick filter chips: all / bands present / weak / new / active */ /* Wi-Fi or wired: the switch shows where there are both; a device
var chipDefs = [ [ 'all', _('All'), m.clients.length ] ]; without radios lists its wired hosts only */
var bands = []; var hosts = m.wiredHosts || [], both = !!(hosts.length && !m.wired);
m.clients.forEach(function(c) { if (c.band && bands.indexOf(c.band) < 0) bands.push(c.band); }); if (!both) ui.kind = m.wired ? 'wired' : 'wifi';
bands.sort().forEach(function(b) { chipDefs.push([ 'band:' + b, wifi.bandLabel(b), m.clients.filter(function(c) { return c.band === b; }).length ]); }); var wired = ui.kind === 'wired', all = wired ? hosts : m.clients;
chipDefs.push([ 'active', _('Active'), m.clients.filter(function(c) { var r = st.staRates[c.mac]; return r && r.rx + r.tx > 8000; }).length ]); el.kinds.hidden = !both;
chipDefs.push([ 'weak', _('Weak signal'), m.clients.filter(function(c) { return c.signal != null && c.signal < insight.WEAK_DBM; }).length ]); var kindSig = both ? [ ui.kind, m.clients.length, hosts.length ].join(' ') : '';
chipDefs.push([ 'new', _('New', 'vantage'), m.clients.filter(function(c) { return c.isNew; }).length ]); if (this.kindSig !== kindSig) {
if (!chipDefs.some(function(d) { return d[0] === ui.chip; })) ui.chip = 'all'; this.kindSig = kindSig;
/* chips and headings change with the counts or the sort, not with every poll */ dom.content(el.kinds, !both ? [] : [ [ 'wifi', _('Wi-Fi'), m.clients.length ], [ 'wired', _('Wired'), hosts.length ] ].map(function(k) {
var chipSig = JSON.stringify([ ui.chip, chipDefs ]); return E('button', { 'type': 'button', 'class': 'vt-kind' + (ui.kind === k[0] ? ' vt-kind-on' : ''), 'aria-pressed': String(ui.kind === k[0]),
if (this.chipSig !== chipSig) dom.content(el.chips, chipDefs.map(function(d) { 'click': function() { if (ui.kind !== k[0]) { ui.kind = k[0]; ui.chip = 'all'; self.paintClients(); } } }, [
return E('button', { 'type': 'button', 'class': 'vt-fchip' + (ui.chip === d[0] ? ' vt-fchip-on' : '') + (d[0] === 'weak' && d[2] ? ' vt-fchip-warn' : ''), E('span', {}, [ k[1] ]), E('span', { 'class': 'vt-kind-n vt-tn' }, [ String(k[2]) ])
'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]) ]) }));
]); }
})); var hint = wired ? _('Filter by name, address, port…') : _('Filter by name, address, SSID…');
this.chipSig = chipSig; if (el.filter.getAttribute('placeholder') !== hint) el.filter.setAttribute('placeholder', hint);
var defs = this.filterDefs();
if (!defs.some(function(d) { return d[0] === ui.chip; })) ui.chip = 'all';
this.paintFilters(defs);
el.filterClear.hidden = !ui.filter; el.filterClear.hidden = !ui.filter;
/* header with sort buttons */ /* header with sort buttons; wired hosts have a link and when the
var cols = [ [ 'name', _('Device') ], [ 'signal', _('Signal') ], [ 'ssid', _('Network') ], [ 'rate', _('Link rate') ], [ 'live', _('Live') ], [ 'exp', _('Experience') ], [ 'time', _('Online', 'vantage') ] ]; bridge last heard from them instead of radio figures */
var headSig = ui.sort + ' ' + ui.dir; var cols = wired ? [ [ 'name', _('Device') ], [ 'link', _('Link', 'vantage') ], [ 'net', _('Network') ], [ 'live', _('Live') ],
[ 'seen', _('Last heard'), _('The bridge forgets a device after about five minutes without traffic') ] ]
: [ [ 'name', _('Device') ], [ 'signal', _('Signal') ], [ 'ssid', _('Network') ], [ 'rate', _('Link rate') ], [ 'live', _('Live') ], [ 'exp', _('Experience') ], [ 'time', _('Online', 'vantage') ] ];
var so = ui.sorts[ui.kind];
var headSig = ui.kind + ' ' + so.key + ' ' + so.dir;
if (this.headSig !== headSig) dom.content(el.thead, [ E('tr', {}, cols.map(function(c) { if (this.headSig !== headSig) dom.content(el.thead, [ E('tr', {}, cols.map(function(c) {
var on = ui.sort === c[0]; var on = so.key === c[0];
return E('th', { 'class': 'vt-th-' + c[0], 'scope': 'col', 'aria-sort': on ? (ui.dir > 0 ? 'ascending' : 'descending') : 'none' }, [ var btn = 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() { if (so.key === c[0]) so.dir = -so.dir; else { so.key = c[0]; so.dir = (c[0] === 'name' || c[0] === 'ssid' || c[0] === 'net' || c[0] === 'seen') ? 1 : -1; }
if (ui.sort === c[0]) ui.dir = -ui.dir; else { ui.sort = c[0]; ui.dir = (c[0] === 'name' || c[0] === 'ssid') ? 1 : -1; } self.paintClients();
self.paintClients(); } }, [ E('span', {}, [ c[1] ]), E('span', { 'class': 'vt-sort-ind', 'aria-hidden': 'true' }, [ on ? (so.dir > 0 ? '↑' : '↓') : '' ]) ]);
} }, [ E('span', {}, [ c[1] ]), E('span', { 'class': 'vt-sort-ind', 'aria-hidden': 'true' }, [ on ? (ui.dir > 0 ? '↑' : '↓') : '' ]) ]) if (c[2]) btn.setAttribute('title', c[2]);
]); return E('th', { 'class': 'vt-th-' + c[0], 'scope': 'col', 'aria-sort': on ? (so.dir > 0 ? 'ascending' : 'descending') : 'none' }, [ btn ]);
})) ]); })) ]);
this.headSig = headSig; this.headSig = headSig;
var list = m.clients.filter(L.bind(this.matches, this)); var list = all.filter(L.bind(this.matches, this));
list.sort(function(a, b) { list.sort(function(a, b) {
var va = self.sortValue(a, ui.sort), vb = self.sortValue(b, ui.sort); var va = self.sortValue(a, so.key), vb = self.sortValue(b, so.key);
if (va < vb) return -ui.dir; if (va < vb) return -so.dir;
if (va > vb) return ui.dir; if (va > vb) return so.dir;
return a.mac < b.mac ? -1 : 1; return a.mac < b.mac ? -1 : 1;
}); });
var gone = (ui.chip === 'all' || ui.chip === 'new') ? st.gone.map(function(g) { return Object.assign({}, g.client, { gone: true, goneAt: g.at }); }).filter(function(c) { var gone = !wired && (ui.chip === 'all' || ui.chip === 'new') ? st.gone.map(function(g) { return Object.assign({}, g.client, { gone: true, goneAt: g.at }); }).filter(function(c) {
return !ui.filter || self.matches(Object.assign({}, c, { isNew: false })); return !ui.filter || self.matches(Object.assign({}, c, { isNew: false }));
}) : []; }) : [];
if (ui.chip === 'new') gone = []; if (ui.chip === 'new') gone = [];
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; }); all.forEach(function(c) { var r = wired ? self.portRate(c) : st.staRates[c.mac]; if (r && r.rx + r.tx > maxRate) maxRate = r.rx + r.tx; });
var keep = new Set(), at = 0; var keep = new Set(), at = 0;
this.rowSig = this.rowSig || new Map(); 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 = (wired ? 'wired:' : '') + (c.gone ? 'gone:' : '') + c.mac;
keep.add(key); keep.add(key);
var tr = self.rowMap.get(key); var tr = self.rowMap.get(key);
if (!tr) { if (!tr) {
@@ -2258,7 +2495,7 @@ return view.extend({
tr.setAttribute('aria-label', _('Open details of %s').format(c.name)); tr.setAttribute('aria-label', _('Open details of %s').format(c.name));
/* a row that would come out the same keeps its cells, and rows /* a row that would come out the same keeps its cells, and rows
already in order stay where they are */ already in order stay where they are */
var cells = self.clientCells(c, maxRate), sig = treeSig(cells); var cells = wired ? self.wiredCells(c, maxRate) : self.clientCells(c, maxRate), sig = treeSig(cells);
if (self.rowSig.get(key) !== sig) { dom.content(tr, cells); self.rowSig.set(key, sig); } 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); if (el.tbody.children[at] !== tr) el.tbody.insertBefore(tr, el.tbody.children[at] || null);
at++; at++;
@@ -2271,21 +2508,79 @@ return view.extend({
}); });
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);
var total = m.clients.length; var total = all.length;
txt(el.count, list.length === total ? (total === 1 ? _('1 connected') : _('%d connected').format(total)) : _('%d of %d').format(list.length, total)); txt(el.count, list.length !== total ? _('%d of %d').format(list.length, total)
: wired ? (total === 1 ? _('1 device') : _('%d devices').format(total))
: (total === 1 ? _('1 connected') : _('%d connected').format(total)));
el.count.hidden = both && list.length === total;
el.clientsEmpty.hidden = list.length + gone.length > 0; el.clientsEmpty.hidden = list.length + gone.length > 0;
if (!el.clientsEmpty.hidden) { if (!el.clientsEmpty.hidden) {
dom.content(el.clientsEmpty, !total ? [ dom.content(el.clientsEmpty, total ? [
icon('wifi'), E('p', { 'class': 'vt-empty-title' }, [ _('No clients connected') ]),
E('p', {}, [ _('Stations appear here as soon as they associate with one of the SSIDs.') ])
] : [
icon('search'), E('p', { 'class': 'vt-empty-title' }, [ ui.filter ? _('No clients match “%s”').format(fmt.safeText(ui.filter, 40)) : _('No clients in this view') ]), icon('search'), E('p', { 'class': 'vt-empty-title' }, [ ui.filter ? _('No clients match “%s”').format(fmt.safeText(ui.filter, 40)) : _('No clients in this view') ]),
E('button', { 'type': 'button', 'class': 'vt-btn', 'click': function() { ui.chip = 'all'; self.setFilter(''); } }, [ _('Show all clients') ]) E('button', { 'type': 'button', 'class': 'vt-btn', 'click': function() { ui.chip = 'all'; self.setFilter(''); } }, [ _('Show all clients') ])
] : wired && this.lastRaw && this.lastRaw.fdbCrowded ? [
icon('router'), E('p', { 'class': 'vt-empty-title' }, [ _('Too many devices to list') ]),
E('p', {}, [ _('The bridges have learned so many addresses that listing them here would strain this device.') ])
] : wired ? [
icon('router'), E('p', { 'class': 'vt-empty-title' }, [ _('No wired devices heard') ]),
E('p', {}, [ _('Devices on the Ethernet ports appear here once they send traffic; the bridge forgets quiet ones after about five minutes.') ])
] : [
icon('wifi'), E('p', { 'class': 'vt-empty-title' }, [ _('No clients connected') ]),
E('p', {}, [ _('Stations appear here as soon as they associate with one of the SSIDs.') ])
]); ]);
} }
}, },
/* the device cell of a client row: avatar, name, where the name comes
from, tags and the address */
whoCell: function(c, tags) {
return E('td', { 'class': 'vt-td-name', 'data-title': _('Device') }, [
E('div', { 'class': 'vt-who' }, [
avatar(c),
E('div', { 'class': 'vt-who-text' }, [
E('div', { 'class': 'vt-who-label', 'title': c.name }, [ c.name ]),
E('div', { 'class': 'vt-who-sub' }, [ E('span', { 'class': 'vt-src vt-src-' + c.nameSource, 'title': _('Name from: %s').format(this.sourceText(c)) }, [ c.nameSourceLabel ]) ].concat(tags, [ E('span', { 'class': 'vt-mono vt-who-id' }, [ c.secondary ]) ]))
])
])
]);
},
/* a wired host's row. Its link is its port's when it has the port to
itself; on a shared port (a switch or another access point behind
it) neither the speed nor the traffic is its own */
wiredCells: function(c, maxRate) {
var r = this.portRate(c), total = r ? r.rx + r.tx : 0;
var bar = E('span', { 'class': 'vt-mini-fill' });
setW(bar, maxRate > 0 ? total / maxRate * 100 : 0);
var tags = [];
var up = this.m && this.m.uplink;
if (c.gateway) tags.push(pill(_('Gateway'), 'accent', _('The router this device sends its traffic to')));
else if (up && up.port && c.port === up.port) tags.push(pill(_('Upstream'), 'muted', _('Reached through the uplink port %s, not connected to this device').format(c.port)));
if (c.vlan != null) tags.push(pill(_('VLAN %d').format(c.vlan), 'muted'));
var speed = c.speed ? fmt.phyRate(c.speed.mbps * 1000) : fmt.DASH;
var link = E('td', { 'class': 'vt-td-link', 'data-title': _('Link', 'vantage') }, [
E('div', { 'class': 'vt-phy vt-tn' }, [ E('b', { 'class': c.carrier === false ? 'vt-t-warn' : '' }, [ c.carrier === false ? _('down') : c.shared ? _('Shared port') : speed ]) ]),
E('div', { 'class': 'vt-phy vt-phy-up vt-tn' }, [ c.shared ? _('%s · %d devices').format(c.port, c.peers) : c.port ])
]);
link.setAttribute('title', c.shared ? _('%s is shared by %d devices (a switch or another access point behind it); its %s link is not theirs').format(c.port, c.peers, speed) : _('Port %s').format(c.port));
return [
this.whoCell(c, tags),
link,
E('td', { 'class': 'vt-td-net', 'data-title': _('Network') }, [ E('div', { 'class': 'vt-net' }, [ E('span', { 'class': 'vt-net-ssid' }, [ c.network || c.bridge || fmt.DASH ]) ]) ]),
E('td', { 'class': 'vt-td-live', 'data-title': _('Live') }, c.shared ? [
E('span', { 'class': 'vt-muted', 'title': _('Not counted per device on a shared port') }, [ fmt.DASH ])
] : [
E('div', { 'class': 'vt-live-cell' }, [
E('span', { 'class': 'vt-tn vt-live-total' }, [ r ? fmt.bits(total) : fmt.DASH ]),
E('span', { 'class': 'vt-mini' }, [ bar ]),
E('span', { 'class': 'vt-live-split vt-tn', 'title': r ? _('To device %s, from device %s').format(fmt.bits(r.tx), fmt.bits(r.rx)) : '' }, [ r ? '↓ ' + fmt.bitsShort(r.tx) + ' ↑ ' + fmt.bitsShort(r.rx) : '' ])
])
]),
E('td', { 'class': 'vt-td-seen vt-tn', 'data-title': _('Last heard') }, [ c.seen == null ? _('static entry') : fmt.ago(c.seen * 1000) ])
];
},
clientCells: function(c, maxRate) { clientCells: function(c, maxRate) {
var st = this.st, r = c.gone ? null : st.staRates[c.mac]; var st = this.st, r = c.gone ? null : st.staRates[c.mac];
var total = r ? r.rx + r.tx : 0; var total = r ? r.rx + r.tx : 0;
@@ -2298,15 +2593,7 @@ return view.extend({
var exp = c.exp || { score: 0, level: 'muted', grade: fmt.DASH, reason: '' }; var exp = c.exp || { score: 0, level: 'muted', grade: fmt.DASH, reason: '' };
return [ return [
E('td', { 'class': 'vt-td-name', 'data-title': _('Device') }, [ this.whoCell(c, tags),
E('div', { 'class': 'vt-who' }, [
avatar(c),
E('div', { 'class': 'vt-who-text' }, [
E('div', { 'class': 'vt-who-label', 'title': c.name }, [ c.name ]),
E('div', { 'class': 'vt-who-sub' }, [ E('span', { 'class': 'vt-src vt-src-' + c.nameSource, 'title': _('Name from: %s').format(this.sourceText(c)) }, [ c.nameSourceLabel ]) ].concat(tags, [ E('span', { 'class': 'vt-mono vt-who-id' }, [ c.secondary ]) ]))
])
])
]),
E('td', { 'class': 'vt-td-signal', 'data-title': _('Signal') }, c.gone ? [ E('span', { 'class': 'vt-muted' }, [ fmt.DASH ]) ] : [ E('td', { 'class': 'vt-td-signal', 'data-title': _('Signal') }, c.gone ? [ E('span', { 'class': 'vt-muted' }, [ fmt.DASH ]) ] : [
E('span', { 'class': 'vt-signal', 'title': c.level.word }, [ signalGlyph(c.level), E('span', { 'class': 'vt-tn' }, [ fmt.dbm(c.signal) ]) ]) E('span', { 'class': 'vt-signal', 'title': c.level.word }, [ signalGlyph(c.level), E('span', { 'class': 'vt-tn' }, [ fmt.dbm(c.signal) ]) ])
]), ]),
@@ -2501,6 +2788,8 @@ return view.extend({
this.m.clients.forEach(function(x) { if (x.mac === mac) c = x; }); this.m.clients.forEach(function(x) { if (x.mac === mac) c = x; });
if (!c) st.gone.forEach(function(g) { if (g.client.mac === mac) gone = g; }); if (!c) st.gone.forEach(function(g) { if (g.client.mac === mac) gone = g; });
if (!c && gone) c = Object.assign({}, gone.client, { gone: true, goneAt: gone.at }); if (!c && gone) c = Object.assign({}, gone.client, { gone: true, goneAt: gone.at });
if (!c) (this.m.wiredHosts || []).forEach(function(x) { if (x.mac === mac) c = x; });
if (c && typeof c.port === 'string') return this.drawerWired(c);
if (!c) return { if (!c) return {
title: [ E('span', { 'class': 'vt-drawer-name' }, [ names.normMac(mac) ? fmt.mac(mac) : _('Unknown device', 'vantage') ]) ], title: [ E('span', { 'class': 'vt-drawer-name' }, [ names.normMac(mac) ? fmt.mac(mac) : _('Unknown device', 'vantage') ]) ],
body: [ E('div', { 'class': 'vt-empty' }, [ icon('wifi'), E('p', { 'class': 'vt-empty-title' }, [ _('Not connected') ]), E('p', {}, [ _('This station is not associated right now.') ]) ]) ] body: [ E('div', { 'class': 'vt-empty' }, [ icon('wifi'), E('p', { 'class': 'vt-empty-title' }, [ _('Not connected') ]), E('p', {}, [ _('This station is not associated right now.') ]) ]) ]
@@ -2587,6 +2876,41 @@ return view.extend({
return { title: title, body: body }; return { title: title, body: body };
}, },
drawerWired: function(c) {
var self = this, r = this.portRate(c);
var title = [
avatar(c),
E('div', { 'class': 'vt-drawer-titles' }, [
E('div', { 'class': 'vt-drawer-name', 'id': 'vt-drawer-name' }, [ c.name ]),
E('div', { 'class': 'vt-drawer-sub' }, [
E('span', { 'class': 'vt-src vt-src-' + c.nameSource }, [ c.nameSourceLabel ]),
E('span', { 'class': 'vt-mono' }, [ c.secondary ])
])
])
];
var rename = this.canWrite ? E('button', { 'type': 'button', 'class': 'vt-btn vt-btn-ghost', 'click': function() { self.startRename(c); } }, [ icon('pencil'), E('span', {}, [ c.alias ? _('Rename', 'vantage') : _('Name this device') ]) ]) : '';
var speed = c.speed ? model.speedLabel(c.speed) : fmt.DASH;
var body = [
E('div', { 'class': 'vt-dactions' }, [ rename, pageLink(insight.PAGES.network, _('Interfaces')) ]),
this.el.renameSlot = E('div', { 'class': 'vt-rename-slot' }),
this.section(_('Connection'), [ E('dl', { 'class': 'vt-kvs' }, [
kv(_('Port'), c.port, true),
kv(_('Link', 'vantage'), c.carrier === false ? _('down') : c.shared ? [ _('Shared by %d devices').format(c.peers), E('small', {}, [ _('the port: %s').format(speed) ]) ] : speed),
kv(_('Traffic'), c.shared ? E('span', { 'class': 'vt-muted' }, [ _('Not counted per device on a shared port') ])
: r ? '↓ ' + fmt.bits(r.tx) + ' · ↑ ' + fmt.bits(r.rx) : _('measuring')),
kv(_('Network'), [ c.network || c.bridge || fmt.DASH, c.vlan != null ? E('small', {}, [ _('VLAN %d').format(c.vlan) ]) : '' ]),
kv(_('Last heard'), c.seen == null ? _('static entry') : fmt.ago(c.seen * 1000))
]) ]),
this.section(_('Identity'), [ E('dl', { 'class': 'vt-kvs' }, [
kv(_('MAC address'), c.macLabel, true),
kv(_('Address type'), c.isPrivate ? _('Private address (randomised by the device)') : (c.vendor ? _('Globally unique · %s').format(c.vendor) : _('Globally unique'))),
kv(_('IP addresses'), c.ips && c.ips.length ? c.ips.join('\n') : E('span', { 'class': 'vt-muted' }, [ _('not known to this device') ]), true),
kv(_('Name from'), this.sourceText(c))
]) ])
];
return { title: title, body: body };
},
sourceText: function(c) { sourceText: function(c) {
switch (c.nameSource) { switch (c.nameSource) {
case 'alias': return _('Your name for this device'); case 'alias': return _('Your name for this device');
@@ -2812,7 +3136,7 @@ return view.extend({
return; return;
} }
/* 'f' focuses the client filter; '/' and Ctrl+K belong to the theme */ /* 'f' focuses the client filter; '/' and Ctrl+K belong to the theme */
if ((ev.key === 'f' || ev.key === 'F') && !typing && !ev.ctrlKey && !ev.metaKey && !ev.altKey && !this.ui.drawer && !this.m.wired) { if ((ev.key === 'f' || ev.key === 'F') && !typing && !ev.ctrlKey && !ev.metaKey && !ev.altKey && !this.ui.drawer) {
ev.preventDefault(); ev.preventDefault();
this.el.filter.focus(); this.el.filter.focus();
this.el.filter.select(); this.el.filter.select();
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,6 @@
{ {
"luci-app-vantage": { "luci-app-vantage": {
"description": "Vantage dashboard: read-only status of the device, uplink, radios and stations (wireless configuration without keys, through luci.vantage wireless) and its temperature sensors (luci.vantage thermal)", "description": "Vantage dashboard: read-only status of the device, uplink, radios and stations (wireless configuration without keys, through luci.vantage wireless), its temperature sensors (luci.vantage thermal) and the wired hosts its bridges have learned (luci.vantage wired: MAC, port and when last heard)",
"read": { "read": {
"file": { "file": {
"/proc/stat": [ "read" ] "/proc/stat": [ "read" ]
@@ -11,7 +11,7 @@
"network.interface": [ "dump" ], "network.interface": [ "dump" ],
"network.device": [ "status" ], "network.device": [ "status" ],
"luci-rpc": [ "getHostHints" ], "luci-rpc": [ "getHostHints" ],
"luci.vantage": [ "thermal", "wireless" ], "luci.vantage": [ "thermal", "wired", "wireless" ],
"iwinfo": [ "assoclist", "info" ], "iwinfo": [ "assoclist", "info" ],
"hostapd.*": [ "get_clients", "get_status" ], "hostapd.*": [ "get_clients", "get_status" ],
"umdns": [ "hosts" ], "umdns": [ "hosts" ],
@@ -5,10 +5,21 @@
// //
// wireless network.wireless status reduced to the fields the dashboard // wireless network.wireless status reduced to the fields the dashboard
// reads: no key, sae_password, RADIUS or 802.11r secrets, no // reads: no key, sae_password, RADIUS or 802.11r secrets, no
// EAP credentials. Read group luci-app-vantage. // EAP credentials. Where netifd has no network.wireless object
// (vendor drivers that bypass it, such as MediaTek's mtkwifi),
// the same fields from /etc/config/wireless instead. Read group
// luci-app-vantage.
// thermal the temperature of each thermal zone in /sys/class/thermal // thermal the temperature of each thermal zone in /sys/class/thermal
// (millidegrees Celsius) with its type; nothing else is read. // (millidegrees Celsius) with its type; nothing else is read.
// Read group luci-app-vantage. // Read group luci-app-vantage.
// wired the hosts the Linux bridges have learned on their ports:
// MAC, port, bridge, VLAN and seconds since last heard, from a
// read-only netlink dump of the forwarding database; at most
// 128 per port and 512 in all. Not dumped (crowded: true)
// when the bridges hold FDB_LIMIT entries or more between
// them, counted first through sysfs without reading further: a flood of
// made-up addresses must not make rpcd build the whole
// table. Read group luci-app-vantage.
// set_alias { mac, name, icon }: name one station, or remove its name // set_alias { mac, name, icon }: name one station, or remove its name
// with name ''. Validated here, whatever the browser did, and // with name ''. Validated here, whatever the browser did, and
// written to /etc/config/vantage only. Write group // written to /etc/config/vantage only. Write group
@@ -23,14 +34,21 @@
import { cursor } from 'uci'; import { cursor } from 'uci';
import { connect } from 'ubus'; import { connect } from 'ubus';
import { lsdir, readfile } from 'fs'; import { lsdir, readfile } from 'fs';
import * as rtnl from 'rtnl';
const CONFIG = 'vantage'; const CONFIG = 'vantage';
const WIRELESS = 'wireless'; // read only, by wireless_from_config()
const NAME_MAX = 48; // code points, after collapsing spaces const NAME_MAX = 48; // code points, after collapsing spaces
const NAME_MAX_UNITS = NAME_MAX * 8; // raw input cap, in UTF-16 code units (as names.js) const NAME_MAX_UNITS = NAME_MAX * 8; // raw input cap, in UTF-16 code units (as names.js)
const MAX_ALIASES = 512; // client sections; bounds /etc/config/vantage const MAX_ALIASES = 512; // client sections; bounds /etc/config/vantage
const ICONS = [ 'phone', 'laptop', 'tablet', 'desktop', 'tv', 'speaker', 'printer', 'camera', 'console', 'watch', 'iot', 'router', 'device' ]; const ICONS = [ 'phone', 'laptop', 'tablet', 'desktop', 'tv', 'speaker', 'printer', 'camera', 'console', 'watch', 'iot', 'router', 'device' ];
const THERMAL = '/sys/class/thermal'; const THERMAL = '/sys/class/thermal';
const MAX_ZONES = 16; const MAX_ZONES = 16;
const MAX_HOSTS = 512; // wired hosts per reply
const MAX_PER_PORT = 128; // of them on one port (the uplink's would take them all)
const NET = '/sys/class/net';
const FDB_LIMIT = 4096; // forwarding entries, all bridges together, before wired() lists none
const FDB_ENTRY = 16; // sizeof(struct __fdb_entry), brforward's record
/* ------------------------------------------------------------ helpers */ /* ------------------------------------------------------------ helpers */
@@ -170,6 +188,12 @@ function short_string(v, max) {
return (type(v) == 'string' && length(v) <= max) ? v : null; return (type(v) == 'string' && length(v) <= max) ? v : null;
} }
// a boolean option that is on, read as the init scripts read it
// (config_get_bool; netifd takes '1' and 'true')
function uci_true(v) {
return v in [ '1', 'on', 'true', 'yes', 'enabled' ];
}
/* ----------------------------------------------------------- wireless */ /* ----------------------------------------------------------- wireless */
// the encryption mode ('sae-mixed+ccmp', 'psk2', 'none', ...): its leading // the encryption mode ('sae-mixed+ccmp', 'psk2', 'none', ...): its leading
@@ -229,6 +253,46 @@ function reduce_radio(r) {
}; };
} }
// The same reply from /etc/config/wireless, for firmware whose driver
// bypasses netifd. Only enabled interfaces that name their ifname, since
// the view can look nothing else up; the first section to claim a name
// wins. Run state stays unknown here: iwinfo is served by rpcd itself, so
// asking it from a plugin would wait on rpcd. No txpower either: vendor
// drivers do not configure it in dBm (mtkwifi: per cent).
function wireless_from_config() {
let wl = cursor();
let radios = {}, taken = {}, out = {};
if (!wl.load(WIRELESS))
return out;
wl.foreach(WIRELESS, 'wifi-device', (s) => {
if (match(s['.name'], /^[A-Za-z0-9_.-]{1,64}$/))
radios[s['.name']] = { disabled: uci_true(s.disabled), config: { ...s, txpower: null }, interfaces: [] };
});
wl.foreach(WIRELESS, 'wifi-iface', (s) => {
let r = (type(s.device) == 'string') ? radios[s.device] : null;
let ifname = s.ifname;
if (type(r) != 'object' || uci_true(s.disabled) || type(ifname) != 'string' ||
!match(ifname, /^[A-Za-z0-9_.-]{1,64}$/) || taken[ifname])
return;
taken[ifname] = true;
push(r.interfaces, {
ifname,
section: s['.name'],
config: { ...s, network: (type(s.network) == 'string') ? filter(split(s.network, /\s+/), (n) => n != '') : s.network }
});
});
for (let name, r in radios)
out[name] = { ...reduce_radio(r), source: 'config' };
return out;
}
/* ---------------------------------------------------------- set_alias */ /* ---------------------------------------------------------- set_alias */
function set_alias(args) { function set_alias(args) {
@@ -338,6 +402,72 @@ function thermal() {
return out; return out;
} }
/* -------------------------------------------------------------- wired */
function netdev(v) {
return type(v) == 'string' && match(v, /^[A-Za-z0-9_.-]{1,15}$/) ? v : null;
}
// true when the bridges hold FDB_LIMIT forwarding entries or more
// between them (one dump returns them all): each sysfs brforward lists
// its bridge's as FDB_ENTRY-byte records, and the kernel fills only as
// many as asked for, so no more than the budget left is read
function crowded() {
let left = FDB_LIMIT * FDB_ENTRY;
for (let n in (lsdir(NET) ?? [])) {
let b = netdev(n) ? readfile(NET + '/' + n + '/brforward', left) : null;
if (type(b) == 'string')
left -= length(b);
if (left <= 0)
return true;
}
return false;
}
// [ { mac, port, bridge, vlan, seen } ] in dump order, at most MAX_HOSTS
// and MAX_PER_PORT a port: what the bridges learned from their ports.
// Left out: the bridges' own addresses (permanent), the switch drivers'
// copies (self, no master) and group addresses. seen: seconds since the
// bridge last heard from the host (the kernel counts in 1/100 s), null
// for a static entry. null when a bridge is crowded.
function wired() {
if (crowded())
return null;
let fdb = rtnl.request(rtnl.const.RTM_GETNEIGH, rtnl.const.NLM_F_DUMP, { family: rtnl.const.AF_BRIDGE });
let out = [], per = {};
for (let n in ((type(fdb) == 'array') ? fdb : [])) {
if (length(out) >= MAX_HOSTS)
break;
let mac = (type(n) == 'object') ? norm_mac(n.lladdr) : null;
if (!mac || is_multicast(mac) || type(n.state) != 'int' || (n.state & rtnl.const.NUD_PERMANENT) ||
(type(n.flags) == 'int' && (n.flags & rtnl.const.NTF_SELF)) || !netdev(n.master) || !netdev(n.dev) ||
(per[n.dev] ?? 0) >= MAX_PER_PORT)
continue;
per[n.dev] = (per[n.dev] ?? 0) + 1;
let upd = n.cacheinfo?.updated;
push(out, {
mac,
port: n.dev,
bridge: n.master,
vlan: (type(n.vlan) == 'int' && n.vlan > 0 && n.vlan < 4095) ? n.vlan : null,
seen: (!(n.state & rtnl.const.NUD_NOARP) && type(upd) == 'int' && upd >= 0) ? int(upd / 100) : null
});
}
return out;
}
/* ------------------------------------------------------------ methods */ /* ------------------------------------------------------------ methods */
const methods = { const methods = {
@@ -346,8 +476,10 @@ const methods = {
let reply = connect()?.call('network.wireless', 'status'); let reply = connect()?.call('network.wireless', 'status');
let out = {}; let out = {};
if (type(reply) != 'object') // no object, or one that skipped every radio (netifd has no
return out; // handler for the vendor's driver type): the config instead
if (type(reply) != 'object' || !length(reply))
return wireless_from_config();
for (let name, r in reply) for (let name, r in reply)
if (type(r) == 'object' && match(name, /^[A-Za-z0-9_.-]{1,64}$/)) if (type(r) == 'object' && match(name, /^[A-Za-z0-9_.-]{1,64}$/))
@@ -363,6 +495,14 @@ const methods = {
} }
}, },
wired: {
call: function() {
let hosts = wired();
return (hosts == null) ? { hosts: [], crowded: true } : { hosts };
}
},
set_alias: { set_alias: {
args: { mac: 'mac', name: 'name', icon: 'icon' }, args: { mac: 'mac', name: 'name', icon: 'icon' },
call: function(request) { call: function(request) {
@@ -51,8 +51,22 @@
@apply mb-3 flex flex-wrap justify-around gap-4 md:flex-nowrap; @apply mb-3 flex flex-wrap justify-around gap-4 md:flex-nowrap;
} }
/* luci-mod-status (status/include/29_ports.js) lays the port map out as an
inline grid with `repeat(auto-fit, minmax(70px, 1fr))` tracks and no gap,
and every card carries an inline `margin:.25em;min-width:70px;max-width:100px`.
On a narrow screen auto-fit packs 4+ tracks of ~71px, which no longer fit
the 70px card once its horizontal margin is subtracted: the margin
collapses to a few pixels and the cards read as glued together. Give the
tracks a comfortable minimum there -- auto-fit then lands on two columns --
let the cards fill them, and move the spacing into the grid gap. */
div[style*="display:grid"]:has(> .ifacebox) { div[style*="display:grid"]:has(> .ifacebox) {
@apply place-items-center; @apply place-items-center max-md:place-items-stretch max-md:gap-3;
@apply max-md:[grid-template-columns:repeat(auto-fit,minmax(140px,1fr))]!;
/* Track and card width are inline styles written by LuCI. */
> .ifacebox {
@apply max-md:m-0! max-md:max-w-none!;
}
} }
} }
@@ -50,8 +50,9 @@ test("page content appears without decorative entry fades", () => {
}); });
// uhttpd serves identity bytes, so raw size is what every cold load pays. // uhttpd serves identity bytes, so raw size is what every cold load pays.
// Set 2026-09 from main 129213, login 10521, menu 15859, router 15771, // Set 2026-11 from main 130197 (+346 for the narrow-screen port grid in
// sidebar 6107, Inter latin 48256, logo 258. // _card.css), login 10521, menu 15859, router 15771, sidebar 6107,
// Inter latin 48256, logo 258.
test("production assets stay within raw-transfer budgets", () => { test("production assets stay within raw-transfer budgets", () => {
const main = bytes("shadcn/main.css"); const main = bytes("shadcn/main.css");
const login = bytes("shadcn/login.css"); const login = bytes("shadcn/login.css");
@@ -61,7 +62,7 @@ test("production assets stay within raw-transfer budgets", () => {
const font = bytes("shadcn/inter-latin-wght-normal.woff2"); const font = bytes("shadcn/inter-latin-wght-normal.woff2");
const logo = bytes("shadcn/images/logo.svg"); const logo = bytes("shadcn/images/logo.svg");
assert.ok(main <= 130_000, `main.css ${main} B exceeds 130 KB`); assert.ok(main <= 130_500, `main.css ${main} B exceeds 130.5 KB`);
assert.ok(login <= 11_000, `login.css ${login} B exceeds 11 KB`); assert.ok(login <= 11_000, `login.css ${login} B exceeds 11 KB`);
// 17.5K: palette tabs (third-level nodes, redirect-parent folding, legacy // 17.5K: palette tabs (third-level nodes, redirect-parent folding, legacy
// recents mapping, parent-aware scoring, per-segment path words) added // recents mapping, parent-aware scoring, per-segment path words) added
+1 -1
View File
@@ -9,7 +9,7 @@ LUCI_TITLE:=A modern sidebar LuCI theme for OpenWrt, built with shadcn/ui design
LUCI_DEPENDS:=+luci-base LUCI_DEPENDS:=+luci-base
PKG_VERSION:=0.6.1 PKG_VERSION:=0.6.1
PKG_RELEASE:=8 PKG_RELEASE:=9
PKG_LICENSE:=Apache-2.0 PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:= LUCI_MINIFY_CSS:=
@@ -1,2 +1,2 @@
/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */ /*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-content:"";--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-space-y-reverse:0;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-border-spacing-x:0;--tw-border-spacing-y:0}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:.25rem;--breakpoint-md:48rem;--container-xs:20rem;--container-2xl:42rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-sm:calc(var(--radius-base) * .5);--radius-md:calc(var(--radius-base) * .75);--radius-lg:var(--radius-base);--radius-xl:calc(var(--radius-base) * 1.5);--shadow-sm:var(--shadow-sm);--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--blur-sm:8px;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--radius:calc(var(--radius-base) * .5);--color-sidebar-fg:var(--sidebar-foreground);--color-terminal-fg:var(--terminal-foreground)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-Line truncated @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-content:"";--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-space-y-reverse:0;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-border-spacing-x:0;--tw-border-spacing-y:0}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:.25rem;--breakpoint-md:48rem;--container-xs:20rem;--container-2xl:42rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-sm:calc(var(--radius-base) * .5);--radius-md:calc(var(--radius-base) * .75);--radius-lg:var(--radius-base);--radius-xl:calc(var(--radius-base) * 1.5);--shadow-sm:var(--shadow-sm);--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--blur-sm:8px;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--radius:calc(var(--radius-base) * .5);--color-sidebar-fg:var(--sidebar-foreground);--color-terminal-fg:var(--terminal-foreground)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-Line truncated
+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.11 PKG_VERSION:=1.0.12
PKG_RELEASE:=26 PKG_RELEASE:=27
PKG_LICENSE:=GPL-3.0-or-later PKG_LICENSE:=GPL-3.0-or-later
LUCI_TITLE:=Vantage theme LUCI_TITLE:=Vantage theme
@@ -1743,6 +1743,9 @@ table { border-collapse: separate; border-spacing: 0; width: 100%; }
div.right, div.left { display: block; } 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; }
/* a .left and .right pair on one line (the upload dialog's Browse… and
Cancel/Upload): the right group keeps to the line */
#modal_overlay .modal div.left + div.right { margin-top: 0; }
/* Tabs: one segmented control for every level, the menu's page tabs /* Tabs: one segmented control for every level, the menu's page tabs
(.vt-pagetabs in #vt-tabs), ui.tabs at page level, in a form or modal, (.vt-pagetabs in #vt-tabs), ui.tabs at page level, in a form or modal,
@@ -2509,11 +2512,13 @@ body[data-page^="admin-status-channel_analysis"] #view [data-tab-title]:not([dat
#view .tr.v-ca-own > .td:nth-child(2) { font-weight: 600; } #view .tr.v-ca-own > .td:nth-child(2) { font-weight: 600; }
@media (max-width: 720px) { @media (max-width: 720px) {
/* on a phone: the name, the current value, then average and peak on a
line each, so wide values (332.59 Mibit/s) never run into the labels */
body[data-page^="admin-status-realtime"] #view .table:has(strong[style*="border-bottom"]) > .tr { body[data-page^="admin-status-realtime"] #view .table:has(strong[style*="border-bottom"]) > .tr {
grid-template-columns: minmax(0, 1fr) max-content minmax(0, 1fr) max-content minmax(0, 1fr); grid-template-columns: max-content minmax(0, 1fr);
row-gap: 6px; padding: 10px 0; row-gap: 6px; padding: 10px 0;
} }
body[data-page^="admin-status-realtime"] #view .table:has(strong[style*="border-bottom"]) > .tr > .td:nth-child(1) { grid-column: 1 / -1; } body[data-page^="admin-status-realtime"] #view .table:has(strong[style*="border-bottom"]) > .tr > .td:is(:nth-child(1), :nth-child(2)) { grid-column: 1 / -1; }
body[data-page^="admin-status-realtime"] #view #settings-row { flex-wrap: wrap; } body[data-page^="admin-status-realtime"] #view #settings-row { flex-wrap: wrap; }
body[data-page^="admin-status-realtime"] #view #settings-row > * { flex: 1 1 100% !important; } body[data-page^="admin-status-realtime"] #view #settings-row > * { flex: 1 1 100% !important; }
body[data-page^="admin-status-realtime"] #view #settings-row > .right > .btn { margin-left: 0; } body[data-page^="admin-status-realtime"] #view #settings-row > .right > .btn { margin-left: 0; }
+4 -1
View File
@@ -2,7 +2,9 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=vless-core-cli PKG_NAME:=vless-core-cli
PKG_VERSION:=2.0.0 PKG_VERSION:=2.0.0
PKG_RELEASE:=2 PKG_RELEASE:=3
PKG_GIT_DIR:=$(dir $(firstword $(MAKEFILE_LIST)))
PKG_GIT_COMMIT:=$(shell git -C $(PKG_GIT_DIR) rev-parse --short HEAD 2>/dev/null || echo unknown)
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/notfence/vless-core-cli/tar.gz/v$(PKG_VERSION)? PKG_SOURCE_URL:=https://codeload.github.com/notfence/vless-core-cli/tar.gz/v$(PKG_VERSION)?
@@ -37,6 +39,7 @@ endef
define Build/Compile define Build/Compile
$(TARGET_CC) $(TARGET_CFLAGS) $(TARGET_CPPFLAGS) -std=c11 -Wall \ $(TARGET_CC) $(TARGET_CFLAGS) $(TARGET_CPPFLAGS) -std=c11 -Wall \
-I$(PKG_BUILD_DIR)/include $(PKG_BUILD_DIR)/src/*.c \ -I$(PKG_BUILD_DIR)/include $(PKG_BUILD_DIR)/src/*.c \
-DVLESS_CORE_COMMIT='"git~$(PKG_GIT_COMMIT)"'\
-o $(PKG_BUILD_DIR)/vless-core \ -o $(PKG_BUILD_DIR)/vless-core \
$(TARGET_LDFLAGS) -lssl -lcrypto -lpthread $(TARGET_LDFLAGS) -lssl -lcrypto -lpthread
endef endef