Compare commits

...
8 Commits
Author SHA1 Message Date
github-actions[bot] 6ab5dde746 🌴 Sync 2026-08-31 11:11:11
Merge-upstream / merge (push) Canceled after 0s
2026-08-31 11:11:11 +08:00
github-actions[bot] a0412f91a9 🤞 Sync 2026-08-31 03:15:28 2026-08-31 03:15:28 +08:00
github-actions[bot] 221a4bc6ae 🎉 Sync 2026-08-31 01:44:18 2026-08-31 01:44:18 +08:00
github-actions[bot] cf0c9e3564 🏅 Sync 2026-08-30 23:57:09 2026-08-30 23:57:09 +08:00
github-actions[bot] b975999290 🤞 Sync 2026-08-30 21:23:32 2026-08-30 21:23:32 +08:00
kiddin9 bb4b6094e3 Update upstream.yml 2026-08-30 21:21:17 +08:00
github-actions[bot] dd41e8d648 🍉 Sync 2026-08-30 11:16:17 2026-08-30 11:16:17 +08:00
github-actions[bot] 5093acfe28 🎄 Sync 2026-08-30 03:21:07 2026-08-30 03:21:07 +08:00
93 changed files with 4883 additions and 287 deletions
+2 -2
View File
@@ -352,8 +352,8 @@ jobs:
git_sparse_clone revision https://github.com/Self-Hosting-Group/packages net/miniupnpd git_sparse_clone revision https://github.com/Self-Hosting-Group/packages net/miniupnpd
) & ) &
( (
git_sparse_clone frp-toml https://github.com/laipeng668/luci applications/luci-app-frpc git_sparse_clone frp https://github.com/laipeng668/luci applications/luci-app-frpc
git_sparse_clone frp-toml https://github.com/laipeng668/luci applications/luci-app-frps git_sparse_clone frp https://github.com/laipeng668/luci applications/luci-app-frps
) & ) &
( (
git_sparse_clone master "https://github.com/coolsnowwolf/lede" package/wwan package/lean package/network/services/shellsync package/qca/shortcut-fe && cp -rf wwan/*/* ./ ; rm -Rf wwan git_sparse_clone master "https://github.com/coolsnowwolf/lede" package/wwan package/lean package/network/services/shellsync package/qca/shortcut-fe && cp -rf wwan/*/* ./ ; rm -Rf wwan
+73
View File
@@ -0,0 +1,73 @@
#
# Copyright (C) 2026 jjm2473@gmail.com
#
# This is free software, licensed under the GNU General Public License v3.
#
include $(TOPDIR)/rules.mk
AGENTFLOW_ARCH_x86_64:=amd64
AGENTFLOW_ARCH_aarch64:=arm64
AGENTFLOW_ARCH:=$(AGENTFLOW_ARCH_$(ARCH))
AGENTFLOW_HASH_x86_64:=b02c1211f3c7f288b0b25ad187ba1e74989bdde744477875a553dc9f7f7501c8
AGENTFLOW_HASH_aarch64:=938b5feca2fc942eca13b0e8c9df9ea6e7d45c0b0adba0b61f6a3da5bc2ad8d8
PKG_NAME:=agentflow
PKG_VERSION:=0.3.0
PKG_RELEASE:=3
AGENTFLOW_DOWNLOAD:=agentflow-linux-$(AGENTFLOW_ARCH)
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-$(PKG_VERSION)
PKG_BUILD_PARALLEL:=1
PKG_USE_MIPS16:=0
STRIP:=true
RSTRIP:=:
include $(INCLUDE_DIR)/package.mk
define Download/agentflow
URL:=https://fw0.koolcenter.com/binary/geili/agentflow/build/
FILE:=$(AGENTFLOW_DOWNLOAD)
HASH:=skip
endef
define Package/$(PKG_NAME)
SECTION:=net
CATEGORY:=Network
SUBMENU:=Web Servers/Proxies
TITLE:=AgentFlow
DEPENDS:=@(x86_64||aarch64) +git +mise
URL:=https://agentflow.geili.ai/
endef
define Package/$(PKG_NAME)/description
AgentFlow provides a Web UI for orchestrating coding agents and workflows.
endef
define Package/$(PKG_NAME)/conffiles
/etc/config/agentflow
endef
define Build/Prepare
$(INSTALL_DIR) $(PKG_BUILD_DIR)
$(CP) $(DL_DIR)/$(AGENTFLOW_DOWNLOAD) $(PKG_BUILD_DIR)/agentflow
endef
define Build/Configure
endef
define Build/Compile
endef
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/etc/config
$(INSTALL_BIN) $(PKG_BUILD_DIR)/agentflow $(1)/usr/sbin/agentflow
$(INSTALL_BIN) ./files/agentflow.init $(1)/etc/init.d/agentflow
$(INSTALL_BIN) ./files/agentflow.uci-default $(1)/etc/uci-defaults/09-agentflow
$(INSTALL_CONF) ./files/agentflow.config $(1)/etc/config/agentflow
endef
$(eval $(call Download,agentflow))
$(eval $(call BuildPackage,$(PKG_NAME)))
+5
View File
@@ -0,0 +1,5 @@
config agentflow
option enabled '0'
option data_dir ''
option host '0.0.0.0'
option port '9000'
+52
View File
@@ -0,0 +1,52 @@
#!/bin/sh /etc/rc.common
START=93
USE_PROCD=1
get_config() {
config_get_bool enabled "$1" enabled 0
config_get data_dir "$1" data_dir ""
config_get host "$1" host "0.0.0.0"
config_get port "$1" port "9000"
}
set_mise_env() {
procd_set_param env \
"$@" \
MISE_INSTALLS_DIR="$data_dir/globalmise/mise-installs" \
MISE_GLOBAL_CONFIG_FILE="$data_dir/globalmise/mise-config/config.toml" \
MISE_DATA_DIR="$data_dir/globalmise/mise-data" \
MISE_CACHE_DIR="$data_dir/globalmise/mise-cache" \
MISE_STATE_DIR="$data_dir/globalmise/mise-state" \
NPM_CONFIG_CACHE="$data_dir/globalmise/npm-cache" \
NPM_CONFIG_USERCONFIG="$data_dir/globalmise/.npmrc" \
PATH="$data_dir/globalmise/mise-data/shims:$PATH"
}
start_service() {
config_load agentflow
config_foreach get_config agentflow
[ "$enabled" = 1 ] || return 1
[ -x /usr/sbin/agentflow ] || {
logger -t agentflow "missing executable: /usr/sbin/agentflow"
return 1
}
mkdir -p "$data_dir" || return 1
logger -t agentflow "starting AgentFlow on $host:$port"
procd_open_instance
procd_set_param command /usr/sbin/agentflow
set_mise_env \
"AGENT_FLOW_DATA=$data_dir/data" \
"AGENT_FLOW_HOST=$host" \
"AGENT_FLOW_PORT=$port"
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param respawn
procd_close_instance
}
service_triggers() {
procd_add_reload_trigger "agentflow"
}
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
[ ! -f "/usr/share/ucitrack/luci-app-agentflow.json" ] && {
cat > /usr/share/ucitrack/luci-app-agentflow.json << EEOF
{
"config": "agentflow",
"init": "agentflow"
}
EEOF
}
uci -q batch <<-EOF >/dev/null
delete ucitrack.@agentflow[-1]
add ucitrack agentflow
set ucitrack.@agentflow[-1].init=agentflow
commit ucitrack
EOF
/etc/init.d/agentflow enable
/etc/init.d/agentflow start
exit 0
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=BaiduPCS-Go PKG_NAME:=BaiduPCS-Go
PKG_VERSION:=4.0.2 PKG_VERSION:=4.0.2
PKG_RELEASE:=8 PKG_RELEASE:=9
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/qjfoidnh/BaiduPCS-Go/tar.gz/v$(PKG_VERSION)? PKG_SOURCE_URL:=https://codeload.github.com/qjfoidnh/BaiduPCS-Go/tar.gz/v$(PKG_VERSION)?
@@ -0,0 +1,25 @@
From: coolsnowwolf <coolsnowwolf@gmail.com>
Subject: [PATCH] cachepool: stop using runtime.rawbyteslice
Recent Go releases reject external linkname references to rawbyteslice.
Use the language-provided slice allocator for the zeroed allocation path.
Signed-off-by: coolsnowwolf <coolsnowwolf@gmail.com>
---
--- a/pcsutil/cachepool/malloc.go
+++ b/pcsutil/cachepool/malloc.go
@@ -8,12 +8,9 @@
//go:linkname mallocgc runtime.mallocgc
func mallocgc(size uintptr, typ uintptr, needzero bool) unsafe.Pointer
-//go:linkname rawbyteslice runtime.rawbyteslice
-func rawbyteslice(size int) (b []byte)
-
-// RawByteSlice point to runtime.rawbyteslice
+// RawByteSlice allocates a zeroed byte slice.
func RawByteSlice(size int) (b []byte) {
- return rawbyteslice(size)
+ return make([]byte, size)
}
// RawMalloc allocates a new slice. The slice is not zeroed.
+1 -1
View File
@@ -7,7 +7,7 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=filebrowser PKG_NAME:=filebrowser
PKG_VERSION:=1.5.4-stable PKG_VERSION:=1.5.5-stable
PKG_RELEASE=1 PKG_RELEASE=1
ifeq ($(ARCH),aarch64) ifeq ($(ARCH),aarch64)
+2 -1
View File
@@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=go-ethereum PKG_NAME:=go-ethereum
PKG_VERSION:=1.17.5 PKG_VERSION:=1.17.5
PKG_RELEASE:=8 PKG_RELEASE:=9
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/ethereum/go-ethereum/tar.gz/v${PKG_VERSION}? PKG_SOURCE_URL:=https://codeload.github.com/ethereum/go-ethereum/tar.gz/v${PKG_VERSION}?
@@ -26,6 +26,7 @@ PKG_CONFIG_DEPENDS:=CONFIG_BUILD_NLS
GO_PKG:=github.com/ethereum/go-ethereum GO_PKG:=github.com/ethereum/go-ethereum
GO_PKG_BUILD_PKG:=github.com/ethereum/go-ethereum/cmd/geth GO_PKG_BUILD_PKG:=github.com/ethereum/go-ethereum/cmd/geth
GO_PKG_TAGS:=untested_go_version
include $(INCLUDE_DIR)/package.mk include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/nls.mk include $(INCLUDE_DIR)/nls.mk
+1 -1
View File
@@ -9,7 +9,7 @@ PROG=/usr/bin/geth
start_service() { start_service() {
procd_open_instance procd_open_instance
procd_set_param command ${PROG} procd_set_param command ${PROG}
procd_append_param command --syncmode "light" --cache 1024 procd_append_param command --syncmode "snap" --cache 1024
procd_set_param respawn procd_set_param respawn
procd_close_instance procd_close_instance
} }
+2 -2
View File
@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=gost PKG_NAME:=gost
PKG_VERSION:=3.2.6 PKG_VERSION:=3.3.0
PKG_RELEASE:=4 PKG_RELEASE:=5
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/go-gost/gost/tar.gz/v$(PKG_VERSION)? PKG_SOURCE_URL:=https://codeload.github.com/go-gost/gost/tar.gz/v$(PKG_VERSION)?
+4 -3
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=headscale PKG_NAME:=headscale
PKG_VERSION:=0.29.3 PKG_VERSION:=0.29.3
PKG_RELEASE:=7 PKG_RELEASE:=8
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/juanfont/headscale/tar.gz/v$(PKG_VERSION)? PKG_SOURCE_URL:=https://codeload.github.com/juanfont/headscale/tar.gz/v$(PKG_VERSION)?
@@ -24,7 +24,7 @@ GO_PKG:=github.com/juanfont/headscale
GO_PKG_BUILD_PKG:=$(GO_PKG)/cmd/headscale GO_PKG_BUILD_PKG:=$(GO_PKG)/cmd/headscale
GO_PKG_LDFLAGS:=-s -w GO_PKG_LDFLAGS:=-s -w
GO_PKG_LDFLAGS+= \ GO_PKG_LDFLAGS+= \
-X '$(GO_PKG_BUILD_PKG)/cli.Version=v$(PKG_VERSION)' -X '$(GO_PKG)/hscontrol/types.version=v$(PKG_VERSION)'
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
@@ -47,7 +47,7 @@ define Package/headscale/conffiles
/etc/headscale/db.sqlite /etc/headscale/db.sqlite
/etc/headscale/derp.yaml /etc/headscale/derp.yaml
/etc/headscale/noise_private.key /etc/headscale/noise_private.key
/etc/headscale/private.key /etc/headscale/derp_server_private.key
endef endef
define Package/headscale/install define Package/headscale/install
@@ -56,6 +56,7 @@ define Package/headscale/install
$(INSTALL_DIR) $(1)/etc/headscale $(INSTALL_DIR) $(1)/etc/headscale
touch $(1)/etc/headscale/db.sqlite touch $(1)/etc/headscale/db.sqlite
$(INSTALL_CONF) $(PKG_BUILD_DIR)/config-example.yaml $(1)/etc/headscale/config.yaml $(INSTALL_CONF) $(PKG_BUILD_DIR)/config-example.yaml $(1)/etc/headscale/config.yaml
$(SED) 's#/var/lib/headscale#/etc/headscale#g' $(1)/etc/headscale/config.yaml
$(INSTALL_CONF) $(PKG_BUILD_DIR)/derp-example.yaml $(1)/etc/headscale/derp.yaml $(INSTALL_CONF) $(PKG_BUILD_DIR)/derp-example.yaml $(1)/etc/headscale/derp.yaml
endef endef
@@ -0,0 +1,14 @@
Allow package builds from release tarballs to provide the version because Go
records the main module version as "(devel)" outside the module proxy. This
keeps Headscale's database upgrade checks enabled for packaged releases.
Signed-off-by: coolsnowwolf <coolsnowwolf@gmail.com>
---
--- a/hscontrol/types/version.go
+++ b/hscontrol/types/version.go
@@ -24,0 +25,2 @@
+var version = "dev"
+
@@ -45 +47 @@
- Version: "dev",
+ Version: version,
+28
View File
@@ -0,0 +1,28 @@
#
# Copyright (C) 2008-2014 The LuCI Team <luci@lists.subsignal.org>
#
# This is free software, licensed under the Apache License, Version 2.0 .
#
include $(TOPDIR)/rules.mk
LUCI_TITLE:=AgentFlow
PKG_VERSION:=1.0.0
PKG_RELEASE:=2
LUCI_DEPENDS:=+agentflow +luci-compat
LUCI_MINIFY_CSS:=0
LUCI_MINIFY_JS:=0
define Package/luci-app-agentflow/conffiles
/etc/config/agentflow
endef
define Package/luci-app-agentflow/postrm
#!/bin/sh
rm -f /tmp/luci-indexcache
exit 0
endef
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,29 @@
local http = require "luci.http"
module("luci.controller.agentflow", package.seeall)
function index()
if not nixio.fs.access("/etc/config/agentflow") then
return
end
local page = entry({"admin", "services", "agentflow"}, cbi("agentflow"), _("AgentFlow"), 100)
page.dependent = true
entry({"admin", "services", "agentflow_status"}, call("agentflow_status"))
end
function agentflow_status()
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
local port = tonumber(uci:get_first("agentflow", "agentflow", "port")) or 9000
if port < 1 or port > 65535 then
port = 9000
end
local status = {
running = (sys.call("pidof agentflow >/dev/null") == 0),
port = port
}
http.prepare_content("application/json")
http.write_json(status)
end
@@ -0,0 +1,48 @@
local jsonc = require "luci.jsonc"
local agentflow = {}
agentflow.blocks = function()
local f = io.popen("lsblk -s -f -b -o NAME,FSSIZE,MOUNTPOINT --json", "r")
local vals = {}
if f then
local ret = f:read("*all")
f:close()
local obj = jsonc.parse(ret)
for _, val in pairs(obj and obj["blockdevices"] or {}) do
local fsize = val["fssize"]
if fsize ~= nil and string.len(fsize) > 10 and val["mountpoint"] then
vals[#vals + 1] = val["mountpoint"]
end
end
end
return vals
end
agentflow.home = function()
local uci = require "luci.model.uci".cursor()
local home_dirs = {}
home_dirs["main_dir"] = uci:get_first("quickstart", "main", "main_dir", "/root")
home_dirs["Configs"] = uci:get_first("quickstart", "main", "conf_dir", home_dirs["main_dir"] .. "/Configs")
return home_dirs
end
agentflow.find_paths = function(blocks, home_dirs)
local default_path = home_dirs["Configs"] .. "/AgentFlow"
local paths = {}
if #blocks == 0 then
table.insert(paths, default_path)
else
for _, val in pairs(blocks) do
table.insert(paths, val .. "/Configs/AgentFlow")
end
if default_path == "/root/Configs/AgentFlow" then
default_path = paths[1]
end
end
return paths, default_path
end
return agentflow
@@ -0,0 +1,43 @@
local m, s
m = Map("agentflow", translate("AgentFlow"), translate("AgentFlow provides a Web UI for orchestrating coding agents and workflows."))
m:section(SimpleSection).template = "agentflow/status"
s = m:section(TypedSection, "agentflow", translate("Global settings"))
s.addremove = false
s.anonymous = true
s:option(Flag, "enabled", translate("Enable")).rmempty = false
local agentflow_model = require "luci.model.agentflow"
local blocks = agentflow_model.blocks()
local home = agentflow_model.home()
local data_dir = s:option(Value, "data_dir", translate("Data directory"))
data_dir.rmempty = false
data_dir.description = translate("Required. AgentFlow stores its configuration, database and workspace data under this directory.")
function data_dir.validate(self, value, section)
value = (value or ""):match("^%s*(.-)%s*$")
if value == "" or value == "/" then
return nil, translate("Data directory cannot be empty.")
end
if not value:match("^/mnt/[^/]+/") then
return nil, translate("Please select a disk as the data directory.")
end
return value
end
local paths, default_path = agentflow_model.find_paths(blocks, home)
for _, val in pairs(paths) do
data_dir:value(val, val)
end
data_dir.default = default_path
local port = s:option(Value, "port", translate("Listen port"))
port.default = "9000"
port.rmempty = false
port.datatype = "port"
port.description = translate("Port for the AgentFlow HTTP server.")
return m
@@ -0,0 +1,20 @@
<script type="text/javascript">//<![CDATA[
XHR.poll(5, '<%=url("admin/services/agentflow_status")%>', null, function(x, st) {
var el = document.getElementById('agentflow_status');
if (st && el) {
if (!st.running) {
el.innerHTML = '<br/><em style="color:red"><%:The AgentFlow service is not running.%></em>';
} else {
el.innerHTML = '<br/><em style="color:green"><%:The AgentFlow service is running.%></em>'
+ "<br/><br/><input class=\"btn cbi-button cbi-button-apply\" type=\"button\" value=\" <%:Click to open AgentFlow%> \" onclick=\"window.open('http://" + window.location.hostname + ":" + st.port + "/')\"/>";
}
}
});
//]]></script>
<fieldset class="cbi-section">
<legend><%:Status%></legend>
<p id="agentflow_status">
<em><%:Collecting data...%></em>
</p>
</fieldset>
+47
View File
@@ -0,0 +1,47 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8"
msgid "AgentFlow"
msgstr "AgentFlow"
msgid "AgentFlow provides a Web UI for orchestrating coding agents and workflows."
msgstr "AgentFlow 提供用于编排编码智能体和工作流的 Web 管理界面。"
msgid "Click to open AgentFlow"
msgstr "点击打开 AgentFlow"
msgid "Collecting data..."
msgstr "正在获取数据……"
msgid "Data directory"
msgstr "数据目录"
msgid "Data directory cannot be empty."
msgstr "数据目录不能为空"
msgid "Enable"
msgstr "启用"
msgid "Global settings"
msgstr "全局设置"
msgid "Listen port"
msgstr "监听端口"
msgid "Port for the AgentFlow HTTP server."
msgstr "AgentFlow HTTP 服务的端口号。"
msgid "Please select a disk as the data directory."
msgstr "请选择硬盘作为数据目录"
msgid "Required. AgentFlow stores its configuration, database and workspace data under this directory."
msgstr "必需。AgentFlow 在此目录中保存配置、数据库和工作区数据。"
msgid "Status"
msgstr "状态"
msgid "The AgentFlow service is not running."
msgstr "AgentFlow 服务未运行。"
msgid "The AgentFlow service is running."
msgstr "AgentFlow 服务运行中。"
+1
View File
@@ -0,0 +1 @@
zh-cn
+4 -3
View File
@@ -1,12 +1,12 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-mosdns PKG_NAME:=luci-app-mosdns
PKG_VERSION:=1.7.9 PKG_VERSION:=1.7.11
PKG_RELEASE:=17 PKG_RELEASE:=19
LUCI_TITLE:=LuCI Support for mosdns LUCI_TITLE:=LuCI Support for mosdns
LUCI_PKGARCH:=all LUCI_PKGARCH:=all
LUCI_DEPENDS:=+mosdns +curl +v2ray-geoip +v2ray-geosite +geo2txt +ucode LUCI_DEPENDS:=+mosdns +uclient-fetch +v2ray-geoip +v2ray-geosite +geo2txt +ucode
PKG_MAINTAINER:=sbwml <admin@cooluc.com> PKG_MAINTAINER:=sbwml <admin@cooluc.com>
@@ -15,6 +15,7 @@ define Package/$(PKG_NAME)/conffiles
/etc/mosdns/cache.dump /etc/mosdns/cache.dump
/etc/mosdns/config_custom.yaml /etc/mosdns/config_custom.yaml
/etc/mosdns/rule /etc/mosdns/rule
/etc/mosdns/stats.dump
endef endef
include $(TOPDIR)/feeds/luci/luci.mk include $(TOPDIR)/feeds/luci/luci.mk
@@ -163,7 +163,7 @@ return view.extend({
o.value('info', _('Info')); o.value('info', _('Info'));
o.value('warn', _('Warning')); o.value('warn', _('Warning'));
o.value('error', _('Error')); o.value('error', _('Error'));
o.default = 'info'; o.default = 'error';
o.depends('configfile', '/var/etc/mosdns.json'); o.depends('configfile', '/var/etc/mosdns.json');
o = s.taboption('basic', form.Value, 'log_file', _('Log File')); o = s.taboption('basic', form.Value, 'log_file', _('Log File'));
@@ -426,6 +426,29 @@ return view.extend({
o.default = 52001; o.default = 52001;
o.depends('configfile', '/var/etc/mosdns.json'); o.depends('configfile', '/var/etc/mosdns.json');
o = s.taboption('api', form.Flag, 'stats_collector', _('Enable Stats Collector'));
o.rmempty = false;
o.default = o.enabled;
o.depends('configfile', '/var/etc/mosdns.json');
o = s.taboption('api', form.Value, 'stats_capacity', _('Ring Buffer Capacity'),
_('Query log ring buffer capacity (FIFO overwrite, default 2000, larger values consume more memory)'));
o.datatype = 'and(uinteger,min(1))';
o.default = 2000;
o.depends('stats_collector', '1');
o = s.taboption('api', form.Flag, 'stats_dump_file', _('Stats Dump'),
_('Save query statistics and logs locally and reload on next startup.'));
o.rmempty = false;
o.default = false;
o.depends('stats_collector', '1');
o = s.taboption('api', form.Value, 'stats_dump_interval',
_('Auto Save Stats Interval'));
o.datatype = 'and(uinteger,min(1))';
o.default = 600;
o.depends('stats_dump_file', '1');
o = s.taboption('api', form.Button, '_flush_cache', null, o = s.taboption('api', form.Button, '_flush_cache', null,
_('Flushing DNS Cache will clear any IP addresses or DNS records from MosDNS cache.')); _('Flushing DNS Cache will clear any IP addresses or DNS records from MosDNS cache.'));
o.title = '&#160;'; o.title = '&#160;';
@@ -0,0 +1,722 @@
'use strict';
'require dom';
'require poll';
'require rpc';
'require ui';
'require view';
const callGetStats = rpc.declare({
object: 'luci.mosdns',
method: 'get_stats',
expect: { '': {} }
});
const callGetHistory = rpc.declare({
object: 'luci.mosdns',
method: 'get_history',
params: ['points'],
expect: { '': {} }
});
const callGetTop = rpc.declare({
object: 'luci.mosdns',
method: 'get_top',
params: ['limit'],
expect: { '': {} }
});
const callGetLogs = rpc.declare({
object: 'luci.mosdns',
method: 'get_logs',
params: ['limit', 'offset', 'search', 'filter'],
expect: { '': {} }
});
const callClearQueryLogs = rpc.declare({
object: 'luci.mosdns',
method: 'clear_query_logs',
expect: { '': {} }
});
let filterVal = 'all';
let searchVal = '';
let pageIdx = 0;
const PAGE_SIZE = 20;
let isUserPaused = false;
let nodeStats;
let nodeTop;
let nodeLogs;
let autoStatusBadge;
const cleanIP = ip => {
if (!ip) return '-';
return ip.replace(/^::ffff:/i, '');
};
const injectStyles = () => {
if (document.getElementById('mosdns-statistics-styles'))
return;
/* HTML Styles provided by DeepSeek Chat */
const css = [
'.mosdns-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 1.25rem; }',
'.mosdns-rankings-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }',
'.mosdns-stat-card { background: var(--cbi-section-bg, #fff); border: 1px solid rgba(0,0,0,0.08); border-radius: 8px; padding: 1rem 1.2rem; box-shadow: 0 2px 6px rgba(0,0,0,0.03); display: flex; flex-direction: column; justify-content: space-between; position: relative; overflow: hidden; }',
'.mosdns-stat-card .title-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; font-size: 0.85rem; opacity: 0.75; font-weight: 500; }',
'.mosdns-stat-card .metric-val { font-size: 1.85rem; font-weight: 700; line-height: 1.1; letter-spacing: -0.02em; }',
'.mosdns-stat-card .subtext { font-size: 0.8rem; opacity: 0.6; margin-top: 0.4rem; }',
'.mosdns-sparkline-wrap { margin-top: 0.5rem; height: 44px; position: relative; overflow: visible; display: flex; align-items: flex-end; }',
'.mosdns-sparkline { width: 100%; height: 100%; display: block; overflow: visible; }',
'.mosdns-sparkline-tooltip { position: absolute; pointer-events: none; z-index: 20; padding: 0.25rem 0.5rem; border-radius: 5px; background: var(--cbi-section-bg, #fff); border: 1px solid rgba(0,0,0,0.12); box-shadow: 0 3px 10px rgba(0,0,0,0.12); line-height: 1.25; text-align: center; white-space: nowrap; transition: opacity 0.15s ease; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }',
'.mosdns-rank-panel { background: var(--cbi-section-bg, #fff); border: 1px solid rgba(0,0,0,0.08); border-radius: 8px; padding: 1rem 1.1rem; box-shadow: 0 2px 6px rgba(0,0,0,0.03); }',
'.mosdns-rank-panel h4 { margin: 0 0 0.85rem 0; font-size: 0.95rem; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }',
'.mosdns-rank-item { position: relative; overflow: hidden; border-radius: 6px; padding: 0.35rem 0.65rem; display: flex; justify-content: space-between; align-items: center; background: rgba(125,125,125,0.03); border: 1px solid rgba(125,125,125,0.08); margin-bottom: 0.35rem; }',
'.mosdns-rank-bar { position: absolute; left: 0; top: 0; bottom: 0; opacity: 0.15; pointer-events: none; transition: width .3s ease; }',
'.mosdns-badge { display: inline-block; padding: 0.15em 0.55em; font-size: 0.75rem; font-weight: 600; border-radius: 4px; line-height: 1.25; text-align: center; white-space: nowrap; box-sizing: border-box; }',
'.mosdns-status-badge { min-width: 68px; }',
'.badge-danger { background: rgba(239, 68, 68, 0.12); color: #dc2626; border: 1px solid rgba(239, 68, 68, 0.25); }',
'.badge-teal { background: rgba(16, 185, 129, 0.12); color: #059669; border: 1px solid rgba(16, 185, 129, 0.25); }',
'.badge-primary { background: rgba(59, 130, 246, 0.12); color: #2563eb; border: 1px solid rgba(59, 130, 246, 0.25); }',
'.badge-neutral { background: rgba(107, 114, 128, 0.12); color: #4b5563; border: 1px solid rgba(107, 114, 128, 0.25); }',
'.badge-qtype { font-family: monospace; font-size: 0.72rem; padding: 0.1em 0.4em; background: rgba(125,125,125,0.1); border-radius: 3px; opacity: 0.8; margin-left: 0.4rem; }',
'.badge-pulse { animation: pulse 2s infinite; }',
'.dns-latency-fastest { color: #10b981; font-weight: 600; }',
'.dns-latency-fast { color: #059669; font-weight: 600; }',
'.dns-latency-normal { color: #3b82f6; font-weight: 600; }',
'.dns-latency-slow { color: #d97706; font-weight: 600; }',
'.dns-latency-slower { color: #ea580c; font-weight: 600; }',
'.dns-latency-timeout { color: #dc2626; font-weight: 600; }',
'.mosdns-table td { vertical-align: middle !important; padding: 0.45rem 0.6rem !important; }',
'.mosdns-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }',
'.mosdns-modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.85rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(125,125,125,0.15); flex-wrap: wrap; gap: 0.5rem; }',
'.mosdns-modal-domain { font-size: 1.05rem; font-weight: 700; word-break: break-all; display: flex; align-items: center; flex-wrap: wrap; gap: 0.4rem; }',
'.mosdns-modal-meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 0.6rem; margin-bottom: 1rem; }',
'.mosdns-modal-meta-item { background: rgba(125,125,125,0.04); border: 1px solid rgba(125,125,125,0.08); border-radius: 6px; padding: 0.5rem 0.75rem; }',
'.mosdns-modal-meta-item .meta-label { font-size: 0.75rem; opacity: 0.6; margin-bottom: 0.2rem; font-weight: 600; }',
'.mosdns-modal-meta-item .meta-val { font-size: 0.85rem; font-weight: 600; }',
'.mosdns-modal-section-title { font-size: 0.9rem; font-weight: 700; margin: 0.85rem 0 0.45rem 0; display: flex; align-items: center; justify-content: space-between; }',
'.mosdns-answers-list { display: flex; flex-direction: column; gap: 0.35rem; max-height: 240px; overflow-y: auto; }',
'.mosdns-answer-row { display: flex; justify-content: space-between; align-items: center; background: rgba(125,125,125,0.04); border: 1px solid rgba(125,125,125,0.08); border-radius: 6px; padding: 0.4rem 0.65rem; gap: 0.5rem; font-size: 0.82rem; }',
'.mosdns-answer-data { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }',
'.mosdns-answer-ttl { font-size: 0.75rem; opacity: 0.65; white-space: nowrap; }',
'@keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } }',
'@media (prefers-color-scheme: dark) {',
' .mosdns-stat-card, .mosdns-rank-panel, .mosdns-modal-meta-item, .mosdns-answer-row { background: rgba(255,255,255,0.03); border-color: rgba(255,255,255,0.08); box-shadow: none; }',
' .mosdns-sparkline-tooltip { background: #1e242b; border-color: rgba(255,255,255,0.15); box-shadow: 0 4px 12px rgba(0,0,0,0.5); }',
' .badge-danger { background: rgba(239, 68, 68, 0.2); color: #f87171; border-color: rgba(239, 68, 68, 0.35); }',
' .badge-teal { background: rgba(16, 185, 129, 0.2); color: #34d399; border-color: rgba(16, 185, 129, 0.35); }',
' .badge-primary { background: rgba(59, 130, 246, 0.2); color: #60a5fa; border-color: rgba(59, 130, 246, 0.35); }',
' .badge-neutral { background: rgba(156, 163, 175, 0.2); color: #9ca3af; border-color: rgba(156, 163, 175, 0.3); }',
' .dns-latency-fastest { color: #34d399; }',
' .dns-latency-normal { color: #60a5fa; }',
' .dns-latency-timeout { color: #f87171; }',
'}'
].join('\n');
document.head.appendChild(E('style', { id: 'mosdns-statistics-styles' }, css));
};
const debounce = (fn, delay = 300) => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
};
const formatTimestamp = iso => {
if (!iso) return '-';
const d = new Date(iso);
return isNaN(d) ? iso : d.toTimeString().slice(0, 8);
};
const getLatencyClass = elapsedMs => {
const val = parseFloat(elapsedMs) || 0;
if (val < 5) return 'dns-latency-fastest';
if (val < 20) return 'dns-latency-fast';
if (val < 50) return 'dns-latency-normal';
if (val < 100) return 'dns-latency-slow';
if (val < 300) return 'dns-latency-slower';
return 'dns-latency-timeout';
};
const updateLiveStatusBadge = () => {
if (!autoStatusBadge) return;
if (pageIdx === 0 && !searchVal && !isUserPaused) {
dom.content(autoStatusBadge, [
E('span', { class: 'mosdns-badge badge-teal badge-pulse' }, _('● Live Auto-refresh'))
]);
} else {
dom.content(autoStatusBadge, [
E('span', { class: 'mosdns-badge badge-neutral' }, _('❚❚ Paused (Page %d)').format(pageIdx + 1))
]);
}
};
const createSparklineSVG = (dataItems, strokeColor, fillGradId, maxScale) => {
const width = 300;
const height = 44;
const padTop = 4;
const padBottom = 2;
const drawHeight = height - padTop - padBottom;
let items = (dataItems && dataItems.length > 0) ? dataItems : [];
if (!items.length) {
items = new Array(24).fill(0).map(() => ({ time: '', val: 0 }));
}
if (items.length < 2) {
items = [items[0] || { time: '', val: 0 }, items[0] || { time: '', val: 0 }];
}
const vals = items.map(i => i.val || 0);
const maxVal = maxScale || Math.max(...vals, 1);
const len = items.length;
const coords = items.map((item, idx) => {
const x = (idx / (len - 1)) * width;
const y = height - padBottom - ((item.val || 0) / maxVal) * drawHeight;
return { x, y, time: item.time, val: item.val || 0 };
});
let pathD = 'M ' + coords[0].x.toFixed(1) + ',' + coords[0].y.toFixed(1);
for (let i = 0; i < coords.length - 1; i++) {
const p0 = coords[i === 0 ? 0 : i - 1];
const p1 = coords[i];
const p2 = coords[i + 1];
const p3 = coords[i + 2 < coords.length ? i + 2 : i + 1];
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
pathD += ' C ' + cp1x.toFixed(1) + ',' + cp1y.toFixed(1) + ' ' + cp2x.toFixed(1) + ',' + cp2y.toFixed(1) + ' ' + p2.x.toFixed(1) + ',' + p2.y.toFixed(1);
}
const areaD = pathD + ' L ' + width + ',' + height + ' L 0,' + height + ' Z';
const svgContainer = E('div', { class: 'mosdns-sparkline-wrap' });
svgContainer.innerHTML =
'<svg viewBox="0 0 ' + width + ' ' + height + '" class="mosdns-sparkline" preserveAspectRatio="none">' +
'<defs>' +
'<linearGradient id="' + fillGradId + '" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0%" stop-color="' + strokeColor + '" stop-opacity="0.30" />' +
'<stop offset="100%" stop-color="' + strokeColor + '" stop-opacity="0.02" />' +
'</linearGradient>' +
'</defs>' +
'<path d="' + areaD + '" fill="url(#' + fillGradId + ')" />' +
'<path d="' + pathD + '" fill="none" stroke="' + strokeColor + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />' +
'<g class="spark-hover-group" style="display: none;">' +
'<line class="spark-hover-line" x1="0" y1="' + padTop + '" x2="0" y2="' + (height - padBottom) + '" stroke="' + strokeColor + '" stroke-width="1.2" stroke-dasharray="2 2" opacity="0.6" />' +
'<circle class="spark-hover-dot" cx="0" cy="0" r="3.5" fill="' + strokeColor + '" stroke="#fff" stroke-width="1.5" />' +
'</g>' +
'<rect width="' + width + '" height="' + height + '" fill="transparent" class="spark-hover-hitbox" style="cursor: crosshair;" />' +
'</svg>' +
'<div class="mosdns-sparkline-tooltip" style="display: none;"></div>';
const hoverGroup = svgContainer.querySelector('.spark-hover-group');
const hoverLine = svgContainer.querySelector('.spark-hover-line');
const hoverDot = svgContainer.querySelector('.spark-hover-dot');
const hitbox = svgContainer.querySelector('.spark-hover-hitbox');
const tooltip = svgContainer.querySelector('.mosdns-sparkline-tooltip');
const onMove = e => {
const rect = svgContainer.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const ratio = Math.max(0, Math.min(1, mouseX / rect.width));
const idx = Math.round(ratio * (len - 1));
const coord = coords[idx];
if (!coord) return;
hoverLine.setAttribute('x1', coord.x);
hoverLine.setAttribute('x2', coord.x);
hoverDot.setAttribute('cx', coord.x);
hoverDot.setAttribute('cy', coord.y);
hoverGroup.style.display = 'block';
let timeStr = '-';
if (coord.time) {
const d = new Date(coord.time);
if (!isNaN(d)) {
const hh = String(d.getHours()).padStart(2, '0');
timeStr = hh + ':00';
} else {
timeStr = coord.time.slice(11, 16) || coord.time;
}
}
tooltip.innerHTML =
'<div style="font-weight: 700; color: ' + strokeColor + '; font-size: 0.82rem; line-height: 1.1;">' + coord.val.toLocaleString() + '</div>' +
'<div style="font-size: 0.72rem; opacity: 0.75; margin-top: 0.15rem;">' + timeStr + '</div>';
tooltip.style.display = 'block';
const tooltipX = (coord.x / width) * rect.width;
if (ratio > 0.65) {
tooltip.style.left = 'auto';
tooltip.style.right = (rect.width - tooltipX + 8) + 'px';
} else {
tooltip.style.left = (tooltipX + 8) + 'px';
tooltip.style.right = 'auto';
}
tooltip.style.top = '-6px';
};
const onLeave = () => {
hoverGroup.style.display = 'none';
tooltip.style.display = 'none';
};
hitbox.addEventListener('mousemove', onMove);
hitbox.addEventListener('mouseleave', onLeave);
return svgContainer;
};
const renderOverviewStats = (stats, historyData) => {
if (!stats || stats.error) {
return E('div', { class: 'alert-message warning' },
_('MosDNS API is unreachable. Please ensure MosDNS is running and stats_api plugin is enabled.'));
}
const {
total_queries: total = 0,
blocked_queries: blocked = 0,
cached_queries: cached = 0,
blocked_percentage: blocked_pct = 0,
cached_percentage: cached_pct = 0,
avg_latency_ms: avg_ms = 0
} = stats;
const points = historyData?.points || [];
const totalItems = points.map(p => ({ time: p.time, val: Number(p.total) || 0 }));
const blockedItems = points.map(p => ({ time: p.time, val: Number(p.blocked) || 0 }));
const cachedItems = points.map(p => ({ time: p.time, val: Number(p.cached) || 0 }));
const baseMax = Math.max(...totalItems.map(i => i.val), 1);
return E('div', { class: 'mosdns-grid' }, [
E('div', { class: 'mosdns-stat-card' }, [
E('div', {}, [
E('div', { class: 'title-row' }, [
E('span', {}, _('DNS Queries Total')),
E('span', { class: 'mosdns-badge badge-teal badge-pulse' }, _('● Live'))
]),
E('div', { class: 'metric-val' }, total.toLocaleString()),
E('div', { class: 'subtext' }, _('Avg Processing') + ': ' + avg_ms + ' ms')
]),
createSparklineSVG(totalItems, '#3b82f6', 'spark-grad-total', baseMax)
]),
E('div', { class: 'mosdns-stat-card' }, [
E('div', {}, [
E('div', { class: 'title-row' }, [
E('span', {}, _('Blocked by Filters')),
E('span', { class: 'mosdns-badge badge-danger' }, blocked_pct + '%')
]),
E('div', { class: 'metric-val', style: 'color: #dc2626;' }, blocked.toLocaleString())
]),
createSparklineSVG(blockedItems, '#dc2626', 'spark-grad-blocked', baseMax)
]),
E('div', { class: 'mosdns-stat-card' }, [
E('div', {}, [
E('div', { class: 'title-row' }, [
E('span', {}, _('Cached Queries')),
E('span', { class: 'mosdns-badge badge-teal' }, cached_pct + '%')
]),
E('div', { class: 'metric-val', style: 'color: #059669;' }, cached.toLocaleString())
]),
createSparklineSVG(cachedItems, '#059669', 'spark-grad-cached', baseMax)
]),
E('div', { class: 'mosdns-stat-card' }, [
E('div', {}, [
E('div', { class: 'title-row' }, [
E('span', {}, _('Average Processing Time')),
E('span', { class: 'mosdns-badge badge-primary' }, _('Latency'))
]),
E('div', { class: 'metric-val', style: 'color: #2563eb;' }, avg_ms + ' ms'),
E('div', { class: 'subtext' }, _('Per-query speed'))
])
])
]);
};
const renderTopRankings = topData => {
if (!topData || topData.error) return E('div', {});
const { top_blocked = [], top_domains = [], top_clients = [] } = topData;
const renderList = (items, key, color, isClient = false) => {
if (!items || !items.length) {
return E('div', { style: 'padding: 1.5rem; text-align: center; opacity: 0.5; font-size: 0.85rem;' }, _('No data available'));
}
const maxCount = Math.max(...items.map(i => i.count || 1));
return E('div', { style: 'display: flex; flex-direction: column;' },
items.map(item => {
let val = item[key] || '-';
if (isClient) val = cleanIP(val);
const cnt = item.count || 0;
const pct = Math.round((cnt / maxCount) * 100);
return E('div', { class: 'mosdns-rank-item' }, [
E('div', { class: 'mosdns-rank-bar', style: 'width: ' + pct + '%; background-color: ' + color + ';' }),
E('span', {
class: 'mosdns-mono',
style: 'font-size: 0.82rem; z-index: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 0.5rem;',
title: val
}, val),
E('span', { class: 'mosdns-badge badge-neutral mosdns-mono', style: 'z-index: 1;' }, cnt.toLocaleString())
]);
})
);
};
return E('div', { class: 'mosdns-rankings-grid' }, [
E('div', { class: 'mosdns-rank-panel' }, [
E('h4', { style: 'color: #2563eb;' }, [
E('span', {}, _('Top Queried Domains')),
E('span', { class: 'mosdns-badge badge-primary' }, top_domains.length)
]),
renderList(top_domains, 'domain', '#2563eb')
]),
E('div', { class: 'mosdns-rank-panel' }, [
E('h4', { style: 'color: #dc2626;' }, [
E('span', {}, _('Top Blocked Domains')),
E('span', { class: 'mosdns-badge badge-danger' }, top_blocked.length)
]),
renderList(top_blocked, 'domain', '#dc2626')
]),
E('div', { class: 'mosdns-rank-panel' }, [
E('h4', { style: 'color: #059669;' }, [
E('span', {}, _('Top Clients')),
E('span', { class: 'mosdns-badge badge-teal' }, top_clients.length)
]),
renderList(top_clients, 'client_ip', '#059669', true)
])
]);
};
const showLogDetailsModal = item => {
let statusBadge;
if (item.is_blocked) {
statusBadge = E('span', { class: 'mosdns-badge badge-danger mosdns-status-badge' }, 'BLOCKED');
} else if (item.is_cached) {
statusBadge = E('span', { class: 'mosdns-badge badge-teal mosdns-status-badge' }, 'CACHED');
} else if (item.status === 'NOERROR') {
statusBadge = E('span', { class: 'mosdns-badge badge-primary mosdns-status-badge' }, 'NOERROR');
} else {
statusBadge = E('span', { class: 'mosdns-badge badge-neutral mosdns-status-badge' }, item.status || 'NOERROR');
}
const answersCount = (item.answers && item.answers.length) || 0;
let answersContent;
if (answersCount > 0) {
answersContent = E('div', { class: 'mosdns-answers-list' },
item.answers.map(a => E('div', { class: 'mosdns-answer-row' }, [
E('div', { style: 'display: flex; align-items: center; gap: 0.5rem; overflow: hidden; flex: 1;' }, [
E('span', { class: 'badge-qtype', style: 'margin: 0;' }, a.type || 'A'),
E('span', { class: 'mosdns-mono mosdns-answer-data', title: a.data }, a.data)
]),
E('span', { class: 'mosdns-badge badge-neutral mosdns-mono mosdns-answer-ttl' }, 'TTL ' + a.ttl + 's')
]))
);
} else {
answersContent = E('div', {
style: 'text-align: center; padding: 1.25rem; background: rgba(125,125,125,0.03); border: 1px dashed rgba(125,125,125,0.15); border-radius: 6px; opacity: 0.6; font-size: 0.85rem;'
}, _('No DNS answer records returned.'));
}
const body = E('div', { style: 'padding: 0.25rem 0;' }, [
E('div', { class: 'mosdns-modal-header' }, [
E('div', { class: 'mosdns-modal-domain' }, [
E('span', { class: 'mosdns-mono' }, item.domain || '-'),
E('span', { class: 'badge-qtype' }, item.qtype || 'A')
]),
E('div', { style: 'display: flex; align-items: center; gap: 0.5rem;' }, [
statusBadge,
E('span', { class: 'mosdns-mono ' + getLatencyClass(item.elapsed_ms), style: 'font-size: 0.85rem;' }, item.elapsed_ms + ' ms')
])
]),
E('div', { class: 'mosdns-modal-meta-grid' }, [
E('div', { class: 'mosdns-modal-meta-item' }, [
E('div', { class: 'meta-label' }, _('Client IP')),
E('div', { class: 'meta-val mosdns-mono' }, cleanIP(item.client_ip))
]),
E('div', { class: 'mosdns-modal-meta-item' }, [
E('div', { class: 'meta-label' }, _('Time')),
E('div', { class: 'meta-val mosdns-mono' }, formatTimestamp(item.timestamp) + (item.timestamp ? ' (' + item.timestamp.slice(0, 10) + ')' : ''))
]),
E('div', { class: 'mosdns-modal-meta-item' }, [
E('div', { class: 'meta-label' }, _('Upstream')),
E('div', { class: 'meta-val mosdns-mono', style: 'word-break: break-all;' }, item.upstream || '-')
]),
E('div', { class: 'mosdns-modal-meta-item' }, [
E('div', { class: 'meta-label' }, _('Rule Hit')),
E('div', { class: 'meta-val mosdns-mono', style: 'word-break: break-all;' }, item.rule || '-')
])
]),
E('div', { class: 'mosdns-modal-section-title' }, [
E('span', {}, _('Answers')),
E('span', { class: 'mosdns-badge badge-neutral' }, answersCount)
]),
answersContent
]);
ui.showModal(_('Query Log Details'), [
body,
E('div', { class: 'right', style: 'margin-top: 1.25rem;' }, [
E('button', {
class: 'btn cbi-button cbi-button-action',
click: ui.hideModal
}, _('Close'))
])
]);
};
const renderLogsTable = logsData => {
const { total = 0, items = [] } = logsData || {};
const totalPages = Math.ceil(total / PAGE_SIZE) || 1;
const rows = items.map(item => {
let statusBadge;
if (item.is_blocked) {
statusBadge = E('span', { class: 'mosdns-badge badge-danger mosdns-status-badge' }, 'BLOCKED');
} else if (item.is_cached) {
statusBadge = E('span', { class: 'mosdns-badge badge-teal mosdns-status-badge' }, 'CACHED');
} else if (item.status === 'NOERROR') {
statusBadge = E('span', { class: 'mosdns-badge badge-primary mosdns-status-badge' }, 'NOERROR');
} else {
statusBadge = E('span', { class: 'mosdns-badge badge-neutral mosdns-status-badge' }, item.status || 'NOERROR');
}
const answersText = (item.answers && item.answers.length > 0)
? item.answers.map(a => a.data + ' (' + a.type + ')').join(', ')
: '-';
return E('tr', { class: 'tr' }, [
E('td', { class: 'td', style: 'font-size: 0.82rem; opacity: 0.7; white-space: nowrap;' }, formatTimestamp(item.timestamp)),
E('td', { class: 'td mosdns-mono', style: 'font-size: 0.82rem; white-space: nowrap;' }, cleanIP(item.client_ip)),
E('td', { class: 'td', style: 'max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;', title: item.domain || '-' }, [
E('span', { class: 'mosdns-mono', style: 'font-weight: 600;' }, item.domain || '-'),
E('span', { class: 'badge-qtype' }, item.qtype || 'A')
]),
E('td', { class: 'td' }, statusBadge),
E('td', {
class: 'td mosdns-mono',
style: 'font-size: 0.82rem; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;',
title: _('Click to view full details'),
click: () => showLogDetailsModal(item)
}, answersText),
E('td', { class: 'td mosdns-mono ' + getLatencyClass(item.elapsed_ms), style: 'text-align: right; font-size: 0.82rem;' }, item.elapsed_ms + ' ms')
]);
});
if (!rows.length) {
rows.push(E('tr', { class: 'tr' }, [
E('td', { class: 'td', colspan: 6, style: 'text-align: center; opacity: 0.5; padding: 2rem;' }, _('No query log entries found.'))
]));
}
return E('div', {}, [
E('table', { class: 'table cbi-section-table mosdns-table', style: 'margin-top: 0.25rem;' }, [
E('tr', { class: 'tr table-titles' }, [
E('th', { class: 'th', style: 'width: 85px;' }, _('Time')),
E('th', { class: 'th', style: 'width: 125px;' }, _('Client IP')),
E('th', { class: 'th' }, _('Domain & Record')),
E('th', { class: 'th', style: 'width: 90px;' }, _('Status')),
E('th', { class: 'th' }, _('Answers')),
E('th', { class: 'th', style: 'width: 90px; text-align: right;' }, _('Elapsed'))
]),
...rows
]),
E('div', { style: 'display: flex; justify-content: space-between; align-items: center; margin-top: 0.75rem;' }, [
E('span', { style: 'font-size: 0.85rem; opacity: 0.7;' }, _('Page %d / %d (%d entries)').format(pageIdx + 1, totalPages, total)),
E('div', { style: 'display: flex; gap: 0.5rem;' }, [
E('button', {
class: 'btn cbi-button cbi-button-action',
disabled: pageIdx === 0 ? 'disabled' : null,
click: () => {
if (pageIdx > 0) {
pageIdx--;
updateLiveStatusBadge();
refreshLogs();
}
}
}, _('Previous')),
E('button', {
class: 'btn cbi-button cbi-button-action',
disabled: (pageIdx + 1) >= totalPages ? 'disabled' : null,
click: () => {
if ((pageIdx + 1) < totalPages) {
pageIdx++;
updateLiveStatusBadge();
refreshLogs();
}
}
}, _('Next'))
])
])
]);
};
const pollScheduler = async () => {
try {
const promises = [
callGetStats(),
callGetTop(10),
callGetHistory(24)
];
const shouldRefreshLogs = (pageIdx === 0 && !searchVal && !isUserPaused);
if (shouldRefreshLogs) {
promises.push(callGetLogs(PAGE_SIZE, 0, '', filterVal));
}
const results = await Promise.all(promises);
dom.content(nodeStats, renderOverviewStats(results[0], results[2]));
dom.content(nodeTop, renderTopRankings(results[1]));
if (shouldRefreshLogs && results[3]) {
dom.content(nodeLogs, renderLogsTable(results[3]));
}
updateLiveStatusBadge();
} catch (e) {
}
};
const refreshLogs = async () => {
try {
const logs = await callGetLogs(PAGE_SIZE, pageIdx * PAGE_SIZE, searchVal, filterVal);
dom.content(nodeLogs, renderLogsTable(logs));
updateLiveStatusBadge();
} catch (e) {
ui.addNotification(null, E('p', [_('Failed to update query logs: '), e.message]), 'error');
}
};
return view.extend({
async load() {
return Promise.all([
L.resolveDefault(callGetStats(), {}),
L.resolveDefault(callGetTop(10), {}),
L.resolveDefault(callGetLogs(PAGE_SIZE, 0, searchVal, filterVal), {}),
L.resolveDefault(callGetHistory(24), {})
]);
},
render(data) {
injectStyles();
nodeStats = E('div', { id: 'overview-stats' });
nodeTop = E('div', { id: 'top-rankings' });
nodeLogs = E('div', { id: 'logs-table' });
autoStatusBadge = E('div', { style: 'display: inline-block;' });
dom.content(nodeStats, renderOverviewStats(data[0], data[3]));
dom.content(nodeTop, renderTopRankings(data[1]));
dom.content(nodeLogs, renderLogsTable(data[2]));
updateLiveStatusBadge();
const searchInput = E('input', {
type: 'text',
class: 'cbi-input-text',
placeholder: _('Search domain or client IP...'),
style: 'min-width: 220px;'
});
searchInput.addEventListener('input', debounce(() => {
searchVal = searchInput.value.trim();
pageIdx = 0;
updateLiveStatusBadge();
refreshLogs();
}, 300));
const filterSelect = E('select', { class: 'cbi-input-select' }, [
E('option', { value: 'all', selected: filterVal === 'all' ? 'selected' : null }, _('All Queries')),
E('option', { value: 'blocked', selected: filterVal === 'blocked' ? 'selected' : null }, _('Blocked Only')),
E('option', { value: 'cached', selected: filterVal === 'cached' ? 'selected' : null }, _('Cached Only'))
]);
filterSelect.addEventListener('change', () => {
filterVal = filterSelect.value;
pageIdx = 0;
updateLiveStatusBadge();
refreshLogs();
});
const resetPageBtn = E('button', {
class: 'cbi-button cbi-button-apply',
click: () => {
pageIdx = 0;
updateLiveStatusBadge();
refreshLogs();
}
}, _('First Page / Resume'));
const clearBtn = E('button', {
class: 'btn cbi-button cbi-button-remove',
style: 'margin-left: auto;'
}, _('Clear query logs'));
clearBtn.addEventListener('click', () => {
ui.showModal(_('Clear query logs'), [
E('p', {}, _('Are you sure you want to clear all real-time query logs and top rankings?')),
E('div', { class: 'right', style: 'margin-top: 1rem;' }, [
E('button', {
class: 'btn cbi-button cbi-button-neutral',
click: ui.hideModal
}, _('Cancel')),
' ',
E('button', {
class: 'btn cbi-button cbi-button-remove',
click: async () => {
ui.hideModal();
try {
const res = await callClearQueryLogs();
if (res?.success) {
ui.addNotification(null, E('p', _('Query logs cleared successfully.')), 'info');
pageIdx = 0;
await pollScheduler();
await refreshLogs();
} else {
ui.addNotification(null, E('p', [_('Failed to clear query logs: '), res?.error || '']), 'error');
}
} catch (e) {
ui.addNotification(null, E('p', e.message), 'error');
}
}
}, _('Clear'))
])
]);
});
const controlBar = E('div', { style: 'display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; margin-bottom: 0.75rem;' }, [
searchInput,
filterSelect,
resetPageBtn,
autoStatusBadge,
clearBtn
]);
poll.add(pollScheduler);
return E('div', { class: 'cbi-map' }, [
E('h2', { name: 'content' }, '%s - %s'.format(_('MosDNS'), _('Statistics'))),
nodeStats,
nodeTop,
E('div', { class: 'cbi-section' }, [
E('h3', {}, _('Real-time Query Logs')),
controlBar,
nodeLogs
])
]);
},
handleSave: null,
handleSaveApply: null,
handleReset: null
});
+224 -12
View File
@@ -74,24 +74,50 @@ msgstr ""
msgid "Aliyun Public DNS (DNS over QUIC)" msgid "Aliyun Public DNS (DNS over QUIC)"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:640
msgid "All Queries"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:68 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:68
msgid "Another update is already in progress." msgid "Another update is already in progress."
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:463
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:531
msgid "Answers"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:203 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:203
msgid "Apple domains optimization" msgid "Apple domains optimization"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:667
msgid ""
"Are you sure you want to clear all real-time query logs and top rankings?"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:359 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:359
msgid "Auto Save Cache Interval" msgid "Auto Save Cache Interval"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:447
msgid "Auto Save Stats Interval"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:106 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:106
msgid "" msgid ""
"Automatically update GeoIP and GeoSite databases as well as ad filtering " "Automatically update GeoIP and GeoSite databases as well as ad filtering "
"rules through scheduled tasks." "rules through scheduled tasks."
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:336
msgid "Average Processing Time"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:306
msgid "Avg Processing"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:217 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:217
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:263 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:263
msgid "Baidu Public DNS (180.76.76.76)" msgid "Baidu Public DNS (180.76.76.76)"
@@ -119,6 +145,14 @@ msgstr ""
msgid "Block PTR" msgid "Block PTR"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:641
msgid "Blocked Only"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:314
msgid "Blocked by Filters"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:255 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:255
msgid "Bootstrap DNS servers" msgid "Bootstrap DNS servers"
msgstr "" msgstr ""
@@ -137,6 +171,18 @@ msgstr ""
msgid "Cache Prefetching" msgid "Cache Prefetching"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:642
msgid "Cached Only"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:325
msgid "Cached Queries"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:672
msgid "Cancel"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:150 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:150
msgid "Check And Update" msgid "Check And Update"
msgstr "" msgstr ""
@@ -163,10 +209,29 @@ msgstr ""
msgid "Cisco Public DNS (208.67.222.222)" msgid "Cisco Public DNS (208.67.222.222)"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:692
msgid "Clear"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:65 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:65
msgid "Clear logs" msgid "Clear logs"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:663
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:666
msgid "Clear query logs"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:511
msgid "Click to view full details"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:445
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:528
msgid "Client IP"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:475
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:35 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:35
msgid "Close" msgid "Close"
msgstr "" msgstr ""
@@ -202,7 +267,7 @@ msgstr ""
msgid "Config File" msgid "Config File"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:455 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:478
msgid "Configuration Editor" msgid "Configuration Editor"
msgstr "" msgstr ""
@@ -240,6 +305,10 @@ msgstr ""
msgid "DNS Forward" msgid "DNS Forward"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:302
msgid "DNS Queries Total"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:271 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:271
msgid "" msgid ""
"DNS query request concurrency, The number of upstream DNS servers that are " "DNS query request concurrency, The number of upstream DNS servers that are "
@@ -280,6 +349,14 @@ msgstr ""
msgid "DoH/TCP/DoT Connection Multiplexing idle timeout (default 30 seconds)" msgid "DoH/TCP/DoT Connection Multiplexing idle timeout (default 30 seconds)"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:529
msgid "Domain & Record"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:532
msgid "Elapsed"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:111 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:111
msgid "Enable Auto Database Update" msgid "Enable Auto Database Update"
msgstr "" msgstr ""
@@ -296,6 +373,10 @@ msgstr ""
msgid "Enable EDNS client subnet" msgid "Enable EDNS client subnet"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:429
msgid "Enable Stats Collector"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:283 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:283
msgid "" msgid ""
"Enable TCP/DoT RFC 7766 new Query Pipelining connection multiplexing mode" "Enable TCP/DoT RFC 7766 new Query Pipelining connection multiplexing mode"
@@ -310,11 +391,11 @@ msgstr ""
msgid "Enabled" msgid "Enabled"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:482 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:505
msgid "Enter the GeoIP.dat category to be exported, Allow add multiple tags" msgid "Enter the GeoIP.dat category to be exported, Allow add multiple tags"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:477 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:500
msgid "Enter the GeoSite.dat category to be exported, Allow add multiple tags" msgid "Enter the GeoSite.dat category to be exported, Allow add multiple tags"
msgstr "" msgstr ""
@@ -354,8 +435,8 @@ msgstr ""
msgid "Every Wednesday" msgid "Every Wednesday"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:478 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:501
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:483 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:506
msgid "Export directory: /var/mosdns" msgid "Export directory: /var/mosdns"
msgstr "" msgstr ""
@@ -363,11 +444,23 @@ msgstr ""
msgid "Failed to clean logs." msgid "Failed to clean logs."
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:686
msgid "Failed to clear query logs:"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:89 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:89
msgid "Failed to start update." msgid "Failed to start update."
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:432 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:599
msgid "Failed to update query logs:"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:658
msgid "First Page / Resume"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:455
msgid "Flush DNS Cache" msgid "Flush DNS Cache"
msgstr "" msgstr ""
@@ -379,7 +472,7 @@ msgstr ""
msgid "Flushing DNS Cache Success." msgid "Flushing DNS Cache Success."
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:430 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:453
msgid "" msgid ""
"Flushing DNS Cache will clear any IP addresses or DNS records from MosDNS " "Flushing DNS Cache will clear any IP addresses or DNS records from MosDNS "
"cache." "cache."
@@ -415,7 +508,7 @@ msgstr ""
msgid "GeoData Export" msgid "GeoData Export"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:481 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:504
msgid "GeoIP Tags" msgid "GeoIP Tags"
msgstr "" msgstr ""
@@ -423,7 +516,7 @@ msgstr ""
msgid "GeoIP Type" msgid "GeoIP Type"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:476 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:499
msgid "GeoSite Tags" msgid "GeoSite Tags"
msgstr "" msgstr ""
@@ -496,6 +589,10 @@ msgid ""
"seconds)." "seconds)."
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:337
msgid "Latency"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:322 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:322
msgid "Lazy Cache TTL" msgid "Lazy Cache TTL"
msgstr "" msgstr ""
@@ -537,7 +634,7 @@ msgstr ""
msgid "Log Level" msgid "Log Level"
msgstr "" msgstr ""
#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:38 #: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:46
msgid "Logs" msgid "Logs"
msgstr "" msgstr ""
@@ -577,10 +674,17 @@ msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:33 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:33
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:106 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:106
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:70 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:70
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:708
#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:3 #: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:3
msgid "MosDNS" msgid "MosDNS"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:279
msgid ""
"MosDNS API is unreachable. Please ensure MosDNS is running and stats_api "
"plugin is enabled."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:107 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:107
msgid "MosDNS is a plugin-based DNS forwarder/traffic splitter." msgid "MosDNS is a plugin-based DNS forwarder/traffic splitter."
msgstr "" msgstr ""
@@ -593,10 +697,34 @@ msgstr ""
msgid "Netflix, Disney+, Hulu and streaming media rules list will use this DNS" msgid "Netflix, Disney+, Hulu and streaming media rules list will use this DNS"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:561
msgid "Next"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:428
msgid "No DNS answer records returned."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:353
msgid "No data available"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:27 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:27
msgid "No log data." msgid "No log data."
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:520
msgid "No query log entries found."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:538
msgid "Page %d / %d (%d entries)"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:340
msgid "Per-query speed"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:301 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:301
msgid "" msgid ""
"Please provide the IP address you use when accessing foreign websites. This " "Please provide the IP address you use when accessing foreign websites. This "
@@ -628,6 +756,10 @@ msgstr ""
msgid "Prevent DNS Leaks" msgid "Prevent DNS Leaks"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:550
msgid "Previous"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:329 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:329
msgid "" msgid ""
"Proactively refresh hot cache entries in the background before they expire." "Proactively refresh hot cache entries in the background before they expire."
@@ -643,10 +775,28 @@ msgstr ""
msgid "Quad9 Public DNS (9.9.9.9)" msgid "Quad9 Public DNS (9.9.9.9)"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:469
msgid "Query Log Details"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:435
msgid ""
"Query log ring buffer capacity (FIFO overwrite, default 2000, larger values "
"consume more memory)"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:681
msgid "Query logs cleared successfully."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:31 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:31
msgid "RUNNING" msgid "RUNNING"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:712
msgid "Real-time Query Logs"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:23 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:23
msgid "Redirect" msgid "Redirect"
msgstr "" msgstr ""
@@ -669,6 +819,14 @@ msgstr ""
msgid "Remote DNS server" msgid "Remote DNS server"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:434
msgid "Ring Buffer Capacity"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:457
msgid "Rule Hit"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:11 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:11
msgid "Rule Settings" msgid "Rule Settings"
msgstr "" msgstr ""
@@ -677,10 +835,18 @@ msgstr ""
msgid "Rules" msgid "Rules"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:441
msgid "Save query statistics and logs locally and reload on next startup."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:353 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:353
msgid "Save the cache locally and reload the cache dump on the next startup" msgid "Save the cache locally and reload the cache dump on the next startup"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:629
msgid "Search domain or client IP..."
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:175 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:175
msgid "Set the maximum size of the log file (in MB)." msgid "Set the maximum size of the log file (in MB)."
msgstr "" msgstr ""
@@ -689,6 +855,19 @@ msgstr ""
msgid "Starting update..." msgid "Starting update..."
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:708
#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:38
msgid "Statistics"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:440
msgid "Stats Dump"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:530
msgid "Status"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:25 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:25
msgid "Streaming Media" msgid "Streaming Media"
msgstr "" msgstr ""
@@ -730,13 +909,30 @@ msgid ""
"server)" "server)"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:456 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:479
msgid "" msgid ""
"This is the content of the file '/etc/mosdns/config_custom.yaml' from which " "This is the content of the file '/etc/mosdns/config_custom.yaml' from which "
"your MosDNS configuration will be generated. Only accepts configuration " "your MosDNS configuration will be generated. Only accepts configuration "
"content in yaml format." "content in yaml format."
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:449
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:527
msgid "Time"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:386
msgid "Top Blocked Domains"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:393
msgid "Top Clients"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:379
msgid "Top Queried Domains"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:213 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:213
msgid "TrafficRoute Public DNS (180.184.1.1)" msgid "TrafficRoute Public DNS (180.184.1.1)"
msgstr "" msgstr ""
@@ -745,7 +941,7 @@ msgstr ""
msgid "TrafficRoute Public DNS (180.184.2.2)" msgid "TrafficRoute Public DNS (180.184.2.2)"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:471 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:494
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:43 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:43
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:48 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:48
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:67 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:67
@@ -798,6 +994,10 @@ msgstr ""
msgid "Updating Database..." msgid "Updating Database..."
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:453
msgid "Upstream"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:164 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:164
msgid "Warning" msgid "Warning"
msgstr "" msgstr ""
@@ -832,3 +1032,15 @@ msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:143 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:143
msgid "https://gh-proxy.com" msgid "https://gh-proxy.com"
msgstr "" msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:303
msgid "● Live"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:148
msgid "● Live Auto-refresh"
msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:152
msgid "❚❚ Paused (Page %d)"
msgstr ""
+234 -16
View File
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-20 09:23+0800\n" "POT-Creation-Date: 2026-03-20 09:23+0800\n"
"PO-Revision-Date: 2026-05-15 14:09+0800\n" "PO-Revision-Date: 2026-08-30 08:26+0800\n"
"Last-Translator: sbwml <admin@cooluc.com>\n" "Last-Translator: sbwml <admin@cooluc.com>\n"
"Language-Team: Chinese\n" "Language-Team: Chinese\n"
"Language: zh_Hans\n" "Language: zh_Hans\n"
@@ -89,24 +89,50 @@ msgstr "阿里云公共 DNSDNS over HTTPS"
msgid "Aliyun Public DNS (DNS over QUIC)" msgid "Aliyun Public DNS (DNS over QUIC)"
msgstr "阿里云公共 DNSDNS over QUIC" msgstr "阿里云公共 DNSDNS over QUIC"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:640
msgid "All Queries"
msgstr "所有查询"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:68 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:68
msgid "Another update is already in progress." msgid "Another update is already in progress."
msgstr "另一个更新正在进行中。" msgstr "另一个更新正在进行中。"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:463
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:531
msgid "Answers"
msgstr "应答结果"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:203 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:203
msgid "Apple domains optimization" msgid "Apple domains optimization"
msgstr "Apple 域名解析优化" msgstr "Apple 域名解析优化"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:667
msgid ""
"Are you sure you want to clear all real-time query logs and top rankings?"
msgstr "确定要清空所有实时查询日志和排行数据吗?"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:359 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:359
msgid "Auto Save Cache Interval" msgid "Auto Save Cache Interval"
msgstr "自动保存缓存间隔(秒)" msgstr "自动保存缓存间隔(秒)"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:447
msgid "Auto Save Stats Interval"
msgstr "自动保存统计间隔"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:106 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:106
msgid "" msgid ""
"Automatically update GeoIP and GeoSite databases as well as ad filtering " "Automatically update GeoIP and GeoSite databases as well as ad filtering "
"rules through scheduled tasks." "rules through scheduled tasks."
msgstr "通过定时任务自动更新 GeoIP 和 GeoSite 数据库以及广告过滤规则。" msgstr "通过定时任务自动更新 GeoIP 和 GeoSite 数据库以及广告过滤规则。"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:336
msgid "Average Processing Time"
msgstr "平均延迟"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:306
msgid "Avg Processing"
msgstr "平均处理"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:217 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:217
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:263 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:263
msgid "Baidu Public DNS (180.76.76.76)" msgid "Baidu Public DNS (180.76.76.76)"
@@ -136,6 +162,14 @@ msgstr "黑名单"
msgid "Block PTR" msgid "Block PTR"
msgstr "PTR 黑名单" msgstr "PTR 黑名单"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:641
msgid "Blocked Only"
msgstr "仅拦截"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:314
msgid "Blocked by Filters"
msgstr "拦截总数"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:255 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:255
msgid "Bootstrap DNS servers" msgid "Bootstrap DNS servers"
msgstr "Bootstrap DNS 服务器" msgstr "Bootstrap DNS 服务器"
@@ -154,6 +188,18 @@ msgstr "自动保存缓存"
msgid "Cache Prefetching" msgid "Cache Prefetching"
msgstr "缓存预取" msgstr "缓存预取"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:642
msgid "Cached Only"
msgstr "仅缓存"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:325
msgid "Cached Queries"
msgstr "缓存命中"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:672
msgid "Cancel"
msgstr "取消"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:150 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:150
msgid "Check And Update" msgid "Check And Update"
msgstr "检查并更新" msgstr "检查并更新"
@@ -180,10 +226,29 @@ msgstr "思科公共 DNS208.67.220.220"
msgid "Cisco Public DNS (208.67.222.222)" msgid "Cisco Public DNS (208.67.222.222)"
msgstr "思科公共 DNS208.67.222.222" msgstr "思科公共 DNS208.67.222.222"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:692
msgid "Clear"
msgstr "清空"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:65 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:65
msgid "Clear logs" msgid "Clear logs"
msgstr "清空日志" msgstr "清空日志"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:663
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:666
msgid "Clear query logs"
msgstr "清除查询日志"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:511
msgid "Click to view full details"
msgstr "点击查看完整详情"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:445
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:528
msgid "Client IP"
msgstr "客户端 IP"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:475
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:35 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:35
msgid "Close" msgid "Close"
msgstr "关闭" msgstr "关闭"
@@ -219,7 +284,7 @@ msgstr "DNS 服务器并发数(默认 2"
msgid "Config File" msgid "Config File"
msgstr "配置文件" msgstr "配置文件"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:455 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:478
msgid "Configuration Editor" msgid "Configuration Editor"
msgstr "配置编辑器" msgstr "配置编辑器"
@@ -258,6 +323,10 @@ msgstr "DNS 缓存大小"
msgid "DNS Forward" msgid "DNS Forward"
msgstr "DNS 转发" msgstr "DNS 转发"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:302
msgid "DNS Queries Total"
msgstr "DNS 查询总数"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:271 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:271
msgid "" msgid ""
"DNS query request concurrency, The number of upstream DNS servers that are " "DNS query request concurrency, The number of upstream DNS servers that are "
@@ -299,6 +368,14 @@ msgstr ""
msgid "DoH/TCP/DoT Connection Multiplexing idle timeout (default 30 seconds)" msgid "DoH/TCP/DoT Connection Multiplexing idle timeout (default 30 seconds)"
msgstr "DoH/TCP/DoT 连接复用空闲保持时间(默认 30 秒)" msgstr "DoH/TCP/DoT 连接复用空闲保持时间(默认 30 秒)"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:529
msgid "Domain & Record"
msgstr "域名与类型"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:532
msgid "Elapsed"
msgstr "耗时"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:111 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:111
msgid "Enable Auto Database Update" msgid "Enable Auto Database Update"
msgstr "启用自动更新" msgstr "启用自动更新"
@@ -315,6 +392,10 @@ msgstr "启用 DNS 缓存"
msgid "Enable EDNS client subnet" msgid "Enable EDNS client subnet"
msgstr "启用 EDNS 客户端子网" msgstr "启用 EDNS 客户端子网"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:429
msgid "Enable Stats Collector"
msgstr "启用统计收集器"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:283 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:283
msgid "" msgid ""
"Enable TCP/DoT RFC 7766 new Query Pipelining connection multiplexing mode" "Enable TCP/DoT RFC 7766 new Query Pipelining connection multiplexing mode"
@@ -329,11 +410,11 @@ msgstr "启用此选项 fallback 策略会强制转发到远程 DNS"
msgid "Enabled" msgid "Enabled"
msgstr "启用" msgstr "启用"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:482 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:505
msgid "Enter the GeoIP.dat category to be exported, Allow add multiple tags" msgid "Enter the GeoIP.dat category to be exported, Allow add multiple tags"
msgstr "输入需要导出的 GeoIP.dat 类别条目,允许添加多个标签" msgstr "输入需要导出的 GeoIP.dat 类别条目,允许添加多个标签"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:477 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:500
msgid "Enter the GeoSite.dat category to be exported, Allow add multiple tags" msgid "Enter the GeoSite.dat category to be exported, Allow add multiple tags"
msgstr "填写需要导出的 GeoSite.dat 类别条目,允许添加多个标签" msgstr "填写需要导出的 GeoSite.dat 类别条目,允许添加多个标签"
@@ -373,8 +454,8 @@ msgstr "每周二"
msgid "Every Wednesday" msgid "Every Wednesday"
msgstr "每周三" msgstr "每周三"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:478 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:501
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:483 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:506
msgid "Export directory: /var/mosdns" msgid "Export directory: /var/mosdns"
msgstr "导出目录:/var/mosdns" msgstr "导出目录:/var/mosdns"
@@ -382,11 +463,23 @@ msgstr "导出目录:/var/mosdns"
msgid "Failed to clean logs." msgid "Failed to clean logs."
msgstr "清理日志失败:%s" msgstr "清理日志失败:%s"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:686
msgid "Failed to clear query logs:"
msgstr "清除查询日志失败:"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:89 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:89
msgid "Failed to start update." msgid "Failed to start update."
msgstr "启动更新失败" msgstr "启动更新失败"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:432 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:599
msgid "Failed to update query logs:"
msgstr "更新查询日志失败:"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:658
msgid "First Page / Resume"
msgstr "重置并恢复"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:455
msgid "Flush DNS Cache" msgid "Flush DNS Cache"
msgstr "刷新 DNS 缓存" msgstr "刷新 DNS 缓存"
@@ -398,7 +491,7 @@ msgstr "刷新 DNS 缓存失败,请检查 MosDNS 状态是否在运行中。"
msgid "Flushing DNS Cache Success." msgid "Flushing DNS Cache Success."
msgstr "刷新 DNS 缓存成功" msgstr "刷新 DNS 缓存成功"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:430 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:453
msgid "" msgid ""
"Flushing DNS Cache will clear any IP addresses or DNS records from MosDNS " "Flushing DNS Cache will clear any IP addresses or DNS records from MosDNS "
"cache." "cache."
@@ -434,7 +527,7 @@ msgstr "全量:包含所有国家和私有 IP 地址。"
msgid "GeoData Export" msgid "GeoData Export"
msgstr "GeoData 导出" msgstr "GeoData 导出"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:481 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:504
msgid "GeoIP Tags" msgid "GeoIP Tags"
msgstr "GeoIP 标签" msgstr "GeoIP 标签"
@@ -442,7 +535,7 @@ msgstr "GeoIP 标签"
msgid "GeoIP Type" msgid "GeoIP Type"
msgstr "GeoIP 类型" msgstr "GeoIP 类型"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:476 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:499
msgid "GeoSite Tags" msgid "GeoSite Tags"
msgstr "GeoSite 标签" msgstr "GeoSite 标签"
@@ -475,7 +568,7 @@ msgstr "灰名单"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:22 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:22
msgid "Hosts" msgid "Hosts"
msgstr "" msgstr "Hosts 列表"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:300 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:300
msgid "IP Address" msgid "IP Address"
@@ -488,6 +581,10 @@ msgid ""
"www.cloudflare.com/ips-v6\" target=\"_blank\">https://www.cloudflare.com/ips-" "www.cloudflare.com/ips-v6\" target=\"_blank\">https://www.cloudflare.com/ips-"
"v6</a>" "v6</a>"
msgstr "" msgstr ""
"IPv4 CIDR: <a href=\"https://www.cloudflare.com/ips-v4\" target=\"_blank"
"\">https://www.cloudflare.com/ips-v4</a> <br /> IPv6 CIDR: <a href=\"https://"
"www.cloudflare.com/ips-v6\" target=\"_blank\">https://www.cloudflare.com/ips-"
"v6</a>"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:190 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:190
msgid "" msgid ""
@@ -515,6 +612,10 @@ msgid ""
"seconds)." "seconds)."
msgstr "后台线程扫描缓存以进行预取的时间间隔(单位:秒)" msgstr "后台线程扫描缓存以进行预取的时间间隔(单位:秒)"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:337
msgid "Latency"
msgstr "延迟"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:322 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:322
msgid "Lazy Cache TTL" msgid "Lazy Cache TTL"
msgstr "乐观缓存 TTL" msgstr "乐观缓存 TTL"
@@ -556,7 +657,7 @@ msgstr "日志文件大小"
msgid "Log Level" msgid "Log Level"
msgstr "日志等级" msgstr "日志等级"
#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:38 #: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:46
msgid "Logs" msgid "Logs"
msgstr "日志" msgstr "日志"
@@ -599,10 +700,19 @@ msgstr "修改 DNS 应答结果的最小 TTL 值 (秒),0 表示不修改"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:33 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:33
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:106 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:106
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:70 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:70
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:708
#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:3 #: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:3
msgid "MosDNS" msgid "MosDNS"
msgstr "MosDNS" msgstr "MosDNS"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:279
msgid ""
"MosDNS API is unreachable. Please ensure MosDNS is running and stats_api "
"plugin is enabled."
msgstr ""
"MosDNS API 无法连接。请确保 MosDNS 正在运行且已启用统计收集器(stats_api)插"
"件。"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:107 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:107
msgid "MosDNS is a plugin-based DNS forwarder/traffic splitter." msgid "MosDNS is a plugin-based DNS forwarder/traffic splitter."
msgstr "MosDNS 是一个插件化的 DNS 转发/分流器。" msgstr "MosDNS 是一个插件化的 DNS 转发/分流器。"
@@ -615,10 +725,34 @@ msgstr "未运行"
msgid "Netflix, Disney+, Hulu and streaming media rules list will use this DNS" msgid "Netflix, Disney+, Hulu and streaming media rules list will use this DNS"
msgstr "自定义 Netflix、Disney+、Hulu 以及 “流媒体” 规则列表的 DNS 服务器" msgstr "自定义 Netflix、Disney+、Hulu 以及 “流媒体” 规则列表的 DNS 服务器"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:561
msgid "Next"
msgstr "下一页"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:428
msgid "No DNS answer records returned."
msgstr "无 DNS 应答记录"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:353
msgid "No data available"
msgstr "暂无数据"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:27 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:27
msgid "No log data." msgid "No log data."
msgstr "无日志数据。" msgstr "无日志数据。"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:520
msgid "No query log entries found."
msgstr "未找到查询日志记录。"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:538
msgid "Page %d / %d (%d entries)"
msgstr "第 %d / %d 页(%d 条记录)"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:340
msgid "Per-query speed"
msgstr "单次查询速度"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:301 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:301
msgid "" msgid ""
"Please provide the IP address you use when accessing foreign websites. This " "Please provide the IP address you use when accessing foreign websites. This "
@@ -652,6 +786,10 @@ msgstr "当剩余 TTL 小于此值(秒)时触发预取"
msgid "Prevent DNS Leaks" msgid "Prevent DNS Leaks"
msgstr "防止 DNS 泄漏" msgstr "防止 DNS 泄漏"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:550
msgid "Previous"
msgstr "上一页"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:329 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:329
msgid "" msgid ""
"Proactively refresh hot cache entries in the background before they expire." "Proactively refresh hot cache entries in the background before they expire."
@@ -667,10 +805,28 @@ msgstr "Quad9 公共 DNS149.112.112.112"
msgid "Quad9 Public DNS (9.9.9.9)" msgid "Quad9 Public DNS (9.9.9.9)"
msgstr "Quad9 公共 DNS9.9.9.9" msgstr "Quad9 公共 DNS9.9.9.9"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:469
msgid "Query Log Details"
msgstr "查询日志详情"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:435
msgid ""
"Query log ring buffer capacity (FIFO overwrite, default 2000, larger values "
"consume more memory)"
msgstr "查询日志环形缓冲区容量(FIFO 覆盖,默认 2000,数值越大占用内存越多)"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:681
msgid "Query logs cleared successfully."
msgstr "查询日志清除成功。"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:31 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:31
msgid "RUNNING" msgid "RUNNING"
msgstr "运行中" msgstr "运行中"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:712
msgid "Real-time Query Logs"
msgstr "实时查询日志"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:23 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:23
msgid "Redirect" msgid "Redirect"
msgstr "重定向" msgstr "重定向"
@@ -695,6 +851,14 @@ msgstr "远程 DNS 首选 IPv4"
msgid "Remote DNS server" msgid "Remote DNS server"
msgstr "远程 DNS 服务器" msgstr "远程 DNS 服务器"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:434
msgid "Ring Buffer Capacity"
msgstr "环形缓冲区容量"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:457
msgid "Rule Hit"
msgstr "命中规则"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:11 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:11
msgid "Rule Settings" msgid "Rule Settings"
msgstr "自定义规则列表" msgstr "自定义规则列表"
@@ -703,17 +867,38 @@ msgstr "自定义规则列表"
msgid "Rules" msgid "Rules"
msgstr "规则列表" msgstr "规则列表"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:441
msgid "Save query statistics and logs locally and reload on next startup."
msgstr "保存统计数据与查询日志在本地,并在下次启动时重新加载"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:353 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:353
msgid "Save the cache locally and reload the cache dump on the next startup" msgid "Save the cache locally and reload the cache dump on the next startup"
msgstr "保存缓存到本地文件,以供下次启动时重新载入使用" msgstr "保存缓存到本地文件,以供下次启动时重新载入使用"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:629
msgid "Search domain or client IP..."
msgstr "搜索域名或客户端 IP..."
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:175 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:175
msgid "Set the maximum size of the log file (in MB)." msgid "Set the maximum size of the log file (in MB)."
msgstr "设置日志文件的最大容量(单位:MB)。" msgstr "设置日志文件的最大容量(单位:MB)。"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:28 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:28
msgid "Starting update..." msgid "Starting update..."
msgstr "" msgstr "正在启动更新..."
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:708
#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:38
msgid "Statistics"
msgstr "统计"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:440
msgid "Stats Dump"
msgstr "自动保存统计"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:530
msgid "Status"
msgstr "状态"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:25 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:25
msgid "Streaming Media" msgid "Streaming Media"
@@ -758,7 +943,7 @@ msgstr ""
"此功能通常在使用自建 DNS 服务器作为 远程 / 流媒体 DNS 上游时使用(需要上游服" "此功能通常在使用自建 DNS 服务器作为 远程 / 流媒体 DNS 上游时使用(需要上游服"
"务器的支持)" "务器的支持)"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:456 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:479
msgid "" msgid ""
"This is the content of the file '/etc/mosdns/config_custom.yaml' from which " "This is the content of the file '/etc/mosdns/config_custom.yaml' from which "
"your MosDNS configuration will be generated. Only accepts configuration " "your MosDNS configuration will be generated. Only accepts configuration "
@@ -767,6 +952,23 @@ msgstr ""
"这是文件 “/etc/mosdns/config_custom.yaml” 的内容,您的 MosDNS 配置将从此文件" "这是文件 “/etc/mosdns/config_custom.yaml” 的内容,您的 MosDNS 配置将从此文件"
"生成。仅接受 yaml 格式的配置内容。" "生成。仅接受 yaml 格式的配置内容。"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:449
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:527
msgid "Time"
msgstr "时间"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:386
msgid "Top Blocked Domains"
msgstr "拦截域名 (Top 10)"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:393
msgid "Top Clients"
msgstr "活跃客户端 (Top 10)"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:379
msgid "Top Queried Domains"
msgstr "查询域名 (Top 10)"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:213 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:213
msgid "TrafficRoute Public DNS (180.184.1.1)" msgid "TrafficRoute Public DNS (180.184.1.1)"
msgstr "火山引擎公共 DNS180.184.1.1" msgstr "火山引擎公共 DNS180.184.1.1"
@@ -775,7 +977,7 @@ msgstr "火山引擎公共 DNS180.184.1.1"
msgid "TrafficRoute Public DNS (180.184.2.2)" msgid "TrafficRoute Public DNS (180.184.2.2)"
msgstr "火山引擎公共 DNS180.184.2.2" msgstr "火山引擎公共 DNS180.184.2.2"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:471 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:494
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:43 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:43
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:48 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:48
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:67 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:67
@@ -828,6 +1030,10 @@ msgstr "更新成功"
msgid "Updating Database..." msgid "Updating Database..."
msgstr "更新数据库..." msgstr "更新数据库..."
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:453
msgid "Upstream"
msgstr "上游服务器"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:164 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:164
msgid "Warning" msgid "Warning"
msgstr "警告" msgstr "警告"
@@ -865,4 +1071,16 @@ msgstr "信风公共 DNS114.114.115.115"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:143 #: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:143
msgid "https://gh-proxy.com" msgid "https://gh-proxy.com"
msgstr "" msgstr "https://gh-proxy.com"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:303
msgid "● Live"
msgstr "● 实时"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:148
msgid "● Live Auto-refresh"
msgstr "● 实时刷新"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:152
msgid "❚❚ Paused (Page %d)"
msgstr "❚❚ 暂停 (第 %d 页)"
+6 -1
View File
@@ -8,7 +8,7 @@ config mosdns 'config'
option geo_update_day_time '2' option geo_update_day_time '2'
option geoip_type 'geoip-only-cn-private' option geoip_type 'geoip-only-cn-private'
option configfile '/var/etc/mosdns.json' option configfile '/var/etc/mosdns.json'
option log_level 'info' option log_level 'error'
option log_file '/var/log/mosdns.log' option log_file '/var/log/mosdns.log'
option log_size '1' option log_size '1'
option cache '1' option cache '1'
@@ -30,4 +30,9 @@ config mosdns 'config'
option lazy_cache_ttl '86400' option lazy_cache_ttl '86400'
option dump_file '0' option dump_file '0'
option reject_type65 '1' option reject_type65 '1'
option stats_collector '1'
option stats_capacity '2000'
option stats_dump_file '0'
option stats_dump_interval '600'
+26 -1
View File
@@ -10,6 +10,7 @@ CONF=$(uci -q get mosdns.config.configfile)
CRON_FILE=/etc/crontabs/root CRON_FILE=/etc/crontabs/root
DUMP_FILE=/etc/mosdns/cache.dump DUMP_FILE=/etc/mosdns/cache.dump
DUMP_FILE_DEFAULT=/usr/share/mosdns/cache.dump DUMP_FILE_DEFAULT=/usr/share/mosdns/cache.dump
STATS_DUMP_FILE=/etc/mosdns/stats.dump
MOSDNS_SCRIPT=/usr/share/mosdns/mosdns.uc MOSDNS_SCRIPT=/usr/share/mosdns/mosdns.uc
REDIRECT_LOCK_FILE=/etc/mosdns/redirect.lock REDIRECT_LOCK_FILE=/etc/mosdns/redirect.lock
@@ -34,7 +35,7 @@ get_config() {
config_get listen_address $1 listen_address "0.0.0.0" config_get listen_address $1 listen_address "0.0.0.0"
config_get log_file $1 log_file "/var/log/mosdns.log" config_get log_file $1 log_file "/var/log/mosdns.log"
config_get log_size $1 log_size "1" config_get log_size $1 log_size "1"
config_get log_level $1 log_level "info" config_get log_level $1 log_level "error"
config_get minimal_ttl $1 minimal_ttl 0 config_get minimal_ttl $1 minimal_ttl 0
config_get maximum_ttl $1 maximum_ttl 0 config_get maximum_ttl $1 maximum_ttl 0
config_get redirect $1 redirect 0 config_get redirect $1 redirect 0
@@ -57,6 +58,10 @@ get_config() {
config_get cloudflare $1 cloudflare 0 config_get cloudflare $1 cloudflare 0
config_get cloudflare_ip $1 cloudflare_ip "" config_get cloudflare_ip $1 cloudflare_ip ""
config_get reject_type65 $1 reject_type65 "0" config_get reject_type65 $1 reject_type65 "0"
config_get stats_collector $1 stats_collector 1
config_get stats_capacity $1 stats_capacity 2000
config_get stats_dump_file $1 stats_dump_file 0
config_get stats_dump_interval $1 stats_dump_interval 600
} }
generate_config() { generate_config() {
@@ -79,6 +84,20 @@ generate_config() {
json_close_array json_close_array
# plugins # plugins
json_add_array "plugins" json_add_array "plugins"
# plugin: stats_collector
[ "$stats_collector" -eq 1 ] && {
json_add_object
json_add_string "tag" "stats_collector"
json_add_string "type" "stats_api"
json_add_object "args"
json_add_int "capacity" "$stats_capacity"
[ "$stats_dump_file" -eq 1 ] && {
json_add_string "dump_file" "$STATS_DUMP_FILE"
json_add_int "dump_interval" "$stats_dump_interval"
}
json_close_object
json_close_object
}
# plugin: geosite_cn # plugin: geosite_cn
json_add_object json_add_object
json_add_string "tag" "geosite_cn" json_add_string "tag" "geosite_cn"
@@ -589,6 +608,11 @@ generate_config() {
json_add_string "tag" "main_sequence" json_add_string "tag" "main_sequence"
json_add_string "type" "sequence" json_add_string "type" "sequence"
json_add_array "args" json_add_array "args"
[ "$stats_collector" -eq 1 ] && {
json_add_object
json_add_string "exec" "\$stats_collector"
json_close_object
}
json_add_object json_add_object
json_add_string "exec" "\$hosts" json_add_string "exec" "\$hosts"
json_close_object json_close_object
@@ -701,6 +725,7 @@ generate_config() {
# init dump_file # init dump_file
[ "$dump_file" -eq 1 ] && [ ! -f $DUMP_FILE ] && cp -a $DUMP_FILE_DEFAULT $DUMP_FILE [ "$dump_file" -eq 1 ] && [ ! -f $DUMP_FILE ] && cp -a $DUMP_FILE_DEFAULT $DUMP_FILE
[ "$dump_file" -eq 0 ] && \cp -a $DUMP_FILE_DEFAULT $DUMP_FILE [ "$dump_file" -eq 0 ] && \cp -a $DUMP_FILE_DEFAULT $DUMP_FILE
[ "$stats_dump_file" -eq 0 ] && rm -f $STATS_DUMP_FILE
} }
service_triggers() { service_triggers() {
@@ -9,6 +9,14 @@ api:
include: [] include: []
plugins: plugins:
# 统计收集器
- tag: stats_collector
type: stats_api
args:
capacity: 2000 # 查询日志环缓冲区容量 (FIFO 覆盖)
dump_file: "/etc/mosdns/stats.dump" # 数据持久化转存文件路径,不设置则不转存
dump_interval: 600 # 周期性转储间隔(秒)
# 国内域名 # 国内域名
- tag: geosite_cn - tag: geosite_cn
type: domain_set type: domain_set
@@ -120,6 +128,7 @@ plugins:
- tag: main_sequence - tag: main_sequence
type: sequence type: sequence
args: args:
- exec: $stats_collector # 执行全局统计控制器
- exec: $lazy_cache - exec: $lazy_cache
- exec: jump has_resp_sequence - exec: jump has_resp_sequence
- exec: $query_is_local_domain - exec: $query_is_local_domain
@@ -34,9 +34,17 @@
"path": "mosdns/update" "path": "mosdns/update"
} }
}, },
"admin/services/mosdns/statistics": {
"title": "Statistics",
"order": 25,
"action": {
"type": "view",
"path": "mosdns/statistics"
}
},
"admin/services/mosdns/logs": { "admin/services/mosdns/logs": {
"title": "Logs", "title": "Logs",
"order": 25, "order": 30,
"action": { "action": {
"type": "view", "type": "view",
"path": "mosdns/logs" "path": "mosdns/logs"
@@ -149,8 +149,8 @@ function update_adlist() {
print(`Downloading ${mirror}${url}\n`); print(`Downloading ${mirror}${url}\n`);
stdout.flush(); stdout.flush();
let curl_res = exec_sys(`curl --connect-timeout 5 -m 90 --ipv4 -kfSLo "${ad_tmpdir}/${filename}" "${mirror}${url}"`); let dl_res = exec_sys(`wget -4 -q --no-check-certificate -T 90 -O "${ad_tmpdir}/${filename}" "${mirror}${url}"`);
if (curl_res.code !== 0) download_failed = true; if (dl_res.code !== 0) download_failed = true;
} }
} }
@@ -192,7 +192,7 @@ function update_geodat() {
print(`Downloading ${geoip_url}.sha256sum\n`); print(`Downloading ${geoip_url}.sha256sum\n`);
stdout.flush(); stdout.flush();
if (exec_sys(`curl --connect-timeout 5 -m 20 --ipv4 -kfSLo "${tmpdir}/geoip.dat.sha256sum" "${geoip_url}.sha256sum"`).code !== 0) { if (exec_sys(`wget -4 -q --no-check-certificate -T 20 -O "${tmpdir}/geoip.dat.sha256sum" "${geoip_url}.sha256sum"`).code !== 0) {
exec_sys(`rm -rf "${tmpdir}"`); exec_sys(`rm -rf "${tmpdir}"`);
die("Failed to download geoip.dat.sha256sum"); die("Failed to download geoip.dat.sha256sum");
} }
@@ -209,7 +209,7 @@ function update_geodat() {
} else { } else {
print(`Downloading ${geoip_url}\n`); print(`Downloading ${geoip_url}\n`);
stdout.flush(); stdout.flush();
if (exec_sys(`curl --connect-timeout 5 -m 120 --ipv4 -kfSLo "${tmpdir}/geoip.dat" "${geoip_url}"`).code !== 0) { if (exec_sys(`wget -4 -q --no-check-certificate -T 120 -O "${tmpdir}/geoip.dat" "${geoip_url}"`).code !== 0) {
exec_sys(`rm -rf "${tmpdir}"`); exec_sys(`rm -rf "${tmpdir}"`);
die("Failed to download geoip.dat"); die("Failed to download geoip.dat");
} }
@@ -227,7 +227,7 @@ function update_geodat() {
print(`Downloading ${geosite_url}.sha256sum\n`); print(`Downloading ${geosite_url}.sha256sum\n`);
stdout.flush(); stdout.flush();
if (exec_sys(`curl --connect-timeout 5 -m 20 --ipv4 -kfSLo "${tmpdir}/geosite.dat.sha256sum" "${geosite_url}.sha256sum"`).code !== 0) { if (exec_sys(`wget -4 -q --no-check-certificate -T 20 -O "${tmpdir}/geosite.dat.sha256sum" "${geosite_url}.sha256sum"`).code !== 0) {
exec_sys(`rm -rf "${tmpdir}"`); exec_sys(`rm -rf "${tmpdir}"`);
die("Failed to download geosite.dat.sha256sum"); die("Failed to download geosite.dat.sha256sum");
} }
@@ -244,7 +244,7 @@ function update_geodat() {
} else { } else {
print(`Downloading ${geosite_url}\n`); print(`Downloading ${geosite_url}\n`);
stdout.flush(); stdout.flush();
if (exec_sys(`curl --connect-timeout 5 -m 120 --ipv4 -kfSLo "${tmpdir}/geosite.dat" "${geosite_url}"`).code !== 0) { if (exec_sys(`wget -4 -q --no-check-certificate -T 120 -O "${tmpdir}/geosite.dat" "${geosite_url}"`).code !== 0) {
exec_sys(`rm -rf "${tmpdir}"`); exec_sys(`rm -rf "${tmpdir}"`);
die("Failed to download geosite.dat"); die("Failed to download geosite.dat");
} }
@@ -16,10 +16,15 @@
"service": [ "list" ], "service": [ "list" ],
"luci.mosdns": [ "luci.mosdns": [
"clean_log", "clean_log",
"clear_query_logs",
"flush_cache", "flush_cache",
"get_history",
"get_logs",
"get_stats",
"get_top",
"get_update_log",
"get_version", "get_version",
"print_log", "print_log",
"get_update_log",
"start_update" "start_update"
] ]
}, },
@@ -35,7 +40,8 @@
"/var/mosdns/*": [ "write" ] "/var/mosdns/*": [ "write" ]
}, },
"ubus": { "ubus": {
"file": [ "write" ] "file": [ "write" ],
"luci.mosdns": [ "clear_query_logs" ]
}, },
"uci": [ "mosdns" ] "uci": [ "mosdns" ]
} }
@@ -58,16 +58,85 @@ function get_logfile_path_internal() {
} }
} }
function parse_json_safe(str) {
if (!str || str == "")
return null;
try {
return json(str);
} catch(e) {
return null;
}
}
function call_mosdns_api(endpoint, method) {
try {
let uci_cursor = cursor();
uci_cursor.load('mosdns');
let port = uci_cursor.get('mosdns', 'config', 'listen_port_api') || '9091';
let http_method = method ? method : 'GET';
let post_flag = (http_method === 'POST') ? '--post-data=""' : '';
let cmd = `wget -q -O - ${post_flag} "http://127.0.0.1:${port}/plugins/stats_collector${endpoint}"`;
let res = exec_sys(cmd);
if (res.code === 0 && res.stdout != "") {
let json_val = parse_json_safe(res.stdout);
if (json_val) return json_val;
}
return { error: "MosDNS API unreachable" };
} catch (e) {
return { error: String(e) };
}
}
const methods = { const methods = {
get_stats: {
call: function() {
return call_mosdns_api('/api/v1/stats', 'GET');
}
},
get_history: {
args: { points: 24 },
call: function(request) {
let points = request?.args?.points || 24;
return call_mosdns_api(`/api/v1/history?points=${points}`, 'GET');
}
},
get_top: {
args: { limit: 10 },
call: function(request) {
let limit = request?.args?.limit || 10;
return call_mosdns_api(`/api/v1/top?limit=${limit}`, 'GET');
}
},
get_logs: {
args: { limit: 50, offset: 0, search: '', filter: 'all' },
call: function(request) {
let limit = request?.args?.limit || 50;
let offset = request?.args?.offset || 0;
let search = request?.args?.search || '';
let filter = request?.args?.filter || 'all';
return call_mosdns_api(`/api/v1/logs?limit=${limit}&offset=${offset}&search=${search}&filter=${filter}`, 'GET');
}
},
clear_query_logs: {
call: function() {
return call_mosdns_api('/api/v1/logs/clear', 'POST');
}
},
flush_cache: { flush_cache: {
call: function() { call: function() {
try { try {
let uci_cursor = cursor(); let uci_cursor = cursor();
uci_cursor.load('mosdns'); uci_cursor.load('mosdns');
let port = uci_cursor.get('mosdns', 'config', 'listen_port_api'); let port = uci_cursor.get('mosdns', 'config', 'listen_port_api') || '9091';
if (!port) return { error: "API listen port not configured." }; if (!port) return { error: "API listen port not configured." };
let res = exec_sys(`curl -s 127.0.0.1:${port}/plugins/lazy_cache/flush`); let res = exec_sys(`wget -q -O - "http://127.0.0.1:${port}/plugins/lazy_cache/flush"`);
if (res.code === 0) { if (res.code === 0) {
return { success: true }; return { success: true };
} else { } else {
+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.8.26 PKG_VERSION:=26.8.26
PKG_RELEASE:=252 PKG_RELEASE:=253
PKG_PO_VERSION:=$(PKG_VERSION) PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \ PKG_CONFIG_DEPENDS:= \
@@ -7,7 +7,10 @@ local gfwlist_path = path .. "gfwlist"
local chnlist_path = path .. "chnlist" local chnlist_path = path .. "chnlist"
local chnroute_path = path .. "chnroute" local chnroute_path = path .. "chnroute"
m = Map(api.appname) api.set_default_cbi()
m = Map()
m.apply_on_parse = true
function clean_text(text) function clean_text(text)
local nbsp = string.char(0xC2, 0xA0) -- 不间断空格(U+00A0 local nbsp = string.char(0xC2, 0xA0) -- 不间断空格(U+00A0
@@ -314,7 +317,7 @@ if fs.access(chnroute_path) then
]], translate("Read List")) ]], translate("Read List"))
end end
m:append(Template(api.appname .. "/rule_list/js")) m:appendTemplate("/rule_list/js")
local geo_dir = (api.uci_get_c("@global_rules[0]", "v2ray_location_asset") or "/usr/share/v2ray/"):match("^(.*)/") local geo_dir = (api.uci_get_c("@global_rules[0]", "v2ray_location_asset") or "/usr/share/v2ray/"):match("^(.*)/")
local geosite_path = geo_dir .. "/geosite.dat" local geosite_path = geo_dir .. "/geosite.dat"
@@ -324,7 +327,7 @@ if api.finded_com("geoview") and fs.access(geosite_path) and fs.access(geoip_pat
s:tab("geoview", translate("Geo View")) s:tab("geoview", translate("Geo View"))
o = s:taboption("geoview", DummyValue, "_geoview_fieldset") o = s:taboption("geoview", DummyValue, "_geoview_fieldset")
o.rawhtml = true o.rawhtml = true
o.template = api.appname .. "/rule_list/geoview" o.template = m:template_path("/rule_list/geoview")
end end
end end
@@ -332,4 +335,4 @@ m.on_before_save = function(self)
m:set("@global[0]", "flush_set", "1") m:set("@global[0]", "flush_set", "1")
end end
return m return api.return_map(m)
@@ -2063,7 +2063,9 @@ function gen_config(var)
fakedns_dns_rule.query_type = { "A", "AAAA" } fakedns_dns_rule.query_type = { "A", "AAAA" }
end end
fakedns_dns_rule.server = fakedns_tag fakedns_dns_rule.server = fakedns_tag
fakedns_dns_rule.rewrite_ttl = 1
fakedns_dns_rule.disable_cache = true fakedns_dns_rule.disable_cache = true
fakedns_dns_rule.client_subnet = nil
table.insert(dns.rules, fakedns_dns_rule) table.insert(dns.rules, fakedns_dns_rule)
end end
end end
@@ -2098,7 +2100,7 @@ function gen_config(var)
query_type = dns_rule_query_type, query_type = dns_rule_query_type,
server = fakedns_tag, server = fakedns_tag,
disable_cache = true, disable_cache = true,
rewrite_ttl = 30 rewrite_ttl = 1
} }
table.insert(dns.rules, fakedns_dns_rule) table.insert(dns.rules, fakedns_dns_rule)
end end
@@ -23,8 +23,8 @@ local map = self.map
<% if luci.http.formvalue("cbi.apply") == "1" and map.is_js_luci then -%> <% if luci.http.formvalue("cbi.apply") == "1" and map.is_js_luci then -%>
<script type="text/javascript"> <script type="text/javascript">
document.addEventListener("luci-loaded", function() { document.addEventListener("luci-loaded", function() {
L.env.apply_display = 2; //Write whatever you want, it's all just a fake progress bar anyway. //L.env.apply_display = 2; //Write whatever you want, it's all just a fake progress bar anyway.
L.env.apply_holdoff = 1; //L.env.apply_holdoff = 1;
let tt; let tt;
let flag = 2; let flag = 2;
@@ -50,10 +50,12 @@ local map = self.map
} }
window.setTimeout(() => { window.setTimeout(() => {
<% if not map.redirect then -%> if (tt)
window.clearTimeout(tt); window.clearTimeout(tt);
L.ui.changes.displayStatus(false); L.ui.changes.displayStatus(false);
window.location = window.location.href.split('#')[0]; <% if not map.redirect then -%>
//window.location = window.location.href.split('#')[0];
window.location = "<%=map.api.url("log")%>";
<%- else %> <%- else %>
window.location.href = "<%=map.redirect%>"; window.location.href = "<%=map.redirect%>";
<%- end %> <%- end %>
@@ -1,5 +1,6 @@
<% <%
local api = require "luci.passwall.api" local map = self.map
local api = map.api
-%> -%>
<style> <style>
@@ -1,5 +1,6 @@
<% <%
local api = require "luci.passwall.api" local map = self.map
local api = map.api
-%> -%>
<script type="text/javascript"> <script type="text/javascript">
//<![CDATA[ //<![CDATA[
+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.8.27 PKG_VERSION:=26.8.27
PKG_RELEASE:=104 PKG_RELEASE:=105
PKG_PO_VERSION:=$(PKG_VERSION) PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \ PKG_CONFIG_DEPENDS:= \
@@ -685,6 +685,8 @@ function rollback_rules()
local geo_dir = (uci_get("@global_rules[0]", "v2ray_location_asset") or "/usr/share/v2ray/") local geo_dir = (uci_get("@global_rules[0]", "v2ray_location_asset") or "/usr/share/v2ray/")
fs.move(bak_dir .. arg_type .. ".dat", geo_dir .. arg_type .. ".dat") fs.move(bak_dir .. arg_type .. ".dat", geo_dir .. arg_type .. ".dat")
fs.rmdir(bak_dir) fs.rmdir(bak_dir)
uci_set("@global[0]", "flush_set", "1")
uci_save(true)
http_write_json_ok() http_write_json_ok()
end end
@@ -164,6 +164,22 @@ node_socks_bind_local:depends({ node = "", ["!reverse"] = true })
s:tab("DNS", translate("DNS")) s:tab("DNS", translate("DNS"))
o = s:taboption("DNS", ListValue, "direct_dns_protocol", translate("Direct DNS Protocol"))
o:value("", translate("Auto"))
--o:value("tcp", "TCP")
o:value("udp", "UDP")
o = s:taboption("DNS", Value, "direct_dns", translate("Direct DNS"))
o.datatype = "or(ipaddr,ipaddrport(1))"
o.default = "223.5.5.5"
o:value("223.5.5.5")
o:value("223.6.6.6")
o:value("114.114.114.114")
o:value("119.29.29.29")
o:value("180.76.76.76")
o:depends("direct_dns_protocol", "tcp")
o:depends("direct_dns_protocol", "udp")
o = s:taboption("DNS", ListValue, "direct_dns_query_strategy", translate("Direct Query Strategy")) o = s:taboption("DNS", ListValue, "direct_dns_query_strategy", translate("Direct Query Strategy"))
o.default = "UseIP" o.default = "UseIP"
o:value("UseIP") o:value("UseIP")
@@ -419,6 +419,10 @@ if singbox_tags:find("with_quic") then
o.default = { "stun.sip.us:3478", "stun.nextcloud.com:3478", "global.stun.twilio.com:3478" } o.default = { "stun.sip.us:3478", "stun.nextcloud.com:3478", "global.stun.twilio.com:3478" }
o:depends({ hysteria2_realms = "1" }) o:depends({ hysteria2_realms = "1" })
o = s:option(Flag, "hysteria2_realm_upnp", translate("Enable") .. " UPnP/NAT-PMP", translate("Enable UPnP/NAT-PMP port mapping on your gateway to improve hole punching success."))
o.default = "0"
o:depends({ hysteria2_realms = "1" })
o = s:option(Value, "hysteria2_auth_password", translate("Auth Password")) o = s:option(Value, "hysteria2_auth_password", translate("Auth Password"))
o.password = true o.password = true
o:depends({ protocol = "hysteria2"}) o:depends({ protocol = "hysteria2"})
@@ -355,6 +355,10 @@ o = s:option(DynamicList, "hysteria2_realm_stun", translate("Realm STUN"))
o.default = { "stun.sip.us:3478", "stun.nextcloud.com:3478", "global.stun.twilio.com:3478" } o.default = { "stun.sip.us:3478", "stun.nextcloud.com:3478", "global.stun.twilio.com:3478" }
o:depends({ hysteria2_realms = "1" }) o:depends({ hysteria2_realms = "1" })
o = s:option(Flag, "hysteria2_realm_upnp", translate("Enable") .. " UPnP/NAT-PMP", translate("Enable UPnP/NAT-PMP port mapping on your gateway to improve hole punching success."))
o.default = "0"
o:depends({ hysteria2_realms = "1" })
o = s:option(Value, "hysteria2_auth_password", translate("Auth Password")) o = s:option(Value, "hysteria2_auth_password", translate("Auth Password"))
o.password = true o.password = true
o:depends({ protocol = "hysteria2"}) o:depends({ protocol = "hysteria2"})
@@ -183,6 +183,10 @@ if singbox_tags:find("with_quic") then
o.default = { "stun.sip.us:3478", "stun.nextcloud.com:3478", "global.stun.twilio.com:3478" } o.default = { "stun.sip.us:3478", "stun.nextcloud.com:3478", "global.stun.twilio.com:3478" }
o:depends({ hysteria2_realms = "1" }) o:depends({ hysteria2_realms = "1" })
o = s:option(Flag, "hysteria2_realm_upnp", translate("Enable") .. " UPnP/NAT-PMP", translate("Enable UPnP/NAT-PMP port mapping on your gateway to improve hole punching success."))
o.default = "0"
o:depends({ hysteria2_realms = "1" })
o = s:option(ListValue, "hysteria2_obfs_type", translate("Obfs Type")) o = s:option(ListValue, "hysteria2_obfs_type", translate("Obfs Type"))
o:value("", translate("Disable")) o:value("", translate("Disable"))
o:value("salamander") o:value("salamander")
@@ -148,6 +148,10 @@ o = s:option(DynamicList, "hysteria2_realm_stun", translate("Realm STUN"))
o.default = { "stun.sip.us:3478", "stun.nextcloud.com:3478", "global.stun.twilio.com:3478" } o.default = { "stun.sip.us:3478", "stun.nextcloud.com:3478", "global.stun.twilio.com:3478" }
o:depends({ hysteria2_realms = "1" }) o:depends({ hysteria2_realms = "1" })
o = s:option(Flag, "hysteria2_realm_upnp", translate("Enable") .. " UPnP/NAT-PMP", translate("Enable UPnP/NAT-PMP port mapping on your gateway to improve hole punching success."))
o.default = "0"
o:depends({ hysteria2_realms = "1" })
o = s:option(ListValue, "hysteria2_obfs_type", translate("Obfs Type")) o = s:option(ListValue, "hysteria2_obfs_type", translate("Obfs Type"))
o:value("", translate("Disable")) o:value("", translate("Disable"))
o:value("salamander") o:value("salamander")
@@ -68,13 +68,6 @@ local function convert_geofile()
end end
local function convert(file_path, prefix, tags) local function convert(file_path, prefix, tags)
if next(tags) and fs.access(file_path) then if next(tags) and fs.access(file_path) then
local md5_file = GEO_VAR.TO_SRS_PATH .. prefix .. ".dat.md5"
local new_md5 = sys.exec("md5sum " .. file_path .. " 2>/dev/null | awk '{print $1}'"):gsub("\n", "")
local old_md5 = sys.exec("[ -f " .. md5_file .. " ] && head -n 1 " .. md5_file .. " | tr -d ' \t\n' || echo ''")
if new_md5 ~= "" and new_md5 ~= old_md5 then
sys.call("printf '%s' " .. new_md5 .. " > " .. md5_file)
sys.call("rm -rf " .. GEO_VAR.TO_SRS_PATH .. prefix .. "-*.srs" )
end
for k in pairs(tags) do for k in pairs(tags) do
geo_convert_srs({ geo_convert_srs({
["geo_path"] = file_path, ["geo_path"] = file_path,
@@ -624,6 +617,7 @@ function gen_outbound(flag, node, tag, proxy_table)
realm.scheme = nil realm.scheme = nil
realm.address = nil realm.address = nil
realm.port = nil realm.port = nil
realm.port_mapping = (node.hysteria2_realm_upnp == "1") and { enabled = true } or nil
return realm return realm
end end
return nil return nil
@@ -980,6 +974,7 @@ function gen_config_server(node)
realm.address = nil realm.address = nil
realm.port = nil realm.port = nil
realm.stun_domain_resolver = "direct" realm.stun_domain_resolver = "direct"
realm.port_mapping = (node.hysteria2_realm_upnp == "1") and { enabled = true } or nil
return realm return realm
end end
return nil return nil
@@ -1122,6 +1117,8 @@ function gen_config(var)
local dns_listen_port = var["dns_listen_port"] local dns_listen_port = var["dns_listen_port"]
local direct_dns_udp_server = var["direct_dns_udp_server"] local direct_dns_udp_server = var["direct_dns_udp_server"]
local direct_dns_udp_port = var["direct_dns_udp_port"] local direct_dns_udp_port = var["direct_dns_udp_port"]
local direct_dns_tcp_server = var["direct_dns_tcp_server"]
local direct_dns_tcp_port = var["direct_dns_tcp_port"]
local direct_dns_query_strategy = var["direct_dns_query_strategy"] local direct_dns_query_strategy = var["direct_dns_query_strategy"]
local direct_ipset = var["direct_ipset"] local direct_ipset = var["direct_ipset"]
local direct_nftset = var["direct_nftset"] local direct_nftset = var["direct_nftset"]
@@ -1834,10 +1831,18 @@ function gen_config(var)
server_port = tonumber(direct_dns_udp_port) or 53, server_port = tonumber(direct_dns_udp_port) or 53,
detour = "direct", detour = "direct",
}) })
elseif direct_dns_tcp_server then
table.insert(dns.servers, {
tag = "direct",
type = "tcp",
server = direct_dns_tcp_server,
server_port = tonumber(direct_dns_tcp_port) or 53,
detour = "direct",
})
end end
for i, v in pairs(GLOBAL.DNS_SERVER) do for i, v in pairs(GLOBAL.DNS_SERVER) do
if direct_dns_udp_server then if direct_dns_udp_server or direct_dns_tcp_server then
v.server.domain_resolver = "direct" v.server.domain_resolver = "direct"
end end
table.insert(dns.servers, v.server) table.insert(dns.servers, v.server)
@@ -1902,7 +1907,7 @@ function gen_config(var)
end end
end end
if direct_dns_udp_server then if direct_dns_udp_server or direct_dns_tcp_server then
local nodes_domain = {} local nodes_domain = {}
local nodes_domain_text = sys.exec('uci show passwall2 | grep ".address=" | cut -d "\'" -f 2 | grep "[a-zA-Z]$" | sort -u') local nodes_domain_text = sys.exec('uci show passwall2 | grep ".address=" | cut -d "\'" -f 2 | grep "[a-zA-Z]$" | sort -u')
string.gsub(nodes_domain_text, '[^' .. "\r\n" .. ']+', function(w) string.gsub(nodes_domain_text, '[^' .. "\r\n" .. ']+', function(w)
@@ -2089,6 +2094,7 @@ function gen_config(var)
fakedns_dns_rule.query_type = { "A", "AAAA" } fakedns_dns_rule.query_type = { "A", "AAAA" }
end end
fakedns_dns_rule.server = fakedns_tag fakedns_dns_rule.server = fakedns_tag
fakedns_dns_rule.rewrite_ttl = 1
fakedns_dns_rule.disable_cache = true fakedns_dns_rule.disable_cache = true
fakedns_dns_rule.client_subnet = nil fakedns_dns_rule.client_subnet = nil
table.insert(dns.rules, fakedns_dns_rule) table.insert(dns.rules, fakedns_dns_rule)
@@ -2125,7 +2131,7 @@ function gen_config(var)
query_type = dns_rule_query_type, query_type = dns_rule_query_type,
server = fakedns_tag, server = fakedns_tag,
disable_cache = true, disable_cache = true,
rewrite_ttl = tonumber(remote_rewrite_ttl) rewrite_ttl = 1
} }
table.insert(dns.rules, fakedns_dns_rule) table.insert(dns.rules, fakedns_dns_rule)
end end
@@ -318,7 +318,8 @@ function gen_outbound(flag, node, tag, proxy_table)
type = "realm", type = "realm",
settings = { settings = {
url = url, url = url,
stunServers = stun stunServers = stun,
portMapping = (node.hysteria2_realm_upnp == "1") and { enabled = true } or nil
} }
} }
udp[#udp+1] = r udp[#udp+1] = r
@@ -788,7 +789,8 @@ function gen_config_server(node)
type = "realm", type = "realm",
settings = { settings = {
url = url, url = url,
stunServers = stun stunServers = stun,
portMapping = (node.hysteria2_realm_upnp == "1") and { enabled = true } or nil
} }
} }
udp[#udp+1] = r udp[#udp+1] = r
@@ -887,6 +889,8 @@ function gen_config(var)
local dns_listen_port = var["dns_listen_port"] local dns_listen_port = var["dns_listen_port"]
local direct_dns_udp_server = var["direct_dns_udp_server"] local direct_dns_udp_server = var["direct_dns_udp_server"]
local direct_dns_udp_port = var["direct_dns_udp_port"] local direct_dns_udp_port = var["direct_dns_udp_port"]
local direct_dns_tcp_server = var["direct_dns_tcp_server"]
local direct_dns_tcp_port = var["direct_dns_tcp_port"]
local direct_dns_query_strategy = var["direct_dns_query_strategy"] local direct_dns_query_strategy = var["direct_dns_query_strategy"]
local direct_ipset = var["direct_ipset"] local direct_ipset = var["direct_ipset"]
local direct_nftset = var["direct_nftset"] local direct_nftset = var["direct_nftset"]
@@ -1560,13 +1564,24 @@ function gen_config(var)
port = tonumber(direct_dns_udp_port) or 53, port = tonumber(direct_dns_udp_port) or 53,
queryStrategy = (direct_dns_query_strategy and direct_dns_query_strategy ~= "") and direct_dns_query_strategy or "UseIP" queryStrategy = (direct_dns_query_strategy and direct_dns_query_strategy ~= "") and direct_dns_query_strategy or "UseIP"
} }
table.insert(dns_servers, {
if _direct_dns.address then outboundTag = "direct",
table.insert(dns_servers, { server = _direct_dns
outboundTag = "direct", })
server = _direct_dns elseif direct_dns_tcp_server then
}) if api.is_ipv6(direct_dns_tcp_server) then
direct_dns_tcp_server = api.get_ipv6_full(direct_dns_tcp_server)
end end
_direct_dns = {
tag = direct_dns_tag,
address = "tcp://" .. direct_dns_tcp_server .. ":" .. tonumber(direct_dns_tcp_port) or 53,
port = tonumber(direct_dns_tcp_port) or 53,
queryStrategy = (direct_dns_query_strategy and direct_dns_query_strategy ~= "") and direct_dns_query_strategy or "UseIP"
}
table.insert(dns_servers, {
outboundTag = "direct",
server = _direct_dns
})
end end
if next(GLOBAL.DNS_HOSTNAME) then if next(GLOBAL.DNS_HOSTNAME) then
@@ -1687,7 +1702,7 @@ function gen_config(var)
}) })
end end
if direct_dns_udp_server then if direct_dns_udp_server or direct_dns_tcp_server then
local domain = {} local domain = {}
local nodes_domain_text = sys.exec('uci show passwall2 | grep ".address=" | cut -d "\'" -f 2 | grep "[a-zA-Z]$" | sort -u') local nodes_domain_text = sys.exec('uci show passwall2 | grep ".address=" | cut -d "\'" -f 2 | grep "[a-zA-Z]$" | sort -u')
string.gsub(nodes_domain_text, '[^' .. "\r\n" .. ']+', function(w) string.gsub(nodes_domain_text, '[^' .. "\r\n" .. ']+', function(w)
@@ -23,8 +23,8 @@ local map = self.map
<% if luci.http.formvalue("cbi.apply") == "1" and map.is_js_luci then -%> <% if luci.http.formvalue("cbi.apply") == "1" and map.is_js_luci then -%>
<script type="text/javascript"> <script type="text/javascript">
document.addEventListener("luci-loaded", function() { document.addEventListener("luci-loaded", function() {
L.env.apply_display = 2; //Write whatever you want, it's all just a fake progress bar anyway. //L.env.apply_display = 2; //Write whatever you want, it's all just a fake progress bar anyway.
L.env.apply_holdoff = 1; //L.env.apply_holdoff = 1;
let tt; let tt;
let flag = 2; let flag = 2;
@@ -50,10 +50,12 @@ local map = self.map
} }
window.setTimeout(() => { window.setTimeout(() => {
<% if not map.redirect then -%> if (tt)
window.clearTimeout(tt); window.clearTimeout(tt);
L.ui.changes.displayStatus(false); L.ui.changes.displayStatus(false);
window.location = window.location.href.split('#')[0]; <% if not map.redirect then -%>
//window.location = window.location.href.split('#')[0];
window.location = "<%=map.api.url("log")%>";
<%- else %> <%- else %>
window.location.href = "<%=map.redirect%>"; window.location.href = "<%=map.redirect%>";
<%- end %> <%- end %>
+3
View File
@@ -2420,6 +2420,9 @@ msgstr "اندازه بسته Gecko (دقیقه)"
msgid "Gecko Packet Size (max)" msgid "Gecko Packet Size (max)"
msgstr "اندازه بسته Gecko (حداکثر)" msgstr "اندازه بسته Gecko (حداکثر)"
msgid "Enable UPnP/NAT-PMP port mapping on your gateway to improve hole punching success."
msgstr "برای بهبود موفقیت در پانچ کردن حفره، نگاشت پورت UPnP/NAT-PMP را روی گیت‌وی خود فعال کنید."
msgid "valid time (hh:mm)" msgid "valid time (hh:mm)"
msgstr "زمان معتبر (ساعت:میلی‌متر)" msgstr "زمان معتبر (ساعت:میلی‌متر)"
+3
View File
@@ -2418,6 +2418,9 @@ msgstr "Размер упаковки Gecko (мин)"
msgid "Gecko Packet Size (max)" msgid "Gecko Packet Size (max)"
msgstr "Размер упаковки Gecko (макс)" msgstr "Размер упаковки Gecko (макс)"
msgid "Enable UPnP/NAT-PMP port mapping on your gateway to improve hole punching success."
msgstr "Включите сопоставление портов UPnP/NAT-PMP на вашем шлюзе, чтобы повысить вероятность успешного выполнения операций "пробивания дыр"."
msgid "valid time (hh:mm)" msgid "valid time (hh:mm)"
msgstr "Время действия (чч:мм)" msgstr "Время действия (чч:мм)"
+3
View File
@@ -2406,6 +2406,9 @@ msgstr "Gecko 包大小(最小)"
msgid "Gecko Packet Size (max)" msgid "Gecko Packet Size (max)"
msgstr "Gecko 包大小(最大)" msgstr "Gecko 包大小(最大)"
msgid "Enable UPnP/NAT-PMP port mapping on your gateway to improve hole punching success."
msgstr "在网关上启用 UPnP/NAT-PMP 端口映射以提升打洞成功率。"
msgid "valid time (hh:mm)" msgid "valid time (hh:mm)"
msgstr "有效时间(hh:mm" msgstr "有效时间(hh:mm"
+3
View File
@@ -2412,6 +2412,9 @@ msgstr "Gecko 包大小(最小)"
msgid "Gecko Packet Size (max)" msgid "Gecko Packet Size (max)"
msgstr "Gecko 包大小(最大)" msgstr "Gecko 包大小(最大)"
msgid "Enable UPnP/NAT-PMP port mapping on your gateway to improve hole punching success."
msgstr "在網關上啟用 UPnP/NAT-PMP 連接埠對映以提升打洞成功率。"
msgid "valid time (hh:mm)" msgid "valid time (hh:mm)"
msgstr "有效時間(hh:mm" msgstr "有效時間(hh:mm"
@@ -99,8 +99,11 @@ run_xray() {
json_add_string "local_http_password" "${http_password}" json_add_string "local_http_password" "${http_password}"
} }
} }
local direct_dns_proto=${DIRECT_DNS_PROTO}
local direct_dns_server=${DIRECT_DNS_SERVER}
local direct_dns_port=${DIRECT_DNS_PORT}
[ -n "$dns_listen_port" ] && { [ -n "$dns_listen_port" ] && {
local dns_msg="DNS[${dns_listen_port}]:($(i18n "Direct DNS: %s" "${AUTO_DNS}")" local dns_msg="DNS[${dns_listen_port}]:($(i18n "Direct DNS: %s" "${direct_dns_proto}://${direct_dns_server}:${direct_dns_port}")"
json_add_string "dns_listen_port" "${dns_listen_port}" json_add_string "dns_listen_port" "${dns_listen_port}"
[ -n "$dns_cache" ] && json_add_string "dns_cache" "${dns_cache}" [ -n "$dns_cache" ] && json_add_string "dns_cache" "${dns_cache}"
[ "${node_protocol}" = "_shunt" ] && local write_ipset_direct=$(config_n_get $node write_ipset_direct 0) [ "${node_protocol}" = "_shunt" ] && local write_ipset_direct=$(config_n_get $node write_ipset_direct 0)
@@ -116,9 +119,10 @@ run_xray() {
local direct_ipset6="psw2_${node}_white6" local direct_ipset6="psw2_${node}_white6"
local direct_ipset="${direct_ipset4},${direct_ipset6}" local direct_ipset="${direct_ipset4},${direct_ipset6}"
fi fi
run_ipset_dns_server listen_port=${direct_dnsmasq_listen_port} server_dns=${AUTO_DNS} ipset="${direct_ipset}" nftset="${direct_nftset}" config_file=${direct_ipset_conf} run_ipset_dns_server listen_port=${direct_dnsmasq_listen_port} proto=${direct_dns_proto} server_dns="${direct_dns_server}#${direct_dns_port}" ipset="${direct_ipset}" nftset="${direct_nftset}" config_file=${direct_ipset_conf}
DIRECT_DNS_UDP_PORT=${direct_dnsmasq_listen_port} direct_dns_proto="udp"
DIRECT_DNS_UDP_SERVER="127.0.0.1" direct_dns_server="127.0.0.1"
direct_dns_port=${direct_dnsmasq_listen_port}
[ -n "${direct_ipset}" ] && { [ -n "${direct_ipset}" ] && {
json_add_string "direct_ipset" "${direct_ipset}" json_add_string "direct_ipset" "${direct_ipset}"
set_cache_var "node_${node}_direct_ipset4" "${direct_ipset4}" set_cache_var "node_${node}_direct_ipset4" "${direct_ipset4}"
@@ -168,8 +172,8 @@ run_xray() {
[ -n "$remote_dns_client_ip" ] && json_add_string "remote_dns_client_ip" "${remote_dns_client_ip}" [ -n "$remote_dns_client_ip" ] && json_add_string "remote_dns_client_ip" "${remote_dns_client_ip}"
log_out="${dns_msg})" log_out="${dns_msg})"
} }
json_add_string "direct_dns_udp_port" "${DIRECT_DNS_UDP_PORT}" json_add_string "direct_dns_${direct_dns_proto}_server" "${direct_dns_server}"
json_add_string "direct_dns_udp_server" "${DIRECT_DNS_UDP_SERVER}" json_add_string "direct_dns_${direct_dns_proto}_port" "${direct_dns_port}"
json_add_string "direct_dns_query_strategy" "${direct_dns_query_strategy}" json_add_string "direct_dns_query_strategy" "${direct_dns_query_strategy}"
[ -n "${redir_port}" ] && { [ -n "${redir_port}" ] && {
@@ -241,8 +245,11 @@ run_singbox() {
json_add_string "local_http_password" "${http_password}" json_add_string "local_http_password" "${http_password}"
} }
} }
local direct_dns_proto=${DIRECT_DNS_PROTO}
local direct_dns_server=${DIRECT_DNS_SERVER}
local direct_dns_port=${DIRECT_DNS_PORT}
[ -n "$dns_listen_port" ] && { [ -n "$dns_listen_port" ] && {
local dns_msg="DNS[${dns_listen_port}]:($(i18n "Direct DNS: %s" "${AUTO_DNS}")" local dns_msg="DNS[${dns_listen_port}]:($(i18n "Direct DNS: %s" "${direct_dns_proto}://${direct_dns_server}:${direct_dns_port}")"
json_add_string "dns_listen_port" "${dns_listen_port}" json_add_string "dns_listen_port" "${dns_listen_port}"
[ -n "$dns_cache" ] && json_add_string "dns_cache" "${dns_cache}" [ -n "$dns_cache" ] && json_add_string "dns_cache" "${dns_cache}"
[ "${node_protocol}" = "_shunt" ] && local write_ipset_direct=$(config_n_get $node write_ipset_direct 0) [ "${node_protocol}" = "_shunt" ] && local write_ipset_direct=$(config_n_get $node write_ipset_direct 0)
@@ -258,9 +265,10 @@ run_singbox() {
local direct_ipset6="psw2_${node}_white6" local direct_ipset6="psw2_${node}_white6"
local direct_ipset="${direct_ipset4},${direct_ipset6}" local direct_ipset="${direct_ipset4},${direct_ipset6}"
fi fi
run_ipset_dns_server listen_port=${direct_dnsmasq_listen_port} server_dns=${AUTO_DNS} ipset="${direct_ipset}" nftset="${direct_nftset}" config_file=${direct_ipset_conf} run_ipset_dns_server listen_port=${direct_dnsmasq_listen_port} proto=${direct_dns_proto} server_dns="${direct_dns_server}#${direct_dns_port}" ipset="${direct_ipset}" nftset="${direct_nftset}" config_file=${direct_ipset_conf}
DIRECT_DNS_UDP_PORT=${direct_dnsmasq_listen_port} direct_dns_proto="udp"
DIRECT_DNS_UDP_SERVER="127.0.0.1" direct_dns_server="127.0.0.1"
direct_dns_port=${direct_dnsmasq_listen_port}
[ -n "${direct_ipset}" ] && { [ -n "${direct_ipset}" ] && {
json_add_string "direct_ipset" "${direct_ipset}" json_add_string "direct_ipset" "${direct_ipset}"
set_cache_var "node_${node}_direct_ipset4" "${direct_ipset4}" set_cache_var "node_${node}_direct_ipset4" "${direct_ipset4}"
@@ -318,8 +326,8 @@ run_singbox() {
[ -n "$remote_rewrite_ttl" ] && json_add_string "remote_rewrite_ttl" "${remote_rewrite_ttl}" [ -n "$remote_rewrite_ttl" ] && json_add_string "remote_rewrite_ttl" "${remote_rewrite_ttl}"
log_out="${dns_msg})" log_out="${dns_msg})"
} }
json_add_string "direct_dns_udp_port" "${DIRECT_DNS_UDP_PORT}" json_add_string "direct_dns_${direct_dns_proto}_server" "${direct_dns_server}"
json_add_string "direct_dns_udp_server" "${DIRECT_DNS_UDP_SERVER}" json_add_string "direct_dns_${direct_dns_proto}_port" "${direct_dns_port}"
json_add_string "direct_dns_query_strategy" "${direct_dns_query_strategy}" json_add_string "direct_dns_query_strategy" "${direct_dns_query_strategy}"
[ -n "${redir_port}" ] && { [ -n "${redir_port}" ] && {
@@ -423,8 +431,8 @@ run_socks() {
json_add_string "flag" "${flag}" json_add_string "flag" "${flag}"
json_add_string "local_socks_address" "${bind}" json_add_string "local_socks_address" "${bind}"
json_add_string "local_socks_port" "${socks_port}" json_add_string "local_socks_port" "${socks_port}"
json_add_string "direct_dns_udp_port" "${DIRECT_DNS_UDP_PORT}" json_add_string "direct_dns_${DIRECT_DNS_PROTO}_server" "${DIRECT_DNS_SERVER}"
json_add_string "direct_dns_udp_server" "${DIRECT_DNS_UDP_SERVER}" json_add_string "direct_dns_${DIRECT_DNS_PROTO}_port" "${DIRECT_DNS_PORT}"
json_add_string "direct_dns_query_strategy" "${DIRECT_DNS_QUERY_STRATEGY}" json_add_string "direct_dns_query_strategy" "${DIRECT_DNS_QUERY_STRATEGY}"
local _json_arg="$(json_dump)" local _json_arg="$(json_dump)"
lua $UTIL_SINGBOX gen_config "${_json_arg}" > $config_file lua $UTIL_SINGBOX gen_config "${_json_arg}" > $config_file
@@ -450,8 +458,8 @@ run_socks() {
json_add_string "flag" "${flag}" json_add_string "flag" "${flag}"
json_add_string "local_socks_address" "${bind}" json_add_string "local_socks_address" "${bind}"
json_add_string "local_socks_port" "${socks_port}" json_add_string "local_socks_port" "${socks_port}"
json_add_string "direct_dns_udp_port" "${DIRECT_DNS_UDP_PORT}" json_add_string "direct_dns_${DIRECT_DNS_PROTO}_server" "${DIRECT_DNS_SERVER}"
json_add_string "direct_dns_udp_server" "${DIRECT_DNS_UDP_SERVER}" json_add_string "direct_dns_${DIRECT_DNS_PROTO}_port" "${DIRECT_DNS_PORT}"
json_add_string "direct_dns_query_strategy" "${DIRECT_DNS_QUERY_STRATEGY}" json_add_string "direct_dns_query_strategy" "${DIRECT_DNS_QUERY_STRATEGY}"
local _json_arg="$(json_dump)" local _json_arg="$(json_dump)"
lua $UTIL_XRAY gen_config "${_json_arg}" > $config_file lua $UTIL_XRAY gen_config "${_json_arg}" > $config_file
@@ -759,7 +767,7 @@ run_ipset_dns_server() {
} }
run_ipset_chinadns_ng() { run_ipset_chinadns_ng() {
local listen_port server_dns ipset nftset config_file local listen_port proto server_dns ipset nftset config_file
eval_set_val $@ eval_set_val $@
[ ! -s "$TMP_ACL_PATH/vpslist" ] && { [ ! -s "$TMP_ACL_PATH/vpslist" ] && {
node_servers=$(uci show "${CONFIG}" | grep -E "(.address=|.download_address=)" | cut -d "'" -f 2) node_servers=$(uci show "${CONFIG}" | grep -E "(.address=|.download_address=)" | cut -d "'" -f 2)
@@ -777,14 +785,14 @@ run_ipset_chinadns_ng() {
cat <<-EOF > $config_file cat <<-EOF > $config_file
bind-addr 127.0.0.1 bind-addr 127.0.0.1
bind-port ${listen_port} bind-port ${listen_port}
china-dns ${server_dns} china-dns ${proto}://${server_dns}
trust-dns ${server_dns} trust-dns ${proto}://${server_dns}
filter-qtype 65 filter-qtype 65
add-tagchn-ip ${set_names} add-tagchn-ip ${set_names}
default-tag chn default-tag chn
group vpslist group vpslist
group-dnl $TMP_ACL_PATH/vpslist group-dnl $TMP_ACL_PATH/vpslist
group-upstream ${server_dns} group-upstream ${proto}://${server_dns}
group-ipset ${vps_set_names} group-ipset ${vps_set_names}
EOF EOF
ln_run 0 "$(first_type chinadns-ng)" "chinadns-ng" "/dev/null" -C $config_file -v ln_run 0 "$(first_type chinadns-ng)" "chinadns-ng" "/dev/null" -C $config_file -v
@@ -1004,13 +1012,27 @@ get_direct_dns() {
DEFAULT_DNS="${DNSMASQ_UPSTREAM_DNS}" DEFAULT_DNS="${DNSMASQ_UPSTREAM_DNS}"
[ -z "${DEFAULT_DNS}" ] && DEFAULT_DNS=$(echo -n $ISP_DNS | tr ' ' '\n' | head -2 | tr '\n' ',' | sed 's/,$//') [ -z "${DEFAULT_DNS}" ] && DEFAULT_DNS=$(echo -n $ISP_DNS | tr ' ' '\n' | head -2 | tr '\n' ',' | sed 's/,$//')
AUTO_DNS=${DEFAULT_DNS:-119.29.29.29} AUTO_DNS=${DEFAULT_DNS:-119.29.29.29}
RETURN_DNS=${AUTO_DNS}
local AUTO_DNS_1=$(echo ${AUTO_DNS} | awk -F ',' '{print $1}') local AUTO_DNS_1=$(echo ${AUTO_DNS} | awk -F ',' '{print $1}')
local AUTO_DNS_2=$(echo ${AUTO_DNS} | awk -F ',' '{print $2}') local AUTO_DNS_2=$(echo ${AUTO_DNS} | awk -F ',' '{print $2}')
local AUTO_DNS_ADDRESS=$(echo ${AUTO_DNS_1} | awk -F '#' '{print $1}')
local AUTO_DNS_PORT=$(echo ${AUTO_DNS_1} | awk -F '#' '{print $2}') DIRECT_DNS_PROTO="udp"
DIRECT_DNS_UDP_SERVER=${AUTO_DNS_ADDRESS} DIRECT_DNS_SERVER=$(echo ${AUTO_DNS_1} | awk -F '#' '{print $1}')
DIRECT_DNS_UDP_PORT=${AUTO_DNS_PORT} DIRECT_DNS_PORT=$(echo ${AUTO_DNS_1} | awk -F '#' '{print $2}')
DIRECT_DNS_PORT=${DIRECT_DNS_PORT:-53}
local direct_dns_protocol=$(config_n_get @global[0] direct_dns_protocol)
if [ "${direct_dns_protocol}" = "tcp" ] || [ "${direct_dns_protocol}" = "udp" ]; then
local DIRECT_DNS=$(config_n_get @global[0] direct_dns)
local result=$(lua_api "parseDNS(\"${DIRECT_DNS}\")")
[ "${result}" != "nil" ] && {
DIRECT_DNS_PROTO="${direct_dns_protocol}"
DIRECT_DNS_SERVER=$(echo ${result} | awk '{print $1}')
DIRECT_DNS_PORT=$(echo ${result} | awk '{print $2}')
RETURN_DNS="${RETURN_DNS},${DIRECT_DNS_SERVER}#${DIRECT_DNS_PORT}#${DIRECT_DNS_PROTO}"
}
fi
} }
get_config() { get_config() {
@@ -809,15 +809,17 @@ add_firewall_rule() {
$ip6t_m -A PSW2_OUTPUT $(dst $IPSET_VPS6) -j RETURN $ip6t_m -A PSW2_OUTPUT $(dst $IPSET_VPS6) -j RETURN
$ip6t_m -A PSW2_OUTPUT -m conntrack --ctdir REPLY -j RETURN $ip6t_m -A PSW2_OUTPUT -m conntrack --ctdir REPLY -j RETURN
[ -n "$AUTO_DNS" ] && { [ -n "$RETURN_DNS" ] && {
for auto_dns in $(echo $AUTO_DNS | tr ',' ' '); do for _dns in $(echo $RETURN_DNS | tr ',' ' '); do
local dns_address=$(echo $auto_dns | awk -F '#' '{print $1}') local dns_address=$(echo $_dns | awk -F '#' '{print $1}')
local dns_port=$(echo $auto_dns | awk -F '#' '{print $2}') local dns_port=$(echo $_dns | awk -F '#' '{print $2}')
local dns_proto=$(echo $_dns | awk -F '#' '{print $3}')
dns_proto=${dns_proto:-udp}
if [[ "$dns_address" == *::* ]]; then if [[ "$dns_address" == *::* ]]; then
$ip6t_m -I PSW2_OUTPUT -p udp -d ${dns_address} --dport ${dns_port:-53} -j RETURN $ip6t_m -I PSW2_OUTPUT -p ${dns_proto} -d ${dns_address} --dport ${dns_port:-53} -j RETURN
log_i18n 1 "$(i18n "Add direct DNS to %s: %s" "ip6tables" "[${dns_address}]:${dns_port:-53}")" log_i18n 1 "$(i18n "Add direct DNS to %s: %s" "ip6tables" "[${dns_address}]:${dns_port:-53}")"
else else
$ipt_m -I PSW2_OUTPUT -p udp -d ${dns_address} --dport ${dns_port:-53} -j RETURN $ipt_m -I PSW2_OUTPUT -p ${dns_proto} -d ${dns_address} --dport ${dns_port:-53} -j RETURN
log_i18n 1 "$(i18n "Add direct DNS to %s: %s" "iptables" "${dns_address}:${dns_port:-53}")" log_i18n 1 "$(i18n "Add direct DNS to %s: %s" "iptables" "${dns_address}:${dns_port:-53}")"
fi fi
done done
@@ -862,15 +862,17 @@ add_firewall_rule() {
nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE_V6 ct direction reply counter return" nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE_V6 ct direction reply counter return"
nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE_V6 meta mark 255 counter return" nft "add rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE_V6 meta mark 255 counter return"
[ -n "$AUTO_DNS" ] && { [ -n "$RETURN_DNS" ] && {
for auto_dns in $(echo $AUTO_DNS | tr ',' ' '); do for _dns in $(echo $RETURN_DNS | tr ',' ' '); do
local dns_address=$(echo $auto_dns | awk -F '#' '{print $1}') local dns_address=$(echo $_dns | awk -F '#' '{print $1}')
local dns_port=$(echo $auto_dns | awk -F '#' '{print $2}') local dns_port=$(echo $_dns | awk -F '#' '{print $2}')
local dns_proto=$(echo $_dns | awk -F '#' '{print $3}')
dns_proto=${dns_proto:-udp}
if [[ "$dns_address" == *::* ]]; then if [[ "$dns_address" == *::* ]]; then
nft "insert rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE_V6 meta l4proto udp ip6 daddr ${dns_address} $(factor ${dns_port:-53} "udp dport") counter return" nft "insert rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE_V6 meta l4proto ${dns_proto} ip6 daddr ${dns_address} $(factor ${dns_port:-53} "${dns_proto} dport") counter return"
log_i18n 1 "$(i18n "Add direct DNS to %s: %s" "nftables" "[${dns_address}]:${dns_port:-53}")" log_i18n 1 "$(i18n "Add direct DNS to %s: %s" "nftables" "[${dns_address}]:${dns_port:-53}")"
else else
nft "insert rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE ip protocol udp ip daddr ${dns_address} $(factor ${dns_port:-53} "udp dport") counter return" nft "insert rule $NFTABLE_NAME PSW2_OUTPUT_MANGLE ip protocol ${dns_proto} ip daddr ${dns_address} $(factor ${dns_port:-53} "${dns_proto} dport") counter return"
log_i18n 1 "$(i18n "Add direct DNS to %s: %s" "nftables" "${dns_address}:${dns_port:-53}")" log_i18n 1 "$(i18n "Add direct DNS to %s: %s" "nftables" "${dns_address}:${dns_port:-53}")"
fi fi
done done
+3 -3
View File
@@ -15,12 +15,12 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-wwand PKG_NAME:=luci-app-wwand
PKG_RELEASE:=7 PKG_RELEASE:=8
PKG_SOURCE_PROTO:=git PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/luci-app-wwand.git PKG_SOURCE_URL:=https://github.com/ddimension/luci-app-wwand.git
PKG_SOURCE_VERSION:=8dab3e346939ddc633f86e36b4b30365f62d74e0 PKG_SOURCE_VERSION:=0a1cb01913fc2ec77c8fa5246ba7c8ee17ffd54c
PKG_SOURCE_DATE:=2026-08-27 PKG_SOURCE_DATE:=2026-08-30
PKG_MIRROR_HASH:=skip PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only PKG_LICENSE:=GPL-2.0-only
+3 -3
View File
@@ -18,12 +18,12 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=luci-proto-wwand PKG_NAME:=luci-proto-wwand
PKG_RELEASE:=3 PKG_RELEASE:=4
PKG_SOURCE_PROTO:=git PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/luci-proto-wwand.git PKG_SOURCE_URL:=https://github.com/ddimension/luci-proto-wwand.git
PKG_SOURCE_VERSION:=680692f5cc74862b874fe34108b7574a3b05a49b PKG_SOURCE_VERSION:=4d101fd7903bdad30d6220bc155d57469495f68c
PKG_SOURCE_DATE:=2026-08-24 PKG_SOURCE_DATE:=2026-08-30
PKG_MIRROR_HASH:=skip PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only PKG_LICENSE:=GPL-2.0-only
@@ -38,7 +38,7 @@
tag redesign's horizontal padding is meant for text labels, so keep the tag redesign's horizontal padding is meant for text labels, so keep the
label-less swatch a compact pill instead of a wide block. */ label-less swatch a compact pill instead of a wide block. */
.ifacebadge > & { .ifacebadge > & {
@apply rounded-full px-1 py-1.5; @apply rounded-full px-1 py-0.5;
} }
.cbi-dropdown & { .cbi-dropdown & {
@@ -63,7 +63,8 @@
} }
} }
& > img { & > img,
& > .cbi-tooltip-container > img {
@apply w-5 shrink-0 self-center max-md:w-4; @apply w-5 shrink-0 self-center max-md:w-4;
} }
@@ -54,7 +54,7 @@
} }
& .network-status-table { & .network-status-table {
@apply mb-3 flex flex-wrap justify-around gap-4 max-md:flex-col; @apply mb-3 flex flex-nowrap justify-around gap-4 max-md:flex-col;
} }
& .cbi-section-create { & .cbi-section-create {
@@ -69,8 +69,8 @@
.ifacebox { .ifacebox {
@apply border-hairline bg-surface-overlay hover:border-hairline max-md:border-hairline relative inline-flex min-w-28 flex-col items-stretch overflow-visible rounded-2xl border text-center text-base leading-5 shadow-md hover:shadow-xl max-md:min-w-30 max-md:flex-1 max-md:rounded-3xl max-md:shadow-sm; @apply border-hairline bg-surface-overlay hover:border-hairline max-md:border-hairline relative inline-flex min-w-28 flex-col items-stretch overflow-visible rounded-2xl border text-center text-base leading-5 shadow-md hover:shadow-xl max-md:min-w-30 max-md:flex-1 max-md:rounded-3xl max-md:shadow-sm;
td & { #cbi-network-interface & {
@apply max-md:flex-row; @apply max-md:flex-row md:min-w-38;
} }
& .ifacebox-head { & .ifacebox-head {
@@ -80,7 +80,7 @@
@apply bg-[rgb(var(--zone-color-rgb),.75)]!; @apply bg-[rgb(var(--zone-color-rgb),.75)]!;
} }
td & { #cbi-network-interface & {
@apply max-md:flex max-md:w-auto max-md:shrink-0 max-md:items-center max-md:justify-center max-md:rounded-l-3xl max-md:rounded-tr-none max-md:border-r max-md:border-b-0; @apply max-md:flex max-md:w-auto max-md:shrink-0 max-md:items-center max-md:justify-center max-md:rounded-l-3xl max-md:rounded-tr-none max-md:border-r max-md:border-b-0;
} }
@@ -102,14 +102,14 @@
and balanced instead of spread apart. Icon-only cards (switch ports, the and balanced instead of spread apart. Icon-only cards (switch ports, the
interfaces list) centre horizontally via `text-center`/`mx-auto`. The interfaces list) centre horizontally via `text-center`/`mx-auto`. The
interfaces overview layers its richer layout under `.network-status-table`. */ interfaces overview layers its richer layout under `.network-status-table`. */
@apply text-text flex w-full flex-1 flex-col items-stretch justify-center gap-1 rounded-b-2xl p-4 text-center max-md:rounded-b-3xl; @apply text-text flex w-full flex-1 flex-col items-stretch justify-center gap-1 rounded-b-2xl p-3 text-center max-md:rounded-b-3xl;
td & { #cbi-network-interface & {
@apply max-md:flex-row max-md:items-center max-md:rounded-r-3xl max-md:rounded-bl-none max-md:py-2 max-md:pr-2 max-md:pl-4; @apply max-md:flex-row max-md:items-center max-md:rounded-r-3xl max-md:rounded-bl-none max-md:py-2 max-md:pr-2 max-md:pl-4;
} }
& > img { & > img {
@apply mx-auto; @apply mx-auto w-5;
} }
& > span { & > span {
@apply text-text space-y-1.5 max-md:space-y-1 max-md:text-sm max-md:leading-5; @apply text-text space-y-1.5 max-md:space-y-1 max-md:text-sm max-md:leading-5;
@@ -119,7 +119,7 @@
} }
.cbi-tooltip-container + .cbi-tooltip-container { .cbi-tooltip-container + .cbi-tooltip-container {
@apply ml-2 max-md:ml-1.5; @apply ml-1;
} }
.cbi-tooltip { .cbi-tooltip {
@@ -131,17 +131,17 @@
} }
& > .nowrap { & > .nowrap {
@apply not-last:mb-4 not-last:max-md:mb-3; @apply not-last:mb-1;
} }
& img { & img {
@apply w-6 shrink-0 max-md:w-5; @apply w-5 shrink-0;
} }
} }
& > div { & > div {
@apply block w-full space-y-0; @apply block w-full space-y-0;
td & { #cbi-network-interface & {
@apply max-md:w-auto max-md:flex-1 max-md:space-y-1; @apply max-md:w-auto max-md:flex-1 max-md:space-y-1;
} }
} }
@@ -160,7 +160,7 @@
& small { & small {
@apply block text-xs leading-4; @apply block text-xs leading-4;
td & { #cbi-network-interface & {
@apply max-md:mt-0; @apply max-md:mt-0;
} }
} }
+2 -2
View File
@@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CSS) LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CSS)
LUCI_DEPENDS:=+luci-base LUCI_DEPENDS:=+luci-base
PKG_VERSION:=1.2.9 PKG_VERSION:=1.3.0
PKG_RELEASE:=76 PKG_RELEASE:=77
PKG_LICENSE:=Apache-2.0 PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:= LUCI_MINIFY_CSS:=
+2 -2
View File
@@ -80,11 +80,11 @@ OpenWrt 25.12+ and snapshots use `apk`; other versions use `opkg`:
cd /tmp cd /tmp
# opkg # opkg
uclient-fetch -O luci-theme-aurora.ipk https://github.com/eamonxg/luci-theme-aurora/releases/latest/download/luci-theme-aurora_1.2.0-r20260808_all.ipk uclient-fetch -O luci-theme-aurora.ipk https://github.com/eamonxg/luci-theme-aurora/releases/latest/download/luci-theme-aurora_1.3.0-r20260830_all.ipk
opkg install luci-theme-aurora.ipk opkg install luci-theme-aurora.ipk
# apk # apk
uclient-fetch -O luci-theme-aurora.apk https://github.com/eamonxg/luci-theme-aurora/releases/latest/download/luci-theme-aurora-1.2.0-r20260808.apk uclient-fetch -O luci-theme-aurora.apk https://github.com/eamonxg/luci-theme-aurora/releases/latest/download/luci-theme-aurora-1.3.0-r20260830.apk
apk add --allow-untrusted luci-theme-aurora.apk apk add --allow-untrusted luci-theme-aurora.apk
``` ```
+2 -2
View File
@@ -80,11 +80,11 @@ OpenWrt 25.12+ 和 Snapshot 版本使用 `apk`;其他版本使用 `opkg`
cd /tmp cd /tmp
# opkg # opkg
uclient-fetch -O luci-theme-aurora.ipk https://github.com/eamonxg/luci-theme-aurora/releases/latest/download/luci-theme-aurora_1.2.0-r20260808_all.ipk uclient-fetch -O luci-theme-aurora.ipk https://github.com/eamonxg/luci-theme-aurora/releases/latest/download/luci-theme-aurora_1.3.0-r20260830_all.ipk
opkg install luci-theme-aurora.ipk opkg install luci-theme-aurora.ipk
# apk # apk
uclient-fetch -O luci-theme-aurora.apk https://github.com/eamonxg/luci-theme-aurora/releases/latest/download/luci-theme-aurora-1.2.0-r20260808.apk uclient-fetch -O luci-theme-aurora.apk https://github.com/eamonxg/luci-theme-aurora/releases/latest/download/luci-theme-aurora-1.3.0-r20260830.apk
apk add --allow-untrusted luci-theme-aurora.apk apk add --allow-untrusted luci-theme-aurora.apk
``` ```
File diff suppressed because one or more lines are too long
+31 -5
View File
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?= FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),) ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION) PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=42 PKG_RELEASE:=47
endif endif
LUCI_TITLE:=Footstrap Theme LUCI_TITLE:=Footstrap Theme
@@ -30,7 +30,7 @@ LUCI_DEPENDS:=+luci-base
LUCI_PKGARCH:=all LUCI_PKGARCH:=all
# Else luci.mk defaults these to LuCI's own, and the package claims MAINTAINER "OpenWrt LuCI # Else luci.mk defaults these to LuCI's own, and the package claims MAINTAINER "OpenWrt LuCI
# community" / URL openwrt/luci. # community" / URL openwrt/luci.
LUCI_MAINTAINER:=VizzleTF <vizzletf47@gmail.com> LUCI_MAINTAINER:=Ivan Kvashonkin <vizzlef@gmail.com>
LUCI_URL:=https://github.com/VizzleTF/luci-theme-footstrap LUCI_URL:=https://github.com/VizzleTF/luci-theme-footstrap
# CSS: ship verbatim. luci.mk would run csstidy over it, which is old enough to mangle :has(), # CSS: ship verbatim. luci.mk would run csstidy over it, which is old enough to mangle :has(),
@@ -79,9 +79,29 @@ define Package/luci-theme-footstrap/conffiles
endef endef
# reload, never restart: rpcd holds sessions in memory, so `restart` logs out every LuCI user, # reload, never restart: rpcd holds sessions in memory, so `restart` logs out every LuCI user,
# including the admin who just clicked Update. `reload` sends SIGHUP, which re-reads # including the admin who just clicked Update. A session survives `reload` and dies across
# /usr/share/rpcd/acl.d/*, and that ACL refresh is the only thing this package needs from rpcd. # `restart` — verified on a live router.
# Verified on a live router: a session survives `reload` and dies across `restart`. #
# AND RELOAD RE-READS THE PLUGINS, not only /usr/share/rpcd/acl.d/*. That correction matters,
# because it is what makes the check below necessary rather than decorative. Measured on a
# SNAPSHOT stand, where the `luci` object comes from the ucode plugin
# /usr/share/rpcd/ucode/luci (24.10 and 25.12 use /usr/libexec/rpcd/luci):
#
# file removed + reload -> `ubus list` loses `luci` (and it does NOT come back on its own)
# file removed + restart -> still gone
# file restored + reload -> back
#
# So a reload that lands while another package is replacing that file leaves rpcd without the
# object, and every luci/getFeatures, luci/getTimezones and luci/getMountPoints call answers
# `-32000 Object not found` until something reloads it again — the page then renders with the
# system time blank and an RPCError box per call. Reported from the field on a SNAPSHOT router,
# cleared by a reboot; nine days of uptime before it, so it was an upgrade that did it.
#
# Hence: reload, then ASK whether the object is there, and reload once more if it is not. A second
# reload and not a restart, because the table above is the whole argument — reload already does
# everything restart would do to the plugin set, and restart adds only the logout. If the object is
# still missing after that, it is not this package's reload that removed it, and throwing every
# admin out of LuCI would not bring it back.
define Package/luci-theme-footstrap/postinst define Package/luci-theme-footstrap/postinst
#!/bin/sh #!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || { [ -n "$${IPKG_INSTROOT}" ] || {
@@ -93,6 +113,12 @@ define Package/luci-theme-footstrap/postinst
sh /etc/uci-defaults/30_luci-theme-footstrap >/dev/null 2>&1 || true sh /etc/uci-defaults/30_luci-theme-footstrap >/dev/null 2>&1 || true
rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* >/dev/null 2>&1 || true rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* >/dev/null 2>&1 || true
/etc/init.d/rpcd reload >/dev/null 2>&1 || true /etc/init.d/rpcd reload >/dev/null 2>&1 || true
# see the note above the define: a reload that raced another package's file replacement leaves
# rpcd without the `luci` object, and nothing brings it back on its own
ubus list 2>/dev/null | grep -qx luci || {
sleep 1
/etc/init.d/rpcd reload >/dev/null 2>&1 || true
}
} }
exit 0 exit 0
endef endef
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

@@ -9,8 +9,8 @@
"theme_color": "#f6f8fa", "theme_color": "#f6f8fa",
"icons": [ "icons": [
{ {
"src": "/luci-static/footstrap/app-icon-512.png", "src": "/luci-static/footstrap/app-icon-192.png",
"sizes": "512x512", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"
} }
@@ -99,27 +99,95 @@ function run() {
* *
* Wrapping `dom.content()` itself also works, at the price of patching a luci-base API every app * Wrapping `dom.content()` itself also works, at the price of patching a luci-base API every app
* shares and up to seven read/write pairs per call. */ * shares and up to seven read/write pairs per call. */
/* The three things `dom.content()` is called on: a section body, a table, and a TABLE'S BODY. The /* A section body and a table: what `dom.content()` is called on and this can hold. Its third target,
* third was missing and cost a release: on 24.10's Overview the section is a table, so nothing here * a table's BODY, is deliberately absent a floor there holds nothing. `min-height` is undefined on
* matched, the floor held nothing, and a poll emptying it took 58px off the document under the * a table box (CSS 2.1 §10.7) and WebKit acts on that: a `.table.cbi-section-table` carrying a 313px
* reader on ImmortalWrt 24.10 with a webkit engine, where no CI job looks. tools/scroll-anchor.mjs * floor still collapsed to 30px and the document lost 284px (webkit, /admin/network/firewall, 24.10
* looks for the same three and says why. */ * and 25.12 alike; Chromium holds the 313px), and writing the floor on the `.tbody` instead loses
* the same 284px. 24.10 has no `.tbody` at all: its Overview renders `<table class="table">` with the
* rows directly inside, and the container a poll empties there is `.cbi-section > div`.
* tools/scroll-anchor.mjs looks for all three when it picks a box to collapse, which is a different
* question from which box can carry a floor. */
const SHRINKS = '.cbi-section > div, .table'; const SHRINKS = '.cbi-section > div, .table';
/* The floor is the height the next tick may not go below, one per container. Cleared before the /* What the container would stand at with no floor under it, asked of the CONTENT rather than by
* read, or each floor measures itself and never comes down; batched into one clear, one read pass * taking the floor off and re-measuring.
* and one write pass, so the whole sweep costs a single forced layout rather than one per element.
* *
* Not while the reader scrolls: clearing to re-measure is a layout read, and a floor staying where * Clearing `min-height` to re-measure was the obvious way and is the expensive one: `min-height` is
* it was is still a floor. */ * a scroll-anchoring suppression trigger on the path from the anchor to the scroller
* (css-scroll-anchoring-1 §3.2), so a clear, a forced layout and a write back tell the engine to
* drop its own compensation for that frame the theme switching off the very thing it relies on,
* once per tick.
*
* The span of the children answers the same question with reads alone. It is the box's content
* height plus what the box adds below it; a collapsed margin on the last child can put it a few
* pixels out, which a floor can afford the floor is a lower bound on a height that is about to be
* replaced, not a layout the page is drawn to.
*
* An empty container answers 0, and the caller keeps the standing floor for exactly that reason:
* `dom.content()` empties before it refills, and 0 is the number the floor exists to refuse. */
function naturalHeight(el) {
const last = el.lastElementChild;
if (!last) return 0;
const box = el.getBoundingClientRect();
const end = last.getBoundingClientRect();
if (!end.height && !end.width) return 0; /* a last child out of the flow says nothing */
const cs = window.getComputedStyle(el);
const below = parseFloat(cs.paddingBottom) + parseFloat(cs.borderBottomWidth);
return Math.round(end.bottom - box.top + (below || 0));
}
/* The floor is the height the next tick may not go below, one per container. Read in one pass and
* written in another, so the sweep costs a single forced layout rather than one per element.
*
* WRITTEN ONLY WHERE THE VALUE CHANGES, which is the difference between a floor and a page the
* engine refuses to anchor: see naturalHeight() above. Measured over 25 s of real polling on the
* Overview at 390px, the clear-and-rewrite shape wrote style 1550 times on 25.12 and 170 times on
* ImmortalWrt 24.10, of which 75 and 62 carried a value that had actually moved the rest were
* suppression bought for nothing.
*
* Not while the reader scrolls: a rect read is a forced layout, and a floor staying where it was is
* still a floor. */
/* The floor is the height the next tick may not go below, one per box. Read in one pass and written
* in another, so the sweep costs a single forced layout rather than one per element.
*
* WRITTEN ONLY WHERE THE VALUE CHANGES, which is the difference between a floor and a page the
* engine refuses to anchor: see naturalHeight() above. Measured over 25 s of real polling on the
* Overview at 390px, the clear-and-rewrite shape wrote style 1550 times on 25.12 and 170 times on
* ImmortalWrt 24.10, of which 75 and 62 carried a value that had actually moved the rest were
* suppression bought for nothing. It is 45 writes, all of them real, on both.
*
* AND NOT ON A TABLE BOX, which cannot hold it: `min-height` is undefined there (CSS 2.1 §10.7) and
* WebKit acts on that a `.table.cbi-section-table` wearing a 313px floor still collapsed to 30px
* when its rows went, and the document lost 284px on /admin/network/firewall, the same on 24.10 and
* 25.12, while Chromium held the 313px. The `.tbody` inside it is a table box too and loses the same
* 284px. So the floor climbs to the first box that is not one the section where the same
* emptied `.tbody` costs the document 0px on both engines. `getComputedStyle` resolves style, not
* layout, so the climb adds no forced layout of its own.
*
* Not while the reader scrolls: a rect read is a forced layout, and a floor staying where it was is
* still a floor. */
function holdFloor() { function holdFloor() {
if (scrolling()) return; if (scrolling()) return;
const host = document.getElementById('view'); const host = document.getElementById('view');
if (!host) return; /* the login page has no view */ if (!host) return; /* the login page has no view */
const els = host.querySelectorAll(SHRINKS), hs = []; const boxes = [], hs = [];
els.forEach((el) => { el.style.minHeight = ''; }); host.querySelectorAll(SHRINKS).forEach((el) => {
els.forEach((el) => hs.push(el.offsetHeight)); let box = el;
els.forEach((el, i) => { if (hs[i] > 0) el.style.minHeight = hs[i] + 'px'; }); while (box && box !== host && window.getComputedStyle(box).display.startsWith('table'))
box = box.parentElement;
/* several tables in one section climb to the same box; it needs one floor, not one each */
if (!box || box === host || boxes.indexOf(box) !== -1) return;
boxes.push(box);
hs.push(naturalHeight(box));
});
boxes.forEach((box, i) => {
/* zero is an empty box, and the floor it already wears is what holds it up the moment this
* whole mechanism exists for */
if (hs[i] <= 0) return;
const px = hs[i] + 'px';
if (box.style.minHeight !== px) box.style.minHeight = px;
});
} }
/* ---- is the page moving right now? asked of the position, never of the events ---- /* ---- is the page moving right now? asked of the position, never of the events ----
@@ -202,6 +270,11 @@ function sampleMotion() {
/* the page has held still for SCROLL_IDLE: whatever was put off may run now */ /* the page has held still for SCROLL_IDLE: whatever was put off may run now */
if (_deferred) { if (_deferred) {
_deferred = false; _deferred = false;
/* where the reference stands BEFORE the put-off pass re-lays the page — see settleDrift() */
const settled = _rest;
const before = (settled && settled.el && settled.el.isConnected)
? settled.el.getBoundingClientRect().top
: ((settled && settled.sec && settled.sec.isConnected) ? settled.sec.getBoundingClientRect().top : null);
/* No correction for this batch. Both available references are wrong for a page the reader /* No correction for this batch. Both available references are wrong for a page the reader
* has just scrolled through: a fresh one is read against an offset WebKit may not have laid * has just scrolled through: a fresh one is read against an offset WebKit may not have laid
* out yet (the theme then undoes the reader's own move), and the one from the last still * out yet (the theme then undoes the reader's own move), and the one from the last still
@@ -210,9 +283,59 @@ function sampleMotion() {
* the fitters re-measure what the scroll already showed rather than growing the page and * the fitters re-measure what the scroll already showed rather than growing the page and
* the next mutation corrects against a reference taken while the page was still. */ * the next mutation corrects against a reference taken while the page was still. */
run(); run();
if (ENGINE_ANCHORS) settleDrift(settled, before);
} }
} }
/* ---- the put-off pass moves the page too, and nothing was looking ----
*
* A tick landing while the offset is in motion leaves its measurements to the block above, and that
* pass then re-lays the tables it could not measure. An anchoring engine answers that layout change
* the way it answers any other and `min-height`, which the floor writes on every container, is
* itself a suppression trigger on the path to the anchor (css-scroll-anchoring-1 §3.2), so the
* engine's compensation can be switched off by the very pass that needs it. Measured on
* ImmortalWrt 24.10/WebKit: this pass's 88 `min-height` writes and a 58px jump of the offset land in
* the SAME frame, 429 ms after the mutation (@390, top layout, large density, Overview).
*
* `lateDrift()` cannot see it: it is scheduled from the mutation on the same SCROLL_IDLE, so it
* measures ALONGSIDE this pass rather than after it it read a drift of zero three milliseconds
* before the page moved, and scroll-anchor reported those 58px on that cell alone out of 48.
*
* MEASURED SYNCHRONOUSLY AROUND THE PASS, not a frame or an idle window later. Two reasons, and the
* second cost a run: the reader cannot scroll between two statements, so what this sees is the
* pass's doing and nothing else a version that looked two frames later corrected inside a flick on
* three cells of the same sweep. And `getBoundingClientRect()` is exactly the operation the spec
* makes the engine flush a pending adjustment before, so the read after the pass sees the engine's
* answer rather than racing it (§2.2: the suppression window ends at the end of the event loop
* iteration, or before the next operation whose result would differ, whichever is sooner). */
function settleDrift(ref, before) {
if (before == null || !ref) return;
if (!anchorEnabled() || Date.now() < _userUntil) return;
if (_restPage !== pageStamp()) return;
const el = (ref.el && ref.el.isConnected) ? ref.el : ((ref.sec && ref.sec.isConnected) ? ref.sec : null);
if (el) putBack(el, before);
}
/* Give the reader back what moved under them: the one write both corrections make, and the rules
* that write obeys.
*
* A drift under a pixel is rounding, and an engine that answered for it reads the same. One
* viewport is the ceiling, a drift that size being a view that replaced its whole subtree rather
* than a tick anchorFor() raises it for the one drift that big with a receipt, a measured clamp.
*
* The write moves the page by exactly the drift measured, so the reference is back at the top it
* was remembered at and the next tick measures zero. Only `_restAt` moves, and the write may have
* been clamped short, so it is re-read rather than assumed; `rememberRest()` cannot do it, since
* the write starts the motion sampler and that function returns early while the page moves. */
function putBack(el, was) {
const drift = el.getBoundingClientRect().top - was;
if (Math.abs(drift) < 1 || Math.abs(drift) > (window.innerHeight || 800)) return;
const sc = scroller();
const at = sc ? sc.scrollTop : window.scrollY;
if (sc) sc.scrollTop = at + drift; else window.scrollTo(0, at + drift);
_restAt = scrollTop();
}
function noteMotion() { function noteMotion() {
_movingUntil = Date.now() + SCROLL_IDLE; _movingUntil = Date.now() + SCROLL_IDLE;
if (_sampling) return; if (_sampling) return;
@@ -563,23 +686,8 @@ function lateDrift(ref) {
if (scrollTop() !== seen) return; if (scrollTop() !== seen) return;
/* the tick usually replaces the element this was taken on, so without the section /* the tick usually replaces the element this was taken on, so without the section
* fallback the correction does nothing on the tick it exists for */ * fallback the correction does nothing on the tick it exists for */
let el = ref.el, was = ref.top; if (ref.el && ref.el.isConnected) putBack(ref.el, ref.top);
if (!el || !el.isConnected) { else if (ref.sec && ref.sec.isConnected && ref.secTop != null) putBack(ref.sec, ref.secTop);
if (!ref.sec || !ref.sec.isConnected || ref.secTop == null) return;
el = ref.sec; was = ref.secTop;
}
const drift = el.getBoundingClientRect().top - was;
if (Math.abs(drift) < 1) return; /* the engine put it back */
if (Math.abs(drift) > (window.innerHeight || 800)) return;
const sc = scroller();
const at = sc ? sc.scrollTop : window.scrollY;
if (sc) sc.scrollTop = at + drift; else window.scrollTo(0, at + drift);
/* The write moves the page by exactly the drift measured, which puts the reference back
* at the top it was remembered at, so `_rest.top` still holds and the next tick
* measures zero. Only `_restAt` changes, and the write may have been clamped short, so
* it is re-read rather than assumed; `rememberRest()` cannot do it, since the write
* starts the motion sampler and that function returns early while the page moves. */
_restAt = scrollTop();
}, SCROLL_IDLE); }, SCROLL_IDLE);
}); });
} }
@@ -777,9 +885,20 @@ return baseclass.extend({
* theme/30-tables.css gives a data table an honest min-content floor for as long as it is a * theme/30-tables.css gives a data table an honest min-content floor for as long as it is a
* table, so a starved column really does overflow. Do not reconstruct min-content in JS a * table, so a starved column really does overflow. Do not reconstruct min-content in JS a
* canvas approximation cost ~1ms per pass on a 114-row table and claimed 144px where the * canvas approximation cost ~1ms per pass on a 114-row table and claimed 144px where the
* engine's own floor is 93. */ * engine's own floor is 93.
*
* TWO measurements, because a table overflows in two directions and `scrollWidth` only sees one.
* A `display: table` box does not clip: when min-content needs more than it was given it GROWS
* PAST its parent, so its scrollWidth and clientWidth rise together and the overflow is
* invisible from inside the same trap `roomFor()` above is written around. The box's own
* width is what the reader sees sticking out, and it is what tools/live-audit.mjs measures
* (`right > host + 1.5`). Taking the larger of the two makes this test answer the question the
* gate asks: `#packages` on a fresh snapshot router came out 2px past the content column at
* 1440 and stayed un-carded, because scrollWidth alone said it fitted. */
overflows(el) { overflows(el) {
return el.scrollWidth > this.roomFor(el) + 1; /* +1: sub-pixel rounding */ const room = this.roomFor(el);
const grown = el.getBoundingClientRect().width;
return Math.max(el.scrollWidth, grown) > room + 1; /* +1: sub-pixel rounding */
} }
}); });
@@ -159,6 +159,10 @@ function currentNode() {
return baseclass.extend({ return baseclass.extend({
setTree, setTree,
tree: () => _tree, tree: () => _tree,
/* raw presence, no alias or firstchild resolution: fs-commands gates each command on the menu
* node whose `depends.acl` names the group that command needs, and a node that resolves
* elsewhere would answer for a permission the session may not hold */
nodeForSegs,
segsFromPath, segsFromPath,
currentNode, currentNode,
resolveSegs, resolveSegs,
@@ -140,6 +140,44 @@ function guardDarkStamp() {
attributeFilter: ['data-darkmode', 'data-theme', 'data-bs-theme'] attributeFilter: ['data-darkmode', 'data-theme', 'data-bs-theme']
}); });
} }
/* ---- the browser's own chrome follows the page ----
*
* `<meta name="theme-color">` is what colours a mobile address bar, the Android task-switcher card
* and an installed PWA's title bar. head.ut ships it at the default palette's page colour, which is
* all a static template can know; from here it tracks the live one.
*
* Read from the BODY's computed background, not from `--fs-bg`: a custom property returns its token
* stream, so a palette whose page colour is a color-mix() would put `color-mix(in srgb, …)` in the
* attribute, and 21 of the axes reach the canvas through one mix or another.
*
* One observer rather than a call in each applier: the colour is a function of :root's attributes
* and inline properties mode, palette, tint, its strength and every axis already writes exactly
* those. Adding an axis therefore needs nothing here. Coalesced into a frame because the tint and
* strength sliders write on every input event, and getComputedStyle forces style resolution. */
function paintThemeColor() {
const meta = document.querySelector('meta[name="theme-color"]');
if (!meta || !document.body) return;
const bg = getComputedStyle(document.body).backgroundColor;
/* a transparent body means the sheet has not applied yet; keep the server's value */
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') meta.setAttribute('content', bg);
}
function watchThemeColor() {
let queued = false;
const paint = () => {
queued = false;
paintThemeColor();
};
const schedule = () => {
if (queued) return;
queued = true;
window.requestAnimationFrame(paint);
};
paintThemeColor();
new MutationObserver(schedule).observe(document.documentElement,
{ attributes: true, attributeFilter: [ 'style', 'class', 'data-darkmode', 'data-palette', 'data-wallpaper' ] });
}
/* "Auto" means follow the OS continuously, not only at page load. Only while the effective mode is /* "Auto" means follow the OS continuously, not only at page load. Only while the effective mode is
* auto: an explicit browser choice, or an explicit router default with no browser override. */ * auto: an explicit browser choice, or an explicit router default with no browser override. */
_mqDark.addEventListener('change', () => { _mqDark.addEventListener('change', () => {
@@ -316,7 +354,7 @@ return baseclass.extend({
/* the two axis shapes, so the nineteen axes in fs-axes.js can be built from them */ /* the two axis shapes, so the nineteen axes in fs-axes.js can be built from them */
listAxis, enumAxis, listAxis, enumAxis,
currentMode, applyMode, modeDefault, guardDarkStamp, currentMode, applyMode, modeDefault, guardDarkStamp, watchThemeColor,
currentDensity, applyDensity, densityDefault, currentDensity, applyDensity, densityDefault,
currentLayout, applyLayout, isTopLayout, currentLayout, applyLayout, isTopLayout,
currentAutoCollapse, applyAutoCollapse, autoCollapseDefault, currentAutoCollapse, applyAutoCollapse, autoCollapseDefault,
@@ -720,6 +720,35 @@ function commitStage(stage, contentHost) {
dropStage(stage); dropStage(stage);
} }
/* ---- the commit is the one frame of a navigation worth animating ----
*
* `commitStage()` is a synchronous DOM move, so a view transition here wraps a FRAME, not a render:
* the update callback settles in the same tick and the API's rendering suppression cannot outlive
* it. Wrapping navigate() instead would freeze the page for the whole chain 136-196 ms median
* (docs/spa-router.md) and up to RENDER_TIMEOUT on a cold route.
*
* The scroll restore runs INSIDE the callback: `::view-transition` is a fixed overlay of the old
* pixels, and a scroll that lands after the snapshot slides the live page under a still image.
*
* Reduced motion is answered by not starting a transition at all, rather than by a zeroed animation:
* the `*` rule in theme/95-a11y-media.css does not reach a pseudo tree, and skipping also saves the
* snapshot. Read per navigation, so an OS change needs no listener.
*
* `ready` rejects when the transition is skipped a hidden document, a duplicate name which is a
* normal outcome and not an error to report; the swap itself has happened either way.
*
* BOTH promises are taken, and `finished` is not decoration: it rejects with whatever the callback
* threw, so leaving it alone turns one fault into two console lines the throw itself and an
* unhandled rejection behind it. Measured on a page whose callback throws: two `pageerror`s with
* only `ready` handled, one with both. The extra line is noise in a log the live gates read. */
function swapIn(commit) {
const mq = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)');
if ((mq && mq.matches) || typeof document.startViewTransition !== 'function') { commit(); return; }
const t = document.startViewTransition(commit);
t.ready.catch(() => {});
t.finished.catch(() => {});
}
/* The `#view` the document keeps between navigations, i.e. the one the observers are bound to: /* The `#view` the document keeps between navigations, i.e. the one the observers are bound to:
* whichever `#view` is not the stage. A document that has none gets one, once. */ * whichever `#view` is not the stage. A document that has none gets one, once. */
function liveView(contentHost, stage) { function liveView(contentHost, stage) {
@@ -1072,9 +1101,11 @@ function navigate(pathname, push, kbd) {
/* superseded while rendering: the chain painted into its own stage, so drop it and /* superseded while rendering: the chain painted into its own stage, so drop it and
* leave the live page to the newer navigation */ * leave the live page to the newer navigation */
if (gen !== _navGen) { dropStage(stage); return; } if (gen !== _navGen) { dropStage(stage); return; }
commitStage(stage, contentHost); swapIn(() => {
/* now, and only now, is there one height to read: the incoming page's */ commitStage(stage, contentHost);
if (restoreTo) restoreScroll(restoreTo, gen); /* now, and only now, is there one height to read: the incoming page's */
if (restoreTo) restoreScroll(restoreTo, gen);
});
}) })
.catch((e) => { dropStage(stage); throw e; }); .catch((e) => { dropStage(stage); throw e; });
}).catch((e) => { }).catch((e) => {
@@ -99,6 +99,48 @@ function index() {
return _index; return _index;
} }
/* ---- extra sources -------------------------------------------------------
*
* An optional package can add rows to the same list one indexes the SECTION titles inside each
* page, so "footstrap" finds System -> Appearance. A source hands over entries in the shape
* buildIndex() produces and nothing else: the matching, the ranking and the rendering stay here,
* or two lists would disagree about what a hit is. Two fields are the source's alone: `onTake`,
* called when the row is chosen, and `key`, what the recents list stores it under when its `path`
* is not its own (see keyOf).
*
* Registration is a GLOBAL ARRAY, not an export a package requires. This module is fetched on the
* first gesture and most sessions never make it; a package that had to `require` it to register
* would pull it onto every page and pay its 4.5 KB for a palette nobody opened. Pushing a function
* onto `window.__fsSearchSources` costs the package nothing and names no one in either direction.
*
* `window.__fsSearchGen` is how a source says its data grew a harvester fills in over a session
* and the stamp below is what rebuilds the pool when it does. */
const _sources = [];
let _pool = null, _stamp = -1;
function globalSources() {
return Array.isArray(window.__fsSearchSources) ? window.__fsSearchSources : [];
}
function addSource(fn) {
_sources.push(fn);
_pool = null;
}
function refresh() {
_pool = null;
}
function pool() {
const all = _sources.concat(globalSources());
const stamp = all.length + (window.__fsSearchGen || 0);
if (_pool && stamp === _stamp) return _pool;
_stamp = stamp;
_pool = all.reduce((rows, fn) => {
try { return rows.concat(fn() || []); }
catch (e) { console.error('footstrap: a search source threw', e); return rows; }
}, index().slice());
return _pool;
}
/* ---- matching ----------------------------------------------------------- */ /* ---- matching ----------------------------------------------------------- */
/* Every whitespace-separated token must hit something: a second word means AND. Deliberately not /* Every whitespace-separated token must hit something: a second word means AND. Deliberately not
@@ -118,7 +160,7 @@ function search(q, limit) {
if (!toks.length) return []; if (!toks.length) return [];
const hits = []; const hits = [];
for (const e of index()) { for (const e of pool()) {
let sum = 0; let sum = 0;
for (const tok of toks) { for (const tok of toks) {
const s = tokenScore(e, tok); const s = tokenScore(e, tok);
@@ -137,20 +179,31 @@ function search(q, limit) {
/* What the palette shows before anything is typed: an admin lives in three or four pages, so the /* What the palette shows before anything is typed: an admin lives in three or four pages, so the
* empty state is its most-used view. * empty state is its most-used view.
* *
* Only the path is stored, never the title the title is resolved through the index on every * Only the KEY is stored, never the title the title is resolved through the pool on every
* render, so it follows the UI language and a page removed with its package drops out instead of * render, so it follows the UI language and a row whose package went away drops out instead of
* lingering as a dead row. */ * lingering as a dead row. A page's key is its menu path; a row from a source carries its own
* `key`, because a section has no dispatcher node and therefore no path that is only its own
* the sections source keys one `admin/system/system#Footstrap`, the page it is on plus its own
* heading. */
const RECENT_KEY = 'fs-recent'; const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8; const RECENT_MAX = 8;
/* the string a row is remembered under, and the one menu-footstrap-common's remember() writes */
function keyOf(e) {
return e.key || e.path;
}
/* The list is WRITTEN by menu-footstrap-common.js, which is on every page this module is not any /* The list is WRITTEN by menu-footstrap-common.js, which is on every page this module is not any
* more, and a palette that only loads when it is opened cannot be what records where the admin has * more, and a palette that only loads when it is opened cannot be what records where the admin has
* been. Read here, at open time, so it is always current. `prefs.lsGetArr` owns the parse, the * been. Read here, at open time, so it is always current. `prefs.lsGetArr` owns the parse, the
* corruption guard and the Array check; only the "these are paths" filter belongs here. */ * corruption guard and the Array check; only the "these are keys" filter belongs here. */
function recentEntries() { function recentEntries() {
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string'); const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
const byPath = new Map(index().map((e) => [ e.path, e ])); /* pool(), not index(): a section is recalled exactly as a page is. Against the index alone a
return recent.map((p) => byPath.get(p)).filter(Boolean).slice(0, RECENT_MAX); * section key resolved to nothing and the row silently vanished, so taking "Footstrap" left
* only "System" in the list the page path is all either row carries. */
const byKey = new Map(pool().map((e) => [ keyOf(e), e ]));
return recent.map((k) => byKey.get(k)).filter(Boolean).slice(0, RECENT_MAX);
} }
/* ---- the palette -------------------------------------------------------- */ /* ---- the palette -------------------------------------------------------- */
@@ -252,8 +305,16 @@ function build() {
e.trail.length ? E('span', { 'class': 'fs-search-opt-path' }, [ e.trail.join(' ') ]) : '' e.trail.length ? E('span', { 'class': 'fs-search-opt-path' }, [ e.trail.join(' ') ]) : ''
]); ]);
/* close before the click reaches the router, which re-renders the chrome underneath; /* close before the click reaches the router, which re-renders the chrome underneath;
* no focus return, the user is going elsewhere */ * no focus return, the user is going elsewhere.
a.addEventListener('click', () => close(false)); *
* `onTake` is how a row from an extra source finishes the job the href cannot: a
* section row's href can only reach the PAGE, so the source that produced it opens the
* tab and scrolls to the section itself. It fires for a click and for Enter alike
* Enter synthesises this very click. */
a.addEventListener('click', () => {
close(false);
if (typeof e.onTake === 'function') e.onTake();
});
a.addEventListener('pointermove', () => { if (at !== i) setActive(i); }); a.addEventListener('pointermove', () => { if (at !== i) setActive(i); });
list.appendChild(a); list.appendChild(a);
return a; return a;
@@ -354,5 +415,7 @@ function openPalette() {
} }
return baseclass.extend({ return baseclass.extend({
open: openPalette open: openPalette,
/* the seam an optional package registers through; see addSource() */
addSource, refresh
}); });
@@ -54,11 +54,20 @@ const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8; const RECENT_MAX = 8;
const RECENT_WARM = 5; const RECENT_WARM = 5;
function remember(segs) { /* A key is a menu path, or a page path plus the heading of a section inside it
if (!Array.isArray(segs) || !segs.length) return; * (`admin/system/system#Footstrap`) a section has no dispatcher node to name it, and only the
const path = segs.join('/'); * source that produced the row can build that half. Exported for exactly that: the writer stays
* one function, or the two halves would drift on the cap and the de-duplication. */
function remember(key) {
if (typeof key !== 'string' || !key) return;
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string'); const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
prefs.lsSet(RECENT_KEY, JSON.stringify([ path ].concat(recent.filter((p) => p !== path)).slice(0, RECENT_MAX))); prefs.lsSet(RECENT_KEY, JSON.stringify([ key ].concat(recent.filter((p) => p !== key)).slice(0, RECENT_MAX)));
}
/* the page half of a key: what the router can navigate to and what warmRecent() prefetches */
function pageOf(key) {
const h = key.indexOf('#');
return h < 0 ? key : key.slice(0, h);
} }
/* ---- warm the pages this admin actually uses ---- /* ---- warm the pages this admin actually uses ----
@@ -75,8 +84,10 @@ function remember(segs) {
function warmRecent() { function warmRecent() {
try { if (navigator.connection && navigator.connection.saveData) return; } catch (e) {} try { if (navigator.connection && navigator.connection.saveData) return; } catch (e) {}
const here = (L.env.dispatchpath || []).join('/'); const here = (L.env.dispatchpath || []).join('/');
const paths = prefs.lsGetArr(RECENT_KEY) /* Keys, not paths: a section key names the page it sits on, and two sections of one page must
.filter((p) => typeof p === 'string' && p !== here).slice(0, RECENT_WARM); * warm it once the module chain is the page's. */
const keys = prefs.lsGetArr(RECENT_KEY).filter((p) => typeof p === 'string');
const paths = [ ...new Set(keys.map(pageOf)) ].filter((p) => p !== here).slice(0, RECENT_WARM);
if (!paths.length) return; if (!paths.length) return;
const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/'))); const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
if (typeof window.requestIdleCallback === 'function') if (typeof window.requestIdleCallback === 'function')
@@ -91,26 +102,31 @@ function wireSearch() {
const RT = window.L; const RT = window.L;
/* the page this full load landed on; onNavigate covers the SPA path afterwards */ /* the page this full load landed on; onNavigate covers the SPA path afterwards */
remember(L.env.dispatchpath || []); const rememberSegs = (segs) => remember((segs || []).join('/'));
router.onNavigate(remember); rememberSegs(L.env.dispatchpath);
router.onNavigate(rememberSegs);
warmRecent(); warmRecent();
/* One fetch, on the first gesture. The module builds its overlay and opens itself; every later /* One fetch, on the first gesture. The module builds its overlay and opens itself; every later
* gesture reaches the same instance, `require` being a singleton. */ * gesture reaches the same instance, `require` being a singleton.
let pending = false; *
* and then this half stands down: fs-search binds its own toggle to the same button and its
* own copies of Ctrl+K and `/`, so while both were live the module's toggle closed the palette
* and this one re-opened it in the microtask after, and the button looked broken. */
let pending = false, loaded = false;
const open = () => { const open = () => {
if (pending) return; if (pending || loaded) return;
pending = true; pending = true;
RT.require('fs-search').then((m) => { pending = false; m.open(); }, RT.require('fs-search').then((m) => { pending = false; loaded = true; m.open(); },
(e) => { pending = false; console.error('footstrap: fs-search did not load', e); }); (e) => { pending = false; console.error('footstrap: fs-search did not load', e); });
}; };
btn.addEventListener('click', open); btn.addEventListener('click', () => open());
/* the same two shortcuts the palette used to own, with the same guard: `/` must not steal a /* the same two shortcuts the palette used to own, with the same guard: `/` must not steal a
* keystroke from someone typing into a field, a contenteditable, or a .cbi-dropdown, where * keystroke from someone typing into a field, a contenteditable, or a .cbi-dropdown, where
* fs-select.js's typeahead reads it as a search character */ * fs-select.js's typeahead reads it as a search character */
document.addEventListener('keydown', (ev) => { document.addEventListener('keydown', (ev) => {
if (ev.defaultPrevented) return; if (ev.defaultPrevented || loaded) return;
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'k' || ev.key === 'K')) { if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'k' || ev.key === 'K')) {
ev.preventDefault(); open(); return; ev.preventDefault(); open(); return;
} }
@@ -120,6 +136,23 @@ function wireSearch() {
}); });
} }
/* ---- optional companion packages ----
*
* header.ut prints `window.__fsPlugins` from `footstrap.settings.plugin`, a list a package writes
* from its own uci-defaults; each entry is a LuCI module name, already whitelisted there. The
* chrome requires each one after everything below is wired a plugin registers itself through the
* seams the theme exports (`fs-router.onNavigate`, `fs-search.addSource`) and the theme names
* nobody. A plugin that throws costs only itself.
*
* No plugin, no cost: an empty list is the shipped state and this loop does nothing. */
function loadPlugins() {
const RT = window.L;
const names = Array.isArray(window.__fsPlugins) ? window.__fsPlugins : [];
names.forEach((name) => {
RT.require(name).catch((e) => console.error('footstrap: plugin ' + name + ' did not load', e));
});
}
/* The three template globals Status -> Overview needs, defined where ordering is guaranteed. /* The three template globals Status -> Overview needs, defined where ordering is guaranteed.
* *
* `admin_status/index.ut` defines `progressbar`, `renderBox` and `renderBadge` in an inline script * `admin_status/index.ut` defines `progressbar`, `renderBox` and `renderBadge` in an inline script
@@ -198,6 +231,10 @@ ensureOverviewHelpers();
* halves (fs-menutree, fs-prefs) are separate modules. */ * halves (fs-menutree, fs-prefs) are separate modules. */
return baseclass.extend({ return baseclass.extend({
/* the seam a companion package writes its own rows into the recents list through; see
* remember() for what a key is */
remember,
init(renderMainMenu) { init(renderMainMenu) {
/* First, and outside the promise: a third-party sheet that outranks the chrome is already /* First, and outside the promise: a third-party sheet that outranks the chrome is already
* painting (fs-sheets: openclash's `* { margin: 0; padding: 0 }`). Deferring this to * painting (fs-sheets: openclash's `* { margin: 0; padding: 0 }`). Deferring this to
@@ -205,6 +242,7 @@ return baseclass.extend({
* below swallows a menu failure. */ * below swallows a menu failure. */
sheets.watchViewSheets(); sheets.watchViewSheets();
prefs.guardDarkStamp(); /* same, for a third party stamping :root */ prefs.guardDarkStamp(); /* same, for a third party stamping :root */
prefs.watchThemeColor(); /* the mobile address bar, from the live page colour */
ui.menu.load().then((menu) => { ui.menu.load().then((menu) => {
tree.setTree(menu); tree.setTree(menu);
@@ -226,6 +264,9 @@ return baseclass.extend({
wirePageModules(); wirePageModules();
router.wire(); router.wire();
router.wireVisibility(); router.wireVisibility();
/* last: a plugin registers against the parts above, and a broken one must not be able
* to take the chrome with it */
loadPlugins();
/* no sane partial recovery a throw above loses the menu, the router and the Appearance /* no sane partial recovery a throw above loses the menu, the router and the Appearance
* tab together so this fails loudly rather than silently */ * tab together so this fails loudly rather than silently */
}).catch((e) => console.error('footstrap: chrome init failed', e)); }).catch((e) => console.error('footstrap: chrome init failed', e));
@@ -0,0 +1,79 @@
@layer tokens {
/* Static twins for every token whose value is a color-mix(), for a browser that has none.
*
* Last in the tokens layer on purpose: it has to outrank 02-tokens.css AND the per-palette
* definitions in 03-palettes.css, and directory order is source order (build-css.sh).
*
* Why a whole file rather than a fallback declaration in front of each color-mix(): a custom
* property keeps whatever token stream it is given, so the failure is deferred to the point of USE
* `background: var(--fs-good-soft)` becomes invalid at computed-value time and computes to
* `unset`, which is `initial` for a non-inherited property. IACVT does not fall back to an earlier
* declaration in the same block, so the two-declaration trick that works for `background:
* color-mix()` written literally does nothing for a token. 38 tokens are affected; without this
* block a browser below the floor paints borders in currentcolor and loses every soft surface.
*
* color-mix() lands in Chrome 111, Firefox 113 and Safari 16.2 (all 2022-2023). See docs/css.md
* for the theme's browser floor and which feature sets it.
*
* The rules: a tint over transparent degrades to `transparent` an absent surface never lowers
* contrast, while a solid version of the same colour under its own text does. A hairline degrades
* to the solid colour it is a fraction of. A focus ring degrades to the -solo ring, which is
* already color-mix-free, because an invisible focus ring is an accessibility regression rather
* than a cosmetic one. */
@supports not (color: color-mix(in srgb, red 50%, transparent)) {
:root {
/* tints: absent, not solid */
--fs-accent-soft: transparent;
--fs-good-soft: transparent;
--fs-warn-soft: transparent;
--fs-danger-soft: transparent;
--fs-accent-fill: transparent;
--fs-good-fill: transparent;
--fs-warn-fill: transparent;
--fs-danger-fill: transparent;
/* hairlines: the solid colour they are a fraction of */
--fs-accent-line: var(--fs-accent);
--fs-good-line: var(--fs-good);
--fs-warn-line: var(--fs-warn);
--fs-danger-line: var(--fs-danger);
--fs-accent-line-hi: var(--fs-accent);
--fs-good-line-hi: var(--fs-good);
--fs-warn-line-hi: var(--fs-warn);
--fs-danger-line-hi: var(--fs-danger);
/* the two rings, restated: -soft and -fill are transparent above, and a ring nobody can
* see is worse than a heavy one. -solo is already color-mix-free and measured. */
--fs-focus-ring: var(--fs-focus-ring-solo);
--fs-focus-ring-invalid: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-danger);
/* surfaces that are a near-opaque panel anyway */
--fs-glass: var(--fs-panel);
--fs-bar-bg: var(--fs-panel);
--background-color-medium: var(--fs-panel2);
/* depth cues: dropped rather than approximated each is a 1px highlight whose whole
* purpose is subtlety, and an opaque one reads as a stray line. */
--fs-emboss: none;
--fs-text-emboss: none;
--fs-shadow-bar: 0 1px 0 var(--fs-border);
/* the scrim over an uploaded photo: --fs-photo-dim is a percentage this cannot apply, so
* take the fixed dialog scrim. Losing it entirely would leave login text over a photo. */
--fs-photo-scrim: var(--fs-scrim);
/* export tier third-party apps read these, so they must resolve to something.
* -medium keeps the hue, -low degrades to the dim text colour it was mixed toward. */
--border-color-high: var(--fs-border);
--border-color-low: var(--fs-border);
--primary-color-medium: var(--fs-accent);
--primary-color-low: var(--fs-dim);
--error-color-medium: var(--fs-danger);
--error-color-low: var(--fs-dim);
--success-color-medium: var(--fs-good);
--success-color-low: var(--fs-dim);
--warn-color-medium: var(--fs-warn);
--warn-color-low: var(--fs-dim);
}
}
}
+100 -14
View File
@@ -242,14 +242,23 @@
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(2) { body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(2) {
order: 2; /* below the zone-colour line (child 3, order 1) */ order: 2; /* below the zone-colour line (child 3, order 1) */
font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-good); text-align: start; font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-good); text-align: start;
/* Two halves of one mechanism. /* `flex-grow: 1` means "I take the slack", which pushes the traffic to the right edge while
* `nowrap` with no `min-width: 0` means "I do not give way": a flex item's automatic minimum
* size is its min-content, which for nowrap text is the whole string, so the speed cannot be
* squeezed and the TRAFFIC is what wraps.
* `flex-grow: 1` means "I take the slack", which pushes the traffic to the right edge while
* the two share a row. `margin-left: auto` on the traffic is wrong: Chrome counts it when * the two share a row. `margin-left: auto` on the traffic is wrong: Chrome counts it when
* breaking lines, so the figures take a row of their own even on a card they fit. */ * breaking lines, so the figures take a row of their own even on a card they fit.
flex: 1 1 auto; white-space: nowrap; *
* The text WRAPS, and the automatic minimum size is what keeps the wrap tidy. This field used
* to be `nowrap`, which holds for the speeds themselves (`1GbE`, `10 GbE`) and breaks on the
* no-link label one short word in English, three in most other languages. `нет соединения`
* measured 127px in a card that gives it 127 and was CLIPPED, not ellipsised: the reader saw
* `нет соедине`. A label that cannot be read is worse than a card one line taller.
*
* NO `min-width: 0` with it. That would let the flex line squeeze this box to 77px the
* width left beside the port image, which shares this box and the label then broke mid-word
* (`соедине` / `ния`). Left at `auto` the box keeps its min-content, which for wrapping text
* is the longest WORD, so the label breaks between words and the short speeds, having nothing
* to wrap at, do not move at all. `overflow-wrap` is the last resort for a language whose
* single word is wider than the card. */
flex: 1 1 auto; white-space: normal; overflow-wrap: break-word;
} }
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(2) br { display: none; } body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(2) br { display: none; }
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]):has(img[src*="_down.svg"]) > .ifacebox-body:nth-child(2) { color: var(--fs-dim); } body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]):has(img[src*="_down.svg"]) > .ifacebox-body:nth-child(2) { color: var(--fs-dim); }
@@ -332,7 +341,9 @@
/* overflow-wrap dropped base/40-tables.css breaks every cell; the copy that stood here /* overflow-wrap dropped base/40-tables.css breaks every cell; the copy that stood here
* moved nothing against 267 matching cells. */ * moved nothing against 267 matching cells. */
padding-inline-end: min(230px, 50%); padding-inline-end: min(230px, 50%);
font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-text); /* colour and weight are the shared label rule at the end of this file; the size is here
* because it is this row's geometry, not its typography */
font-size: var(--fs-type);
} }
/* On a narrow card the value takes its own line a reserve cannot be made to fit there. /* On a narrow card the value takes its own line a reserve cannot be made to fit there.
@@ -362,11 +373,86 @@
/* Drop the per-row dividers theme/30-tables.css adds (.cbi-section .table .td) /* Drop the per-row dividers theme/30-tables.css adds (.cbi-section .table .td)
* cleaner card. No !important: that rule is `theme`, this file is `page`, so the later * cleaner card. No !important: that rule is `theme`, this file is `page`, so the later
* layer already wins. */ * layer already wins.
body[data-page="admin-status-overview"] :is(.fs-ovl-mem, .fs-ovl-sto) .table .td, *
body[data-page="admin-status-overview"] :is(.fs-ovl-mem, .fs-ovl-sto) .table .th, * Keyed on the table's SHAPE rather than on the three cards the grid happens to name: a
body[data-page="admin-status-overview"] :is(.fs-ovl-mem, .fs-ovl-sto) .cbi-value { border-bottom: 0; } * key/value table is one with no header row, which is the same test the meter rows above
/* System key/value table draws its dividers on .tr (not .td) — remove those too */ * already use. Naming `.fs-ovl-mem/-sto` left System ruled at 1px per row nine dividers down
body[data-page="admin-status-overview"] :is(.fs-ovl-mem, .fs-ovl-sto) .table .tr { border-bottom: 0; } * a card whose two columns are a name and its value, where the alignment already says which is
* which and gave a third-party include of the same shape nothing at all. A DATA table
* (`.tr.table-titles` present: DHCP leases, the wifi station list) keeps its dividers: there
* the row is one record among many and the rule is what separates the records. */
body[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .td,
body[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .th,
body[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr { border-bottom: 0; }
/* Its own rule, not a fourth line in the list above: `.cbi-value` carries no `:has()`, and a
* selector list is not forgiving below the browser floor the whole list would be discarded,
* dividers and all (npm run css-floor, .claude/rules/css.md). */
body[data-page="admin-status-overview"] .cbi-section .cbi-value { border-bottom: 0; }
/* The name in a key/value row is the QUIET half the figure beside it is what the card is
* read for. It came out at --fs-weight (600) in --fs-text, i.e. heavier and brighter than the
* value it introduces, which is the ranking inverted: four bold lines down a Memory card
* competing with the four numbers they label. --fs-dim at normal weight is what the System
* table's first column already resolved to, so the meter cards now read as the same family.
* Data tables are excluded by the same header-row test as the dividers above: there the first
* column is a record's name, not a caption. */
body[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .td:first-child {
font-weight: var(--fs-weight-normal);
color: var(--fs-dim);
}
/* ---- the active interface box ----
*
* theme/45-misc.css fills `.ifacebox-head.active` with --fs-accent and inks it --fs-on-accent.
* On a form that is one small badge; on the Overview it is a full-bleed bar of saturated colour
* across the card three of them at once here (IPv4 Upstream, radio0, radio1), each louder
* than the data underneath and than the page's own controls. The state it encodes is "this
* interface is up", which is a STATUS, so it is said in the status colour and in the text
* rather than in a fill: --fs-good on the same --fs-panel2 every other head carries.
*
* Scoped to this page deliberately: the same head on Network -> Interfaces sits among many
* boxes where the fill is doing real work telling them apart. */
body[data-page="admin-status-overview"] .cbi-section .ifacebox .ifacebox-head.active {
background: var(--fs-panel2);
color: var(--fs-good);
font-weight: var(--fs-weight);
}
/* `L.itemlist` wraps every caption in <strong>, which theme/30-tables.css leaves at 700 so
* inside an interface box the captions (Protocol, Address, Gateway, DNS) are the boldest text
* on the card and the addresses they introduce are not. Same inversion as the key/value rule
* above, same answer; the value keeps --fs-text and needs no rule of its own. */
body[data-page="admin-status-overview"] .cbi-section .ifacebox-body strong {
font-weight: var(--fs-weight-normal);
color: var(--fs-dim);
}
/* The meter's own figure, promoted to match the label it now outranks.
*
* theme/25-progressbar.css prints `attr(title)` above the bar at --fs-type-xs in --fs-dim,
* which was the right pairing while the label beside it was 600 in --fs-text. With the label
* moved down (above), a dim 11px figure left the row with nothing at full strength: on Memory
* that is four bars whose only readable text is the caption. The number is what the card is
* for, so it takes the body size and the body ink; the bar and the placement are untouched.
*
* Overview only. The same meter on Software and in the package manager sits in a form row
* where the value is beside its own field label, not carrying the row on its own. */
body[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .cbi-progressbar::after {
font-size: var(--fs-type);
color: var(--fs-text);
}
/* Row height in the Overview's DATA tables (DHCP leases, the wifi station list).
*
* theme/30-tables.css pads a data cell for a page that IS a table, where the row is the unit of
* work and the padding is its hit target. On the Overview the same table is a card's contents,
* read at a glance and never clicked except on its buttons, and eight leases at that height are
* most of a screen: the DHCP card measures 740px against a System card of 440. One step down
* the space ladder, so it follows the density axis like everything else, and only here. */
body[data-page="admin-status-overview"] .cbi-section .table:has(.tr.table-titles) .td,
body[data-page="admin-status-overview"] .cbi-section .table:has(.tr.table-titles) .th {
padding-block: var(--fs-space-1-5);
}
} }
@@ -682,6 +682,27 @@
#fs-nav-progress[data-state="done"] { transition: none; transform: scaleX(1); } #fs-nav-progress[data-state="done"] { transition: none; transform: scaleX(1); }
} }
/* ---- and the swap itself, when the browser can animate it ----
* The commit is one synchronous DOM move (fs-router.js, swapIn), so this animates a frame rather
* than a render. Same-document view transitions are Baseline since 2025-10-14 (Chrome 111,
* Safari 18, Firefox 144); below that the property parses to nothing and the swap stays instant.
*
* NO `view-transition-name` anywhere, and that is the design: naming `#view` would put two
* elements under one name while the stage is still in the document (fs-router.js stages a second
* `#view`), and a duplicate name aborts the transition; a named element taller than the snapshot
* containing block is also clipped in its own snapshot, which every long status page is. The root
* snapshot is viewport-sized, and bar, rail and sidebar are identical in both halves what
* visibly cross-fades is the content column.
*
* 140ms against the UA's 250ms: the warm navigation this sits on measures 136-142ms median
* (docs/spa-router.md), and a transition longer than the work it covers reads as lag.
*
* `pointer-events`: the `::view-transition` pseudo covers the viewport and would take every click
* for the length of the animation. Reduced motion is handled in fs-router.js, not here the `*`
* rule in 95-a11y-media.css cannot reach a pseudo tree. */
::view-transition { pointer-events: none; }
::view-transition-old(root), ::view-transition-new(root) { animation-duration: 140ms; }
/* ---------- footer ---------- /* ---------- footer ----------
* Bare `footer`, not `footer.fs-footer`: the theme owns footer paint outright (absorbed * Bare `footer`, not `footer.fs-footer`: the theme owns footer paint outright (absorbed
* from base, which keeps only the flex skeleton), so a third-party view emitting its own * from base, which keeps only the flex skeleton), so a third-party view emitting its own
@@ -519,9 +519,14 @@
* mid-card (measured). Stretched, both cells take the flex line's height and the * mid-card (measured). Stretched, both cells take the flex line's height and the
* separators meet; content still sits at the top (the cells are blocks). */ * separators meet; content still sits at the top (the cells are blocks). */
/* A `<tfoot>` with no `.tr` inside it IS the row see the footer block below for which view /* A `<tfoot>` with no `.tr` inside it IS the row see the footer block below for which view
* writes which shape. Here rather than in a rule of its own: `css-dup` compares bodies of three * writes which shape.
* declarations or more, so a second copy of these two would sit unflagged until they diverged. */ *
.table.fs-stacked .tr, * TWO rules, not one list: a selector list is not forgiving, so a browser without `:has()`
* (Firefox before 121) throws away the WHOLE list, and `.table.fs-stacked .tr` every card on
* every stacked table loses `display: flex` with it. Split, the `:has()` half degrades alone.
* The duplicated body is under `css-dup`'s three-declaration floor, so it will not be flagged;
* the two must change together. */
.table.fs-stacked .tr { display: flex; flex-wrap: wrap; }
.table.fs-stacked tfoot:not(:has(> .tr)), .table.fs-stacked tfoot:not(:has(> .tr)),
.table.fs-stacked .tfoot:not(:has(> .tr)) { display: flex; flex-wrap: wrap; } .table.fs-stacked .tfoot:not(:has(> .tr)) { display: flex; flex-wrap: wrap; }
.table.fs-stacked .td { .table.fs-stacked .td {
@@ -3,6 +3,39 @@
* viewport is silently CUT on the right rather than scrolled to. A device sweep found the * viewport is silently CUT on the right rather than scrolled to. A device sweep found the
* offenders below; each fix lets the control or table shrink or wrap. Tablets were already * offenders below; each fix lets the control or table shrink or wrap. Tablets were already
* clean. */ * clean. */
/* A pair of buttons an app packs into one `inline-flex` must be allowed to wrap its labels.
*
* `white-space: pre` on a button is stock luci-theme-bootstrap sets it too and it holds
* while the label is short. Ours are wider than stock at the same job: measured on
* ssclash's split button at 320, `Сохранить и перезагрузить конфигурацию` is 295px against
* stock's 245, because this theme's face is wider than the system stack even at a smaller
* size (13px vs 14px), and the 14px side padding adds 12 of the 50. The app's wrapper is
* `inline-flex`, so it takes the buttons' max-content 324px inside a 288px column and
* overflows by 36. Stock fits only because its buttons are narrower.
*
* Narrowing the buttons would be re-typesetting the theme to suit one app. Letting the label
* wrap costs nothing where it already fits a short label has no wrap point and turns the
* one case that does not into two lines. Scoped to the width where it happens: at 390 the
* same page is clean.
*
* `min-width: 0` with it, or the automatic minimum keeps the button at its longest word and
* the flex line cannot shrink it at all.
*
* `word-break: normal` is the third half of it. base/10-reset.css gives every button
* `break-all` carried over from the fork, and inert while the label cannot wrap at all. The
* moment it can, `break-all` takes precedence over word boundaries and the label breaks mid-word
* (`Сохранить и перезагрузить ко` / `нфиг`). `overflow-wrap: break-word` keeps the escape hatch
* for a single word wider than the button, which is what `break-all` was there for. */
@container fs-view (max-width: 360px) {
#view .cbi-button, #view .btn,
#view input[type="button"], #view input[type="submit"], #view input[type="reset"] {
white-space: normal;
min-width: 0;
word-break: normal;
overflow-wrap: break-word;
}
}
@media (max-width: 767px) { @media (max-width: 767px) {
/* The foreign-<table> scroll fallback used to live here, guarded by this query. It does not /* The foreign-<table> scroll fallback used to live here, guarded by this query. It does not
* any more: whether a table fits is a property of its CONTENT and its COLUMN, never of the * any more: whether a table fits is a property of its CONTENT and its COLUMN, never of the
@@ -69,10 +69,22 @@
it, so the CI gate cannot see the difference. */ it, so the CI gate cannot see the difference. */
fs_defaults.layout = fsd.layout || config.main?.footstrap_layout || ''; fs_defaults.layout = fsd.layout || config.main?.footstrap_layout || '';
/* Optional companion packages announce themselves in the theme's own config —
`uci add_list footstrap.settings.plugin=<module>` from the package's uci-defaults — rather
than being named in this tree. The chrome requires each name and knows nothing else about it,
so the edge points inwards exactly as fs-router.onNavigate does.
Whitelisted to the shape of a LuCI module name before it is printed into a <script>: writing
that file needs root, but a value that reaches an inline script is checked anyway. */
let fs_plugins = fsd.plugin ?? [];
if (type(fs_plugins) != 'array')
fs_plugins = [ fs_plugins ];
fs_plugins = filter(fs_plugins, (m) => type(m) == 'string' && match(m, /^[a-zA-Z0-9._-]{1,48}$/));
http.prepare_content('text/html; charset=UTF-8'); http.prepare_content('text/html; charset=UTF-8');
-%} -%}
{% include('themes/footstrap/partials/head', { boardinfo, fs_defaults }) %} {% include('themes/footstrap/partials/head', { boardinfo, fs_defaults, fs_plugins }) %}
{# {#
data-page carries the DISPATCH path (ctx.path), not request_path: on a firstchild route data-page carries the DISPATCH path (ctx.path), not request_path: on a firstchild route
@@ -146,12 +146,19 @@
{# The router-wide Appearance defaults, read by both the pre-paint below and fs-prefs.js's {# The router-wide Appearance defaults, read by both the pre-paint below and fs-prefs.js's
current*(), so the Appearance controls show the effective default when this browser has no current*(), so the Appearance controls show the effective default when this browser has no
localStorage. See the sanitiser above for the unset encodings. #} localStorage. See the sanitiser above for the unset encodings. #}
<script>window.__fsPlugins={{ sprintf('%J', fs_plugins) }};</script>
<script>window.__fsSD={layout:"{{ _sd_lay }}",darkmode:"{{ _sd_dark }}",palette:"{{ _sd_pal }}",wallpaper:"{{ _sd_wall }}",tint:"{{ _sd_tint }}",accent:"{{ _sd_acc }}",good:"{{ _sd_good }}",warn:"{{ _sd_warn }}",danger:"{{ _sd_dang }}",card:"{{ _sd_card }}",control:"{{ _sd_ctrl }}",bar:"{{ _sd_bar }}",line:"{{ _sd_line }}",rounding:{{ _sd_round }},autocollapse:"{{ _sd_ac }}",login_bg:"{{ _sd_lbg }}",tint_strength:{{ _sd_tstr }},photo_dim:{{ _sd_pdim }},density:"{{ _sd_dens }}",pattern:"{{ _sd_pat }}",pattern_size:{{ _sd_psize }},pattern_strength:{{ _sd_pstr }},pattern_ink:"{{ _sd_pink }}"};</script> <script>window.__fsSD={layout:"{{ _sd_lay }}",darkmode:"{{ _sd_dark }}",palette:"{{ _sd_pal }}",wallpaper:"{{ _sd_wall }}",tint:"{{ _sd_tint }}",accent:"{{ _sd_acc }}",good:"{{ _sd_good }}",warn:"{{ _sd_warn }}",danger:"{{ _sd_dang }}",card:"{{ _sd_card }}",control:"{{ _sd_ctrl }}",bar:"{{ _sd_bar }}",line:"{{ _sd_line }}",rounding:{{ _sd_round }},autocollapse:"{{ _sd_ac }}",login_bg:"{{ _sd_lbg }}",tint_strength:{{ _sd_tstr }},photo_dim:{{ _sd_pdim }},density:"{{ _sd_dens }}",pattern:"{{ _sd_pat }}",pattern_size:{{ _sd_psize }},pattern_strength:{{ _sd_pstr }},pattern_ink:"{{ _sd_pink }}"};</script>
<script> <script>
/* Each block below pre-paints one Appearance axis before the first frame, duplicating a /* Each IIFE below pre-paints one Appearance axis before the first frame, duplicating a
live applier in fs-prefs.js. They cannot share code — this runs before the module live applier in fs-prefs.js. They cannot share code — this runs before the module
loader exists — so tools/axes.mjs derives the contract (keys, attributes, properties, loader exists — so tools/axes.mjs derives the contract (keys, attributes, properties,
ranges, default, order) from the JS and holds this template to it. ranges, default, order) from the JS and holds this template to it.
ONE element for all of them, and exactly two in this file: the data blob above carries
interpolations and this one carries none. That is the split tools/lib/ut-scripts.mjs
keys off — an interpolated body is not JS until the server renders it, so it is exempt
from the lint and everything mergeable into it would go unchecked with it. Two is also
what a Content-Security-Policy would need nonces for, should uhttpd ever send one.
Precedence for every axis: localStorage ?? router default (window.__fsSD) ?? built-in. Precedence for every axis: localStorage ?? router default (window.__fsSD) ?? built-in.
Dark mode: a saved preference, else the router default, else the OS. */ Dark mode: a saved preference, else the router default, else the OS. */
(() => { (() => {
@@ -189,8 +196,7 @@
set(m.matches); set(m.matches);
}); });
})(); })();
</script>
<script>
/* the remaining axes, in the precedence stated above. */ /* the remaining axes, in the precedence stated above. */
(() => { (() => {
/* Guarded per read, not once around the block: a browser that refuses storage throws /* Guarded per read, not once around the block: a browser that refuses storage throws
@@ -320,8 +326,7 @@
root.style.setProperty('--fs-photo-dim', pd + '%'); root.style.setProperty('--fs-photo-dim', pd + '%');
} catch (e) {} } catch (e) {}
})(); })();
</script>
<script>
/* the icon-rail preference, so a reload does not flash the full sidebar first. Not a /* the icon-rail preference, so a reload does not flash the full sidebar first. Not a
router-wide default: a transient chrome collapse, not an appearance choice. */ router-wide default: a transient chrome collapse, not an appearance choice. */
(() => { (() => {
@@ -330,8 +335,7 @@
document.querySelector(':root').setAttribute('data-rail', 'true'); document.querySelector(':root').setAttribute('data-rail', 'true');
} catch (e) {} } catch (e) {}
})(); })();
</script>
<script>
/* the client's saved layout overrides the server's stamp above, before paint. Only a /* the client's saved layout overrides the server's stamp above, before paint. Only a
known value is honoured: a corrupt one leaves the stamp standing rather than blanking known value is honoured: a corrupt one leaves the stamp standing rather than blanking
the attribute every layout rule matches on. */ the attribute every layout rule matches on. */
@@ -345,6 +349,17 @@
</script> </script>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="darkreader-lock"> <meta name="darkreader-lock">
{# The colour a mobile browser paints its own chrome with — address bar, task switcher, the
installed app's title bar. Without it a dark page keeps a white bar above it.
The literal is the default palette's page colour and the SAME value manifest.json carries,
for the same reason: neither file can be rendered per request. It is a starting value
only — fs-prefs.js repaints this tag from the live body background, so it follows palette,
mode and every tint axis. tests/theme-color.test.mjs holds the two literals equal.
One tag, no `media` pair: a media-qualified tag would win over this one for a viewer whose
OS is dark but who chose light HERE, and the choice is the theme's to honour. #}
<meta name="theme-color" content="#f6f8fa">
{# The sheet link below is emitted only when the TOKEN says the sheet exists — set the option {# The sheet link below is emitted only when the TOKEN says the sheet exists — set the option
after the file, never before. A preload is not in the stylesheet, so dropping the after the file, never before. A preload is not in the stylesheet, so dropping the
@font-face rules alone left the pair behind and every page went on asking the router for @font-face rules alone left the pair behind and every page went on asking the router for
@@ -388,9 +403,9 @@
that extension, so the same bytes come back `application/octet-stream` there and with a that extension, so the same bytes come back `application/octet-stream` there and with a
JSON type only as `.json`. The map is compiled into uhttpd. #} JSON type only as `.json`. The map is compiled into uhttpd. #}
<link rel="manifest" href="{{ media }}/manifest.json?v={{ pkgs_update_time }}"> <link rel="manifest" href="{{ media }}/manifest.json?v={{ pkgs_update_time }}">
{# iOS reads this link and never the manifest's icons array, and it scales the 512 square the {# iOS reads this link and never the manifest's icons array, and it scales the 192 square the
manifest also uses. One raster serves both — tools/build-icons.mjs. #} manifest also uses down to its own 180. One raster serves both — tools/build-icons.mjs. #}
<link rel="apple-touch-icon" href="{{ media }}/app-icon-512.png"> <link rel="apple-touch-icon" href="{{ media }}/app-icon-192.png">
{% if (dispatched?.css): %} {% if (dispatched?.css): %}
{# The `css` property of the dispatched menu.d node: an app naming a stylesheet the server {# The `css` property of the dispatched menu.d node: an app naming a stylesheet the server
should link for its page, instead of the view injecting one at module eval. should link for its page, instead of the view injecting one at module eval.
+2 -2
View File
@@ -14,8 +14,8 @@ MISE_HASH_x86_64:=3832f39c325e343f81fe3d92b2447c5d1a5eea1bc85092bb7b6c2580622264
MISE_HASH_aarch64:=06186cfbfe947049b21d58575fb0ea800cc26ed1375f20f4b678cb3a9d679437 MISE_HASH_aarch64:=06186cfbfe947049b21d58575fb0ea800cc26ed1375f20f4b678cb3a9d679437
PKG_NAME:=mise PKG_NAME:=mise
PKG_VERSION:=2026.8.14 PKG_VERSION:=2026.8.15
PKG_RELEASE:=1 PKG_RELEASE:=2
PKG_SOURCE:=$(PKG_NAME)-v$(PKG_VERSION)-linux-$(MISE_ARCH)-musl.tar.gz PKG_SOURCE:=$(PKG_NAME)-v$(PKG_VERSION)-linux-$(MISE_ARCH)-musl.tar.gz
PKG_SOURCE_URL:=https://github.com/jdx/mise/releases/download/v$(PKG_VERSION)/ PKG_SOURCE_URL:=https://github.com/jdx/mise/releases/download/v$(PKG_VERSION)/
PKG_HASH:=skip PKG_HASH:=skip
+1 -1
View File
@@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=mosdns PKG_NAME:=mosdns
PKG_VERSION:=5.3.4 PKG_VERSION:=5.3.4
PKG_RELEASE:=18 PKG_RELEASE:=19
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/IrineSistiana/mosdns/tar.gz/v$(PKG_VERSION)? PKG_SOURCE_URL:=https://codeload.github.com/IrineSistiana/mosdns/tar.gz/v$(PKG_VERSION)?
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
From 7a0bf53ad7219ab07693f93be4700551802bde5d Mon Sep 17 00:00:00 2001
From: sbwml <admin@cooluc.com>
Date: Fri, 28 Aug 2026 21:41:05 +0800
Subject: [PATCH 2/5] fix(stats_api): separate blocked domain counting in top
stats
Avoid incrementing non-blocked top domain counter when a query is blocked.
Signed-off-by: sbwml <admin@cooluc.com>
---
plugin/executable/stats_api/stats_api.go | 9 +++++----
plugin/executable/stats_api/stats_api_test.go | 2 +-
2 files changed, 6 insertions(+), 5 deletions(-)
--- a/plugin/executable/stats_api/stats_api.go
+++ b/plugin/executable/stats_api/stats_api.go
@@ -200,14 +200,15 @@ func (t *TopStats) Record(domain, client
defer t.mu.Unlock()
if domain != "" {
- t.topDomains[domain]++
+ if isBlocked {
+ t.topBlocked[domain]++
+ } else {
+ t.topDomains[domain]++
+ }
}
if clientIP != "" {
t.topClients[clientIP]++
}
- if isBlocked && domain != "" {
- t.topBlocked[domain]++
- }
}
func (t *TopStats) Clear() {
--- a/plugin/executable/stats_api/stats_api_test.go
+++ b/plugin/executable/stats_api/stats_api_test.go
@@ -98,7 +98,7 @@ func TestTopStats(t *testing.T) {
domains, clients, blocked := top.GetTop(10)
- if len(domains) == 0 || domains[0].Domain != "a.com." || domains[0].Count != 3 {
+ if len(domains) == 0 || domains[0].Domain != "a.com." || domains[0].Count != 2 {
t.Errorf("top domains mismatch: %+v", domains)
}
@@ -0,0 +1,72 @@
From 1fba45727126115b09c7f73d944e960643b21d24 Mon Sep 17 00:00:00 2001
From: sbwml <admin@cooluc.com>
Date: Fri, 28 Aug 2026 22:01:35 +0800
Subject: [PATCH 3/5] fix(stats_api): filter blocked domains in top list and
ignore HTTPS queries
- Filter out blocked domains from top domains list
- Do not mark HTTPS type queries as blocked on NXDOMAIN/REFUSED
Signed-off-by: sbwml <admin@cooluc.com>
---
plugin/executable/stats_api/stats_api.go | 12 ++++++++++--
plugin/executable/stats_api/stats_api_test.go | 13 ++++---------
2 files changed, 14 insertions(+), 11 deletions(-)
--- a/plugin/executable/stats_api/stats_api.go
+++ b/plugin/executable/stats_api/stats_api.go
@@ -259,7 +259,15 @@ func (t *TopStats) GetTop(limit int) ([]
if limit <= 0 {
limit = 10
}
- topDomains := getSortedTop(t.topDomains, false, limit)
+
+ cleanTopDomains := make(map[string]uint64, len(t.topDomains))
+ for domain, count := range t.topDomains {
+ if _, isBlocked := t.topBlocked[domain]; !isBlocked {
+ cleanTopDomains[domain] = count
+ }
+ }
+
+ topDomains := getSortedTop(cleanTopDomains, false, limit)
topClients := getSortedTop(t.topClients, true, limit)
topBlocked := getSortedTop(t.topBlocked, false, limit)
return topDomains, topClients, topBlocked
@@ -614,7 +622,7 @@ func (s *StatsAPI) Exec(ctx context.Cont
status = fmt.Sprintf("RCODE%d", r.Rcode)
}
- if r.Rcode == dns.RcodeNameError || r.Rcode == dns.RcodeRefused {
+ if (r.Rcode == dns.RcodeNameError || r.Rcode == dns.RcodeRefused) && qQuestion.Qtype != dns.TypeHTTPS {
isBlocked = true
}
--- a/plugin/executable/stats_api/stats_api_test.go
+++ b/plugin/executable/stats_api/stats_api_test.go
@@ -92,22 +92,17 @@ func TestTopStats(t *testing.T) {
top := NewTopStats()
top.Record("a.com.", "192.168.1.1", false)
- top.Record("a.com.", "192.168.1.1", true)
+ top.Record("a.com.", "192.168.1.1", false)
top.Record("b.com.", "192.168.1.2", true)
- top.Record("a.com.", "192.168.1.2", false)
- domains, clients, blocked := top.GetTop(10)
+ domains, _, blocked := top.GetTop(10)
if len(domains) == 0 || domains[0].Domain != "a.com." || domains[0].Count != 2 {
t.Errorf("top domains mismatch: %+v", domains)
}
- if len(clients) < 2 {
- t.Fatalf("expected at least 2 clients, got %d", len(clients))
- }
-
- if len(blocked) < 2 {
- t.Fatalf("expected at least 2 blocked domains, got %d", len(blocked))
+ if len(blocked) == 0 || blocked[0].Domain != "b.com." {
+ t.Errorf("top blocked mismatch: %+v", blocked)
}
// Test Clear
@@ -0,0 +1,756 @@
From 7279f1514e11b8a5bf6717a0127d73ecf36014a3 Mon Sep 17 00:00:00 2001
From: sbwml <admin@cooluc.com>
Date: Sat, 29 Aug 2026 12:59:42 +0800
Subject: [PATCH 4/5] feat(stats_api): add data persistence support
Add dump_file and dump_interval configuration to support periodically
persisting and restoring metrics, top stats, and query logs across restarts.
Signed-off-by: sbwml <admin@cooluc.com>
---
plugin/executable/stats_api/stats_api.go | 314 +++++++++++++++++-
plugin/executable/stats_api/stats_api_test.go | 244 ++++++++++++++
2 files changed, 552 insertions(+), 6 deletions(-)
--- a/plugin/executable/stats_api/stats_api.go
+++ b/plugin/executable/stats_api/stats_api.go
@@ -24,8 +24,10 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"math"
"net/http"
+ "os"
"sort"
"strconv"
"strings"
@@ -35,13 +37,18 @@ import (
"github.com/IrineSistiana/mosdns/v5/coremain"
"github.com/IrineSistiana/mosdns/v5/pkg/query_context"
+ "github.com/IrineSistiana/mosdns/v5/pkg/utils"
"github.com/IrineSistiana/mosdns/v5/plugin/executable/sequence"
"github.com/go-chi/chi/v5"
+ "github.com/klauspost/compress/gzip"
"github.com/miekg/dns"
"go.uber.org/zap"
)
-const PluginType = "stats_api"
+const (
+ PluginType = "stats_api"
+ statsDumpHeader = "mosdns_stats_v1"
+)
func init() {
coremain.RegNewPluginFunc(PluginType, Init, func() any { return new(Args) })
@@ -51,14 +58,17 @@ func init() {
var _ sequence.RecursiveExecutable = (*StatsAPI)(nil)
type Args struct {
- Listen string `yaml:"listen"`
- Capacity int `yaml:"capacity"`
+ Listen string `yaml:"listen"`
+ Capacity int `yaml:"capacity"`
+ DumpFile string `yaml:"dump_file"`
+ DumpInterval int `yaml:"dump_interval"`
}
func (a *Args) init() {
if a.Capacity <= 0 {
a.Capacity = 2000
}
+ utils.SetDefaultUnsignNum(&a.DumpInterval, 600)
}
type AnswerDTO struct {
@@ -174,6 +184,42 @@ func (r *RingBuffer) QueryLogs(limit, of
return total, filtered[offset:end]
}
+// Export returns logs in chronological order (oldest first) and current seqID.
+func (r *RingBuffer) Export() ([]LogEntry, uint64) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ entries := make([]LogEntry, 0, r.count)
+ for i := 0; i < r.count; i++ {
+ idx := (r.head - r.count + i + r.capacity) % r.capacity
+ entries = append(entries, r.buf[idx])
+ }
+ return entries, r.seqID
+}
+
+// Import restores logs into ring buffer adapting to current capacity.
+func (r *RingBuffer) Import(entries []LogEntry, seqID uint64) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ r.buf = make([]LogEntry, r.capacity)
+ r.head = 0
+ r.count = 0
+ r.seqID = seqID
+
+ start := 0
+ if len(entries) > r.capacity {
+ start = len(entries) - r.capacity
+ }
+ for i := start; i < len(entries); i++ {
+ r.buf[r.head] = entries[i]
+ r.head = (r.head + 1) % r.capacity
+ if r.count < r.capacity {
+ r.count++
+ }
+ }
+}
+
type TopItem struct {
Domain string `json:"domain,omitempty"`
ClientIP string `json:"client_ip,omitempty"`
@@ -220,6 +266,43 @@ func (t *TopStats) Clear() {
t.topBlocked = make(map[string]uint64)
}
+func (t *TopStats) Export() (map[string]uint64, map[string]uint64, map[string]uint64) {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+
+ domains := make(map[string]uint64, len(t.topDomains))
+ for k, v := range t.topDomains {
+ domains[k] = v
+ }
+ clients := make(map[string]uint64, len(t.topClients))
+ for k, v := range t.topClients {
+ clients[k] = v
+ }
+ blocked := make(map[string]uint64, len(t.topBlocked))
+ for k, v := range t.topBlocked {
+ blocked[k] = v
+ }
+ return domains, clients, blocked
+}
+
+func (t *TopStats) Import(domains, clients, blocked map[string]uint64) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ t.topDomains = make(map[string]uint64, len(domains))
+ for k, v := range domains {
+ t.topDomains[k] = v
+ }
+ t.topClients = make(map[string]uint64, len(clients))
+ for k, v := range clients {
+ t.topClients[k] = v
+ }
+ t.topBlocked = make(map[string]uint64, len(blocked))
+ for k, v := range blocked {
+ t.topBlocked[k] = v
+ }
+}
+
func getSortedTop(m map[string]uint64, isClient bool, limit int) []TopItem {
type pair struct {
key string
@@ -286,6 +369,12 @@ type HistoryBucket struct {
Cached atomic.Uint64
}
+type HistoryBucketData struct {
+ Total uint64 `json:"total"`
+ Blocked uint64 `json:"blocked"`
+ Cached uint64 `json:"cached"`
+}
+
type HistoryStats struct {
mu sync.RWMutex
points map[int64]*HistoryBucket
@@ -362,6 +451,55 @@ func (h *HistoryStats) GetHistory(numPoi
return res
}
+func (h *HistoryStats) Export() map[int64]HistoryBucketData {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+
+ res := make(map[int64]HistoryBucketData, len(h.points))
+ for k, v := range h.points {
+ if v != nil {
+ res[k] = HistoryBucketData{
+ Total: v.Total.Load(),
+ Blocked: v.Blocked.Load(),
+ Cached: v.Cached.Load(),
+ }
+ }
+ }
+ return res
+}
+
+func (h *HistoryStats) Import(points map[int64]HistoryBucketData) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ h.points = make(map[int64]*HistoryBucket, len(points))
+ cutoff := time.Now().UTC().Add(-48 * time.Hour).Unix()
+ for k, v := range points {
+ if k >= cutoff {
+ bucket := &HistoryBucket{}
+ bucket.Total.Store(v.Total)
+ bucket.Blocked.Store(v.Blocked)
+ bucket.Cached.Store(v.Cached)
+ h.points[k] = bucket
+ }
+ }
+}
+
+type StatsDumpData struct {
+ Version int `json:"version"`
+ Timestamp int64 `json:"timestamp"`
+ TotalQueries uint64 `json:"total_queries"`
+ BlockedQueries uint64 `json:"blocked_queries"`
+ CachedQueries uint64 `json:"cached_queries"`
+ TotalLatencyUs uint64 `json:"total_latency_us"`
+ TopDomains map[string]uint64 `json:"top_domains,omitempty"`
+ TopClients map[string]uint64 `json:"top_clients,omitempty"`
+ TopBlocked map[string]uint64 `json:"top_blocked,omitempty"`
+ History map[int64]HistoryBucketData `json:"history,omitempty"`
+ Logs []LogEntry `json:"logs,omitempty"`
+ SeqID uint64 `json:"seq_id,omitempty"`
+}
+
type StatsAPI struct {
args *Args
logger *zap.Logger
@@ -375,8 +513,10 @@ type StatsAPI struct {
cachedQueries atomic.Uint64
totalLatencyUs atomic.Uint64
- httpServer *http.Server
- closeOnce sync.Once
+ updatedCount atomic.Uint64
+ closeNotify chan struct{}
+ httpServer *http.Server
+ closeOnce sync.Once
}
func Init(bp *coremain.BP, args any) (any, error) {
@@ -390,6 +530,8 @@ func QuickSetup(bq sequence.BQ, s string
fields := strings.Fields(s)
listen := ""
capacity := 2000
+ dumpFile := ""
+ dumpInterval := 600
if len(fields) > 0 {
listen = fields[0]
}
@@ -398,7 +540,20 @@ func QuickSetup(bq sequence.BQ, s string
capacity = c
}
}
- return NewStatsAPI(&Args{Listen: listen, Capacity: capacity}, bq.L()), nil
+ if len(fields) > 2 {
+ dumpFile = fields[2]
+ }
+ if len(fields) > 3 {
+ if d, err := strconv.Atoi(fields[3]); err == nil && d > 0 {
+ dumpInterval = d
+ }
+ }
+ return NewStatsAPI(&Args{
+ Listen: listen,
+ Capacity: capacity,
+ DumpFile: dumpFile,
+ DumpInterval: dumpInterval,
+ }, bq.L()), nil
}
func NewStatsAPI(args *Args, logger *zap.Logger) *StatsAPI {
@@ -412,8 +567,14 @@ func NewStatsAPI(args *Args, logger *zap
ringBuffer: NewRingBuffer(args.Capacity),
topStats: NewTopStats(),
historyStats: NewHistoryStats(),
+ closeNotify: make(chan struct{}),
}
+ if err := s.loadDump(); err != nil {
+ s.logger.Error("failed to load stats dump", zap.Error(err))
+ }
+ s.startDumpLoop()
+
if len(args.Listen) > 0 {
srv := &http.Server{
Addr: args.Listen,
@@ -450,6 +611,8 @@ func (s *StatsAPI) Router() *chi.Mux {
r.Get("/api/v1/logs", s.handleLogs)
r.Get("/api/v1/top", s.handleTop)
r.Get("/api/v1/history", s.handleHistory)
+ r.Get("/api/v1/dump", s.handleDump)
+ r.Post("/api/v1/load_dump", s.handleLoadDump)
r.Post("/api/v1/logs/clear", s.handleClearLogs)
r.Post("/api/v1/cache/clear", s.handleClearCache)
@@ -560,9 +723,27 @@ func (s *StatsAPI) handleHistory(w http.
})
}
+func (s *StatsAPI) handleDump(w http.ResponseWriter, req *http.Request) {
+ w.Header().Set("Content-Type", "application/octet-stream")
+ if err := s.writeDump(w); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
+func (s *StatsAPI) handleLoadDump(w http.ResponseWriter, req *http.Request) {
+ if err := s.readDump(req.Body); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ s.updatedCount.Add(1)
+ w.WriteHeader(http.StatusOK)
+}
+
func (s *StatsAPI) handleClearLogs(w http.ResponseWriter, req *http.Request) {
s.ringBuffer.Clear()
s.topStats.Clear()
+ s.updatedCount.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
@@ -579,6 +760,122 @@ func (s *StatsAPI) handleClearCache(w ht
})
}
+func (s *StatsAPI) writeDump(w io.Writer) error {
+ gw, err := gzip.NewWriterLevel(w, gzip.BestSpeed)
+ if err != nil {
+ return err
+ }
+ gw.Name = statsDumpHeader
+
+ data := StatsDumpData{
+ Version: 1,
+ Timestamp: time.Now().Unix(),
+ TotalQueries: s.totalQueries.Load(),
+ BlockedQueries: s.blockedQueries.Load(),
+ CachedQueries: s.cachedQueries.Load(),
+ TotalLatencyUs: s.totalLatencyUs.Load(),
+ }
+
+ data.TopDomains, data.TopClients, data.TopBlocked = s.topStats.Export()
+ data.History = s.historyStats.Export()
+ data.Logs, data.SeqID = s.ringBuffer.Export()
+
+ if err := json.NewEncoder(gw).Encode(&data); err != nil {
+ _ = gw.Close()
+ return fmt.Errorf("failed to encode stats dump: %w", err)
+ }
+
+ return gw.Close()
+}
+
+func (s *StatsAPI) readDump(r io.Reader) error {
+ gr, err := gzip.NewReader(r)
+ if err != nil {
+ return fmt.Errorf("failed to create gzip reader: %w", err)
+ }
+ defer gr.Close()
+
+ if gr.Name != statsDumpHeader {
+ return fmt.Errorf("invalid stats dump header: got %s, want %s", gr.Name, statsDumpHeader)
+ }
+
+ var data StatsDumpData
+ if err := json.NewDecoder(gr).Decode(&data); err != nil {
+ return fmt.Errorf("failed to decode stats dump: %w", err)
+ }
+
+ s.totalQueries.Store(data.TotalQueries)
+ s.blockedQueries.Store(data.BlockedQueries)
+ s.cachedQueries.Store(data.CachedQueries)
+ s.totalLatencyUs.Store(data.TotalLatencyUs)
+
+ s.topStats.Import(data.TopDomains, data.TopClients, data.TopBlocked)
+ s.historyStats.Import(data.History)
+ s.ringBuffer.Import(data.Logs, data.SeqID)
+
+ return nil
+}
+
+func (s *StatsAPI) loadDump() error {
+ if len(s.args.DumpFile) == 0 {
+ return nil
+ }
+ f, err := os.Open(s.args.DumpFile)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ s.logger.Info("stats dump file does not exist, starting with empty stats", zap.String("file", s.args.DumpFile))
+ return nil
+ }
+ return err
+ }
+ defer f.Close()
+
+ if err := s.readDump(f); err != nil {
+ return err
+ }
+ s.logger.Info("stats dump loaded successfully", zap.String("file", s.args.DumpFile))
+ return nil
+}
+
+func (s *StatsAPI) dumpStats() error {
+ if len(s.args.DumpFile) == 0 {
+ return nil
+ }
+ f, err := os.Create(s.args.DumpFile)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ if err := s.writeDump(f); err != nil {
+ return fmt.Errorf("failed to write stats dump, %w", err)
+ }
+ s.logger.Info("stats dumped successfully", zap.String("file", s.args.DumpFile))
+ return nil
+}
+
+func (s *StatsAPI) startDumpLoop() {
+ if len(s.args.DumpFile) == 0 {
+ return
+ }
+ go func() {
+ ticker := time.NewTicker(time.Duration(s.args.DumpInterval) * time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ if s.updatedCount.Swap(0) > 0 {
+ if err := s.dumpStats(); err != nil {
+ s.logger.Error("failed to dump stats", zap.Error(err))
+ }
+ }
+ case <-s.closeNotify:
+ return
+ }
+ }
+ }()
+}
+
func (s *StatsAPI) Exec(ctx context.Context, qCtx *query_context.Context, next sequence.ChainWalker) error {
start := time.Now()
err := next.ExecNext(ctx, qCtx)
@@ -586,6 +883,7 @@ func (s *StatsAPI) Exec(ctx context.Cont
s.totalQueries.Add(1)
s.totalLatencyUs.Add(uint64(elapsed.Microseconds()))
+ s.updatedCount.Add(1)
var clientIP string
if clientAddr := qCtx.ServerMeta.ClientAddr; clientAddr.IsValid() {
@@ -725,6 +1023,10 @@ func (s *StatsAPI) Exec(ctx context.Cont
func (s *StatsAPI) Close() error {
s.closeOnce.Do(func() {
+ close(s.closeNotify)
+ if err := s.dumpStats(); err != nil {
+ s.logger.Error("failed to dump stats on close", zap.Error(err))
+ }
if s.httpServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
--- a/plugin/executable/stats_api/stats_api_test.go
+++ b/plugin/executable/stats_api/stats_api_test.go
@@ -20,16 +20,20 @@
package stats_api
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"testing"
"time"
"github.com/IrineSistiana/mosdns/v5/pkg/query_context"
"github.com/IrineSistiana/mosdns/v5/plugin/executable/sequence"
+ "github.com/klauspost/compress/gzip"
"github.com/miekg/dns"
"go.uber.org/zap"
)
@@ -88,6 +92,65 @@ func TestRingBuffer(t *testing.T) {
}
}
+func TestRingBufferExportImport(t *testing.T) {
+ rb1 := NewRingBuffer(5)
+ for i := 1; i <= 8; i++ {
+ rb1.Push(LogEntry{
+ Domain: fmt.Sprintf("domain%d.com.", i),
+ ClientIP: "10.0.0.1",
+ })
+ }
+
+ exported, seqID := rb1.Export()
+ if len(exported) != 5 {
+ t.Fatalf("expected 5 exported entries, got %d", len(exported))
+ }
+ if seqID != 8 {
+ t.Errorf("expected seqID 8, got %d", seqID)
+ }
+ if exported[0].Domain != "domain4.com." {
+ t.Errorf("expected oldest in exported to be domain4.com., got %s", exported[0].Domain)
+ }
+ if exported[4].Domain != "domain8.com." {
+ t.Errorf("expected newest in exported to be domain8.com., got %s", exported[4].Domain)
+ }
+
+ // Import into same capacity
+ rb2 := NewRingBuffer(5)
+ rb2.Import(exported, seqID)
+ total, logs2 := rb2.QueryLogs(10, 0, "", "all")
+ if total != 5 || len(logs2) != 5 {
+ t.Fatalf("expected 5 logs in rb2, got %d", total)
+ }
+ if logs2[0].Domain != "domain8.com." {
+ t.Errorf("expected newest log to be domain8.com., got %s", logs2[0].Domain)
+ }
+ if logs2[4].Domain != "domain4.com." {
+ t.Errorf("expected oldest log to be domain4.com., got %s", logs2[4].Domain)
+ }
+
+ // Test pushing another entry to rb2 to verify seqID continues
+ rb2.Push(LogEntry{Domain: "domain9.com."})
+ _, logsAfterPush := rb2.QueryLogs(1, 0, "", "all")
+ if logsAfterPush[0].Domain != "domain9.com." {
+ t.Errorf("expected newest log domain9.com., got %s", logsAfterPush[0].Domain)
+ }
+
+ // Import into smaller capacity (3)
+ rb3 := NewRingBuffer(3)
+ rb3.Import(exported, seqID)
+ total3, logs3 := rb3.QueryLogs(10, 0, "", "all")
+ if total3 != 3 || len(logs3) != 3 {
+ t.Fatalf("expected 3 logs in rb3, got %d", total3)
+ }
+ if logs3[0].Domain != "domain8.com." {
+ t.Errorf("expected newest log domain8.com., got %s", logs3[0].Domain)
+ }
+ if logs3[2].Domain != "domain6.com." {
+ t.Errorf("expected oldest log domain6.com., got %s", logs3[2].Domain)
+ }
+}
+
func TestTopStats(t *testing.T) {
top := NewTopStats()
@@ -113,6 +176,28 @@ func TestTopStats(t *testing.T) {
}
}
+func TestTopStatsExportImport(t *testing.T) {
+ top1 := NewTopStats()
+ top1.Record("a.com.", "192.168.1.1", false)
+ top1.Record("a.com.", "192.168.1.1", false)
+ top1.Record("b.com.", "192.168.1.2", true)
+
+ d, c, b := top1.Export()
+ top2 := NewTopStats()
+ top2.Import(d, c, b)
+
+ domains, clients, blocked := top2.GetTop(10)
+ if len(domains) != 1 || domains[0].Domain != "a.com." || domains[0].Count != 2 {
+ t.Errorf("top domains export/import mismatch: %+v", domains)
+ }
+ if len(clients) != 2 {
+ t.Errorf("top clients count mismatch: %+v", clients)
+ }
+ if len(blocked) != 1 || blocked[0].Domain != "b.com." {
+ t.Errorf("top blocked mismatch: %+v", blocked)
+ }
+}
+
func TestHistoryStats(t *testing.T) {
h := NewHistoryStats()
now := time.Now()
@@ -132,6 +217,26 @@ func TestHistoryStats(t *testing.T) {
}
}
+func TestHistoryStatsExportImport(t *testing.T) {
+ h1 := NewHistoryStats()
+ now := time.Now()
+ h1.Record(now, false, false)
+ h1.Record(now, true, true)
+
+ exported := h1.Export()
+ if len(exported) == 0 {
+ t.Fatalf("expected exported history points")
+ }
+
+ h2 := NewHistoryStats()
+ h2.Import(exported)
+ points := h2.GetHistory(24)
+ lastPoint := points[len(points)-1]
+ if lastPoint.Total != 2 || lastPoint.Blocked != 1 || lastPoint.Cached != 1 {
+ t.Errorf("history stats import mismatch: %+v", lastPoint)
+ }
+}
+
func TestStatsAPIHTTPEndpoints(t *testing.T) {
s := NewStatsAPI(&Args{Capacity: 100}, zap.NewNop())
router := s.Router()
@@ -185,6 +290,31 @@ func TestStatsAPIHTTPEndpoints(t *testin
t.Errorf("expected 24 history points, got %d", len(histPoints))
}
+ // Test GET /api/v1/dump
+ reqDump := httptest.NewRequest(http.MethodGet, "/api/v1/dump", nil)
+ wDump := httptest.NewRecorder()
+ router.ServeHTTP(wDump, reqDump)
+ if wDump.Code != http.StatusOK {
+ t.Fatalf("expected HTTP 200 for dump, got %d", wDump.Code)
+ }
+ dumpBytes := wDump.Body.Bytes()
+ if len(dumpBytes) == 0 {
+ t.Fatalf("expected non-empty dump body")
+ }
+
+ // Test POST /api/v1/load_dump
+ s2 := NewStatsAPI(&Args{Capacity: 100}, zap.NewNop())
+ router2 := s2.Router()
+ reqLoadDump := httptest.NewRequest(http.MethodPost, "/api/v1/load_dump", bytes.NewReader(dumpBytes))
+ wLoadDump := httptest.NewRecorder()
+ router2.ServeHTTP(wLoadDump, reqLoadDump)
+ if wLoadDump.Code != http.StatusOK {
+ t.Fatalf("expected HTTP 200 for load_dump, got %d", wLoadDump.Code)
+ }
+ if s2.totalQueries.Load() != 1 {
+ t.Errorf("expected s2 total queries to be 1 after load_dump, got %d", s2.totalQueries.Load())
+ }
+
// Test POST /api/v1/logs/clear
reqClearLogs := httptest.NewRequest(http.MethodPost, "/api/v1/logs/clear", nil)
wClearLogs := httptest.NewRecorder()
@@ -258,3 +388,117 @@ func TestStatsAPIExec(t *testing.T) {
t.Errorf("expected rule qname google.com., got %s", logs[0].Rule)
}
}
+
+func TestStatsAPIPersistence(t *testing.T) {
+ tempDir := t.TempDir()
+ dumpFilePath := filepath.Join(tempDir, "stats.dump")
+
+ // Phase 1: Start stats_api with dump_file configured
+ s1 := NewStatsAPI(&Args{
+ Capacity: 50,
+ DumpFile: dumpFilePath,
+ DumpInterval: 600,
+ }, zap.NewNop())
+
+ // Push test logs and stats
+ for i := 1; i <= 10; i++ {
+ s1.ringBuffer.Push(LogEntry{
+ Domain: fmt.Sprintf("test%d.com.", i),
+ ClientIP: "192.168.1.100",
+ IsBlocked: i%2 == 0,
+ IsCached: i%3 == 0,
+ ElapsedMS: float64(i * 5),
+ })
+ s1.totalQueries.Add(1)
+ if i%2 == 0 {
+ s1.blockedQueries.Add(1)
+ }
+ if i%3 == 0 {
+ s1.cachedQueries.Add(1)
+ }
+ s1.totalLatencyUs.Add(uint64(i * 5000))
+ s1.topStats.Record(fmt.Sprintf("test%d.com.", i), "192.168.1.100", i%2 == 0)
+ s1.historyStats.Record(time.Now(), i%2 == 0, i%3 == 0)
+ }
+
+ // Close s1 -> triggers dumpStats()
+ if err := s1.Close(); err != nil {
+ t.Fatalf("Close failed: %v", err)
+ }
+
+ // Verify file exists
+ fi, err := os.Stat(dumpFilePath)
+ if err != nil {
+ t.Fatalf("dump file was not created: %v", err)
+ }
+ if fi.Size() == 0 {
+ t.Fatalf("dump file is empty")
+ }
+
+ // Verify gzip header and compression
+ f, err := os.Open(dumpFilePath)
+ if err != nil {
+ t.Fatalf("failed to open dump file: %v", err)
+ }
+ gr, err := gzip.NewReader(f)
+ if err != nil {
+ t.Fatalf("failed to create gzip reader on dump file: %v", err)
+ }
+ if gr.Name != statsDumpHeader {
+ t.Errorf("expected gzip header %s, got %s", statsDumpHeader, gr.Name)
+ }
+ _ = gr.Close()
+ _ = f.Close()
+
+ // Phase 2: Start new instance s2 with same dump_file -> loads dump automatically
+ s2 := NewStatsAPI(&Args{
+ Capacity: 50,
+ DumpFile: dumpFilePath,
+ DumpInterval: 600,
+ }, zap.NewNop())
+ defer s2.Close()
+
+ if s2.totalQueries.Load() != 10 {
+ t.Errorf("expected 10 total queries after load, got %d", s2.totalQueries.Load())
+ }
+ if s2.blockedQueries.Load() != 5 {
+ t.Errorf("expected 5 blocked queries after load, got %d", s2.blockedQueries.Load())
+ }
+ if s2.cachedQueries.Load() != 3 {
+ t.Errorf("expected 3 cached queries after load, got %d", s2.cachedQueries.Load())
+ }
+
+ totalLogs, logs := s2.ringBuffer.QueryLogs(10, 0, "", "all")
+ if totalLogs != 10 || len(logs) != 10 {
+ t.Fatalf("expected 10 logs in s2, got total=%d len=%d", totalLogs, len(logs))
+ }
+ if logs[0].Domain != "test10.com." {
+ t.Errorf("expected newest log test10.com., got %s", logs[0].Domain)
+ }
+
+ topDomains, topClients, topBlocked := s2.topStats.GetTop(10)
+ if len(topDomains) == 0 {
+ t.Errorf("expected top domains to be restored")
+ }
+ if len(topClients) == 0 || topClients[0].ClientIP != "192.168.1.100" {
+ t.Errorf("expected top clients to be restored")
+ }
+ if len(topBlocked) == 0 {
+ t.Errorf("expected top blocked to be restored")
+ }
+}
+
+func TestStatsAPINonExistentDumpFile(t *testing.T) {
+ tempDir := t.TempDir()
+ dumpFilePath := filepath.Join(tempDir, "non_existent_stats.dump")
+
+ // Should not error or panic
+ s := NewStatsAPI(&Args{
+ DumpFile: dumpFilePath,
+ }, zap.NewNop())
+ defer s.Close()
+
+ if s.totalQueries.Load() != 0 {
+ t.Errorf("expected 0 total queries, got %d", s.totalQueries.Load())
+ }
+}
@@ -0,0 +1,126 @@
From 4a7034b471ea68aea2703b2c2dd89bcf195944dc Mon Sep 17 00:00:00 2001
From: sbwml <admin@cooluc.com>
Date: Sat, 29 Aug 2026 22:59:19 +0800
Subject: [PATCH 5/5] fix(stats_api): improve rule hit extraction and upstream
formatting
- Preserve configured upstream address in forward plugin
- Format DoH/DoT/DoQ upstream URLs with proper protocol scheme
- Filter out control rules and internal sequences when extracting rule hits
- Update unit test assertions for upstream formatting
Signed-off-by: sbwml <admin@cooluc.com>
---
plugin/executable/forward/forward.go | 2 +-
plugin/executable/stats_api/stats_api.go | 66 ++++++++++++++-----
plugin/executable/stats_api/stats_api_test.go | 4 +-
3 files changed, 53 insertions(+), 19 deletions(-)
--- a/plugin/executable/forward/forward.go
+++ b/plugin/executable/forward/forward.go
@@ -330,7 +330,7 @@ func (f *Forward) exchange(ctx context.C
} else if proto == "QUIC" || proto == "DOQ" {
proto = "DoQ"
}
- qCtx.SetUpstreamSelected(addr, proto, chosenUpstream.cfg.Tag, f.pluginTag)
+ qCtx.SetUpstreamSelected(chosenUpstream.cfg.Addr, proto, chosenUpstream.cfg.Tag, f.pluginTag)
}
return r, nil
--- a/plugin/executable/stats_api/stats_api.go
+++ b/plugin/executable/stats_api/stats_api.go
@@ -970,10 +970,20 @@ func (s *StatsAPI) Exec(ctx context.Cont
if isCached {
upstream = "cache"
} else if u := qCtx.UpstreamSelected; u != nil {
- if u.Protocol != "" && u.Addr != "" {
- upstream = fmt.Sprintf("%s://%s", u.Protocol, u.Addr)
- } else if u.Addr != "" {
- upstream = u.Addr
+ if u.Addr != "" {
+ if !strings.Contains(u.Addr, "://") {
+ if u.Protocol == "DoH" {
+ upstream = "https://" + u.Addr
+ } else if u.Protocol == "DoT" {
+ upstream = "tls://" + u.Addr
+ } else if u.Protocol == "DoQ" {
+ upstream = "quic://" + u.Addr
+ } else {
+ upstream = u.Addr
+ }
+ } else {
+ upstream = u.Addr
+ }
} else if u.Tag != "" {
upstream = u.Tag
}
@@ -981,20 +991,44 @@ func (s *StatsAPI) Exec(ctx context.Cont
// Extract Rule information
var rule string
- if len(qCtx.RuleHits) > 0 {
- for i := len(qCtx.RuleHits) - 1; i >= 0; i-- {
- hit := qCtx.RuleHits[i]
- if len(hit.Matches) > 0 {
- rule = strings.Join(hit.Matches, ",")
- break
- } else if hit.Exec != "" {
- rule = hit.Exec
- break
- } else if hit.Sequence != "" {
- rule = hit.Sequence
- break
+ for i := len(qCtx.RuleHits) - 1; i >= 0; i-- {
+ hit := qCtx.RuleHits[i]
+ exec := strings.TrimSpace(hit.Exec)
+
+ if exec == "accept" || exec == "return" || strings.HasPrefix(exec, "jump ") || strings.HasPrefix(exec, "ttl ") {
+ continue
+ }
+
+ var positiveMatches []string
+ for _, m := range hit.Matches {
+ m = strings.TrimSpace(m)
+ if m != "" && m != "has_resp" && !strings.HasPrefix(m, "!") {
+ positiveMatches = append(positiveMatches, m)
}
}
+
+ if len(positiveMatches) > 0 {
+ rule = strings.Join(positiveMatches, ",")
+ break
+ }
+
+ if exec != "" && exec != "$stats_collector" {
+ rule = exec
+ break
+ }
+
+ if hit.Sequence != "" && hit.Sequence != "has_resp_sequence" && hit.Sequence != "main_sequence" {
+ rule = hit.Sequence
+ break
+ }
+ }
+
+ if rule == "" {
+ if isCached {
+ rule = "cache"
+ } else {
+ rule = "-"
+ }
}
s.topStats.Record(domain, clientIP, isBlocked)
--- a/plugin/executable/stats_api/stats_api_test.go
+++ b/plugin/executable/stats_api/stats_api_test.go
@@ -381,8 +381,8 @@ func TestStatsAPIExec(t *testing.T) {
if logs[0].Domain != "google.com." {
t.Errorf("expected domain google.com., got %s", logs[0].Domain)
}
- if logs[0].Upstream != "UDP://8.8.8.8:53" {
- t.Errorf("expected upstream UDP://8.8.8.8:53, got %s", logs[0].Upstream)
+ if logs[0].Upstream != "8.8.8.8:53" {
+ t.Errorf("expected upstream 8.8.8.8:53, got %s", logs[0].Upstream)
}
if logs[0].Rule != "qname google.com." {
t.Errorf("expected rule qname google.com., got %s", logs[0].Rule)
@@ -0,0 +1,86 @@
From 006d70d6910080c7468ec8fa16fb67227b208036 Mon Sep 17 00:00:00 2001
From: sbwml <admin@cooluc.com>
Date: Sun, 30 Aug 2026 07:12:23 +0800
Subject: [PATCH] fix(stats_api): use system local timezone for history and log
timestamps
Signed-off-by: sbwml <admin@cooluc.com>
---
plugin/executable/stats_api/stats_api.go | 13 +++++++------
plugin/executable/stats_api/stats_api_test.go | 9 +++++++++
2 files changed, 16 insertions(+), 6 deletions(-)
--- a/plugin/executable/stats_api/stats_api.go
+++ b/plugin/executable/stats_api/stats_api.go
@@ -387,7 +387,7 @@ func NewHistoryStats() *HistoryStats {
}
func (h *HistoryStats) Record(t time.Time, isBlocked, isCached bool) {
- tHour := t.UTC().Truncate(time.Hour).Unix()
+ tHour := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location()).Unix()
h.mu.RLock()
bucket, ok := h.points[tHour]
@@ -401,7 +401,7 @@ func (h *HistoryStats) Record(t time.Tim
h.points[tHour] = bucket
// Clean up old buckets beyond 48 hours
- cutoff := t.UTC().Add(-48 * time.Hour).Unix()
+ cutoff := t.Add(-48 * time.Hour).Unix()
for k := range h.points {
if k < cutoff {
delete(h.points, k)
@@ -424,14 +424,15 @@ func (h *HistoryStats) GetHistory(numPoi
if numPoints <= 0 {
numPoints = 24
}
- now := time.Now().UTC().Truncate(time.Hour)
+ now := time.Now()
+ nowHour := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
res := make([]HistoryPoint, 0, numPoints)
h.mu.RLock()
defer h.mu.RUnlock()
for i := numPoints - 1; i >= 0; i-- {
- slotTime := now.Add(time.Duration(-i) * time.Hour)
+ slotTime := nowHour.Add(time.Duration(-i) * time.Hour)
slotUnix := slotTime.Unix()
var total, blocked, cached uint64
@@ -473,7 +474,7 @@ func (h *HistoryStats) Import(points map
defer h.mu.Unlock()
h.points = make(map[int64]*HistoryBucket, len(points))
- cutoff := time.Now().UTC().Add(-48 * time.Hour).Unix()
+ cutoff := time.Now().Add(-48 * time.Hour).Unix()
for k, v := range points {
if k >= cutoff {
bucket := &HistoryBucket{}
@@ -1037,7 +1038,7 @@ func (s *StatsAPI) Exec(ctx context.Cont
elapsedMS := math.Round(float64(elapsed.Microseconds())/10.0) / 100.0
entry := LogEntry{
- Timestamp: start.UTC().Format(time.RFC3339),
+ Timestamp: start.Format(time.RFC3339),
ClientIP: clientIP,
Domain: domain,
QType: qtypeStr,
--- a/plugin/executable/stats_api/stats_api_test.go
+++ b/plugin/executable/stats_api/stats_api_test.go
@@ -215,6 +215,15 @@ func TestHistoryStats(t *testing.T) {
if lastPoint.Total != 3 || lastPoint.Blocked != 1 || lastPoint.Cached != 1 {
t.Errorf("history point mismatch: %+v", lastPoint)
}
+
+ parsedTime, err := time.Parse(time.RFC3339, lastPoint.Time)
+ if err != nil {
+ t.Fatalf("failed to parse history point time: %v", err)
+ }
+ expectedHour := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
+ if parsedTime.Unix() != expectedHour.Unix() {
+ t.Errorf("expected history point time unix %d, got %d", expectedHour.Unix(), parsedTime.Unix())
+ }
}
func TestHistoryStatsExportImport(t *testing.T) {
+2 -2
View File
@@ -10,12 +10,12 @@ include $(INCLUDE_DIR)/kernel.mk
PKG_NAME:=natflow PKG_NAME:=natflow
PKG_VERSION:=20260531 PKG_VERSION:=20260531
PKG_RELEASE:=75 PKG_RELEASE:=76
PKG_SOURCE:=$(PKG_VERSION).tar.xz PKG_SOURCE:=$(PKG_VERSION).tar.xz
PKG_SOURCE_URL:=https://github.com/ptpt52/natflow.git PKG_SOURCE_URL:=https://github.com/ptpt52/natflow.git
PKG_SOURCE_PROTO:=git PKG_SOURCE_PROTO:=git
PKG_SOURCE_VERSION:=e89fb0a015be44a2ca2071a5cff6803347759215 PKG_SOURCE_VERSION:=f330072aed90336bb594da2a671475ee6499d726
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_MAINTAINER:=Chen Minqiang <ptpt52@gmail.com> PKG_MAINTAINER:=Chen Minqiang <ptpt52@gmail.com>
PKG_LICENSE:=GPL-2.0 PKG_LICENSE:=GPL-2.0
+5 -3
View File
@@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=ooniprobe PKG_NAME:=ooniprobe
PKG_VERSION:=3.30.0 PKG_VERSION:=3.30.0
PKG_RELEASE:=8 PKG_RELEASE:=9
PKG_SOURCE:=probe-cli-$(PKG_VERSION).tar.gz PKG_SOURCE:=probe-cli-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/ooni/probe-cli/tar.gz/v$(PKG_VERSION)? PKG_SOURCE_URL:=https://codeload.github.com/ooni/probe-cli/tar.gz/v$(PKG_VERSION)?
@@ -17,14 +17,16 @@ PKG_HASH:=skip
PKG_MAINTAINER:=Jan Pavlinec <jan.pavlinec1@gmail.com> PKG_MAINTAINER:=Jan Pavlinec <jan.pavlinec1@gmail.com>
PKG_LICENSE:=BSD-3-Clause PKG_LICENSE:=BSD-3-Clause
PKG_LICENSE_FILES:=LICENSE.md PKG_LICENSE_FILES:=LICENSE
PKG_BUILD_DIR:=$(BUILD_DIR)/probe-cli-$(PKG_VERSION) PKG_BUILD_DIR:=$(BUILD_DIR)/probe-cli-$(PKG_VERSION)
PKG_BUILD_DEPENDS:=golang/host PKG_BUILD_DEPENDS:=golang/host
PKG_BUILD_PARALLEL:=1 PKG_BUILD_PARALLEL:=1
PKG_USE_MIPS16:=0 PKG_USE_MIPS16:=0
GO_PKG:=github.com/ooni/probe-cli GO_PKG:=github.com/ooni/probe-cli/v3
GO_PKG_BUILD_PKG:=$(GO_PKG)/cmd/ooniprobe
GO_PKG_TAGS:=nouserauth
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
+2 -2
View File
@@ -9,7 +9,7 @@ PKG_NAME:=natmapt
PKG_UPSTREAM_VERSION:=20260214 PKG_UPSTREAM_VERSION:=20260214
PKG_UPSTREAM_GITHASH:= PKG_UPSTREAM_GITHASH:=
PKG_VERSION:=$(PKG_UPSTREAM_VERSION)$(if $(PKG_UPSTREAM_GITHASH),~$(call version_abbrev,$(PKG_UPSTREAM_GITHASH))) PKG_VERSION:=$(PKG_UPSTREAM_VERSION)$(if $(PKG_UPSTREAM_GITHASH),~$(call version_abbrev,$(PKG_UPSTREAM_GITHASH)))
PKG_RELEASE:=7 PKG_RELEASE:=8
SCRIPTS_VERSION:=0.2026.01.24 SCRIPTS_VERSION:=0.2026.01.24
PKG_SOURCE_SUBDIR:=$(PKG_UPSTREAM_NAME)-$(PKG_UPSTREAM_VERSION) PKG_SOURCE_SUBDIR:=$(PKG_UPSTREAM_NAME)-$(PKG_UPSTREAM_VERSION)
@@ -23,7 +23,7 @@ PKG_SOURCE:=$(PKG_SOURCE_SUBDIR).tar.xz
else else
PKG_SOURCE_PROTO:=git PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/heiher/natmap.git PKG_SOURCE_URL:=https://github.com/heiher/natmap.git
PKG_SOURCE_VERSION:=9a43147f7a648db1ce5dd81507259dc9216abb37 PKG_SOURCE_VERSION:=31d46801a4d868c84d6bee5660ee34ba83ddc889
PKG_MIRROR_HASH:=skip PKG_MIRROR_HASH:=skip
PKG_SOURCE:=$(PKG_SOURCE_SUBDIR)-$(PKG_SOURCE_VERSION).tar.gz PKG_SOURCE:=$(PKG_SOURCE_SUBDIR)-$(PKG_SOURCE_VERSION).tar.gz
+2 -2
View File
@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=sing-box PKG_NAME:=sing-box
PKG_VERSION:=1.13.20 PKG_VERSION:=1.13.21
PKG_RELEASE:=27 PKG_RELEASE:=28
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/SagerNet/sing-box/tar.gz/v$(PKG_VERSION)? PKG_SOURCE_URL:=https://codeload.github.com/SagerNet/sing-box/tar.gz/v$(PKG_VERSION)?
+124 -22
View File
@@ -43,12 +43,12 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=wwand PKG_NAME:=wwand
PKG_RELEASE:=12 PKG_RELEASE:=15
PKG_SOURCE_PROTO:=git PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git
PKG_SOURCE_VERSION:=2311728191b2b325de54946b0767000baa245366 PKG_SOURCE_VERSION:=95acd0064d8307cde67a05c5a93bf9e62254ab8d
PKG_SOURCE_DATE:=2026-08-27 PKG_SOURCE_DATE:=2026-08-31
PKG_MIRROR_HASH:=skip PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only PKG_LICENSE:=GPL-2.0-only
@@ -89,15 +89,15 @@ UCDIR:=/usr/share/ucode/wwand
# silently ship in the base too. The per-backend install lists below carry the # silently ship in the base too. The per-backend install lists below carry the
# QMI/MBIM/NCM/eSIM modules; main.uc/wwandctl.uc install as executables. # QMI/MBIM/NCM/eSIM modules; main.uc/wwandctl.uc install as executables.
WWAND_BASE_UC:=apndb.uc atcmd.uc atcmd_parse.uc atport.uc backend.uc board.uc \ WWAND_BASE_UC:=apndb.uc atcmd.uc atcmd_parse.uc atport.uc backend.uc board.uc \
client.uc config.uc config_check.uc context_common.uc context_monitor_qmi.uc \ carrier_config.uc client.uc config.uc config_check.uc context_common.uc context_monitor_qmi.uc \
ctx_settings.uc daemon.uc datapath_qmi.uc discovery.uc hwops.uc log.uc \ ctx_settings.uc daemon.uc discovery.uc hwops.uc log.uc modem_datapath_qmi.uc \
modem_common.uc modem_init_qmi.uc modem_quirks.uc modeswitch.uc ncm_vendors.uc \ modem_common.uc modem_init_qmi.uc modem_quirks.uc modeswitch.uc ncm_vendors.uc \
netlink.uc netsel_ops.uc protocol_switch.uc reconnect.uc recovery.uc regdetail.uc \ netlink.uc netsel_ops.uc protocol_switch.uc reconnect.uc recovery.uc regdetail.uc \
sim.uc sim_plmn.uc simops.uc sms.uc sms_pdu.uc telemetry_mbim.uc telemetry_ncm.uc \ sim.uc sim_plmn.uc simops.uc sms.uc sms_pdu.uc telemetry_mbim.uc telemetry_ncm.uc \
telemetry_qmi.uc transport.uc ubus.uc telemetry_qmi.uc transport.uc ubus.uc
WWAND_BASE_CODEC:=arfcn_bands.uc hex.uc qmux.uc tlv.uc WWAND_BASE_CODEC:=arfcn_bands.uc hex.uc qmux.uc tlv.uc
WWAND_BASE_SCHEMA:=ctl.uc dms.uc dsd.uc loc.uc loc_lazy.uc merge.uc nas.uc rat.uc \ WWAND_BASE_SCHEMA:=cat.uc ctl.uc dms.uc dsd.uc loc.uc loc_lazy.uc merge.uc nas.uc pdc.uc rat.uc \
uim.uc wda.uc wds.uc wms.uc wms_lazy.uc tmd.uc uim.uc wda.uc wds.uc wms.uc wms_lazy.uc
# DEVELOPERS: set CONFIG_WWAND_UCODE_SOURCE to ship readable .uc source # DEVELOPERS: set CONFIG_WWAND_UCODE_SOURCE to ship readable .uc source
# instead of bytecode — for editing modules live under /usr/share/ucode/wwand # instead of bytecode — for editing modules live under /usr/share/ucode/wwand
@@ -172,9 +172,10 @@ define Package/wwand/install
$(INSTALL_DIR) $(1)/usr/bin $(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(WWAND_UCODE)/wwandctl.uc $(1)/usr/bin/wwandctl $(INSTALL_BIN) $(WWAND_UCODE)/wwandctl.uc $(1)/usr/bin/wwandctl
$(INSTALL_DIR) $(1)/lib/netifd/proto $(INSTALL_DIR) $(1)/lib/netifd/proto
# the shim registers `wwand`; the legacy `qmi` alias only when the global # the shim registers `wwand` and nothing else — netifd sources every handler
# `option takeover` is set (default off, so uqmi keeps `proto qmi`). Install # in this directory, so two of them claiming `qmi` would be settled by load
# under the current name — the historical qmi.sh belonged to uqmi # order. `proto qmi` therefore stays uqmi's qmi.sh; migrating an interface is
# the user's explicit act.
$(INSTALL_BIN) $(PKG_BUILD_DIR)/files/wwand-proto.sh $(1)/lib/netifd/proto/wwand.sh $(INSTALL_BIN) $(PKG_BUILD_DIR)/files/wwand-proto.sh $(1)/lib/netifd/proto/wwand.sh
$(INSTALL_DIR) $(1)/usr/libexec/wwand $(INSTALL_DIR) $(1)/usr/libexec/wwand
$(INSTALL_BIN) $(PKG_BUILD_DIR)/files/wwand-migrate $(1)/usr/libexec/wwand/migrate $(INSTALL_BIN) $(PKG_BUILD_DIR)/files/wwand-migrate $(1)/usr/libexec/wwand/migrate
@@ -200,6 +201,13 @@ define Package/wwand/install
# new_id bind / late kmodloader) re-kick a modem parked in no_at_port backoff # new_id bind / late kmodloader) re-kick a modem parked in no_at_port backoff
$(INSTALL_DIR) $(1)/etc/hotplug.d/tty $(INSTALL_DIR) $(1)/etc/hotplug.d/tty
$(INSTALL_DATA) $(PKG_BUILD_DIR)/files/wwand.hotplug.tty $(1)/etc/hotplug.d/tty/20-wwand $(INSTALL_DATA) $(PKG_BUILD_DIR)/files/wwand.hotplug.tty $(1)/etc/hotplug.d/tty/20-wwand
# usb hotplug: bind qmi_wwan to the Huawei E1820's ethernet function. The
# kernel hands the device to qmi_wwan (cdc_ether blacklists it) but its
# table entry wants a vendor-specific class this old 802.3 layout does not
# carry, so a scoped dynamic new_id is what makes the control channel appear
# at all — and it is volatile, so it has to be re-created on every replug.
$(INSTALL_DIR) $(1)/etc/hotplug.d/usb
$(INSTALL_DATA) $(PKG_BUILD_DIR)/files/wwand.hotplug.e1820 $(1)/etc/hotplug.d/usb/21-wwand-e1820
# NOTE: the kernel-wwan-subsystem hotplug (/etc/hotplug.d/wwan/20-wwand) is # NOTE: the kernel-wwan-subsystem hotplug (/etc/hotplug.d/wwan/20-wwand) is
# NOT installed here — it belongs to wwand-mhi, which also pulls the MHI # NOT installed here — it belongs to wwand-mhi, which also pulls the MHI
# drivers that create /sys/class/wwan in the first place (procd only arms a # drivers that create /sys/class/wwan in the first place (procd only arms a
@@ -214,11 +222,15 @@ define Package/wwand-qmi
CATEGORY:=Network CATEGORY:=Network
SUBMENU:=WWAN SUBMENU:=WWAN
TITLE:=QMI backend for wwand TITLE:=QMI backend for wwand
DEPENDS:=+wwand +kmod-usb-net-qmi-wwan +kmod-rmnet # kmod-rmnet is the QMAP demuxer and is needed on BOTH transports (an MHI
# coexists with the stock OpenWrt QMI stack (uqmi): # modem multiplexes through it too); only the USB glue is conditional, so a
# by default wwand claims only `proto wwand` interfaces, so both can be # target built without USB support can still select this backend and drive a
# installed. Set the global `option takeover '1'` (or migrate interfaces from # PCIe/MHI modem. On a USB target nothing changes.
# the LuCI modem list) to hand `proto qmi` interfaces to wwand. DEPENDS:=+wwand +USB_SUPPORT:kmod-usb-net-qmi-wwan +kmod-rmnet
# coexists with the stock OpenWrt QMI stack (uqmi): wwand claims only
# `proto wwand` interfaces, so both can be installed. To hand an existing
# `proto qmi` interface to wwand, migrate it — from the LuCI modem list or
# with `/usr/libexec/wwand/migrate --apply` — which rewrites it in place.
endef endef
define Package/wwand-qmi/description define Package/wwand-qmi/description
@@ -246,7 +258,10 @@ define Package/wwand-mbim
CATEGORY:=Network CATEGORY:=Network
SUBMENU:=WWAN SUBMENU:=WWAN
TITLE:=MBIM backend for wwand TITLE:=MBIM backend for wwand
DEPENDS:=+wwand-qmi +kmod-usb-net-cdc-mbim # conditional for the same reason as wwand-qmi: the ucode side is
# transport-neutral, and on MHI the transport is kmod-mhi-wwan-mbim, which
# wwand-mhi already pulls
DEPENDS:=+wwand-qmi +USB_SUPPORT:kmod-usb-net-cdc-mbim
# coexists with the stock OpenWrt MBIM stack (netifd `mbim` proto, package # coexists with the stock OpenWrt MBIM stack (netifd `mbim` proto, package
# umbim): wwand manages a cdc_mbim modem only once its interface has been # umbim): wwand manages a cdc_mbim modem only once its interface has been
# migrated to `proto wwand` (LuCI modem list / migrate CLI), so both stacks can # migrated to `proto wwand` (LuCI modem list / migrate CLI), so both stacks can
@@ -285,7 +300,8 @@ define Package/wwand-ncm
TITLE:=NCM/ECM backend for wwand TITLE:=NCM/ECM backend for wwand
# +kmod-usb-net-rndis: the RNDIS datapath (rndis_host) used by e.g. the # +kmod-usb-net-rndis: the RNDIS datapath (rndis_host) used by e.g. the
# Fibocom FM350-GL (MediaTek T700) — same AT-driven backend, RNDIS netdev. # Fibocom FM350-GL (MediaTek T700) — same AT-driven backend, RNDIS netdev.
DEPENDS:=+wwand +kmod-usb-net-cdc-ncm +kmod-usb-net-cdc-ether +kmod-usb-net-rndis DEPENDS:=+wwand +USB_SUPPORT:kmod-usb-net-cdc-ncm \
+USB_SUPPORT:kmod-usb-net-cdc-ether +USB_SUPPORT:kmod-usb-net-rndis
# coexists with the stock OpenWrt NCM stack (netifd `ncm` proto, package # coexists with the stock OpenWrt NCM stack (netifd `ncm` proto, package
# comgt-ncm): wwand drives an AT/NCM modem only once its interface has been # comgt-ncm): wwand drives an AT/NCM modem only once its interface has been
# migrated to `proto wwand` (LuCI modem list / migrate CLI), so both stacks can # migrated to `proto wwand` (LuCI modem list / migrate CLI), so both stacks can
@@ -329,12 +345,14 @@ define Package/wwand-mhi/description
wwand binds these modems on boot. Install together with a control backend wwand binds these modems on boot. Install together with a control backend
(wwand-qmi or wwand-mbim). (wwand-qmi or wwand-mbim).
Adding wwand-mbim is worthwhile even on a QMI-driven MHI modem: many of them Adding wwand-mbim is worth weighing on a QMI-driven MHI modem: many of them
expose no DUN channel and therefore no AT port at all, and wwand can then expose no DUN channel and therefore no AT port at all, and wwand can then
carry AT over the modem's MBIM channel instead (Quectel QDU). Without that carry AT over the modem's MBIM channel instead (Quectel QDU). The cost is
package the capability is simply absent — vendor AT commands, the protocol that wwand-mbim currently pulls the USB MBIM transport with it, which this
switch and AT telemetry are unavailable, which is a limitation rather than a path does not use — and on a target built without USB support it cannot be
failure. selected at all. Without it the capability is simply absent: vendor AT
commands, the protocol switch and AT telemetry are unavailable, which is a
limitation rather than a failure.
endef endef
define Package/wwand-mhi/install define Package/wwand-mhi/install
@@ -387,9 +405,93 @@ define Package/wwand-esim/install
$(INSTALL_DATA) $(WWAND_UCODE)/esim_bridge.uc $(1)$(UCDIR)/ $(INSTALL_DATA) $(WWAND_UCODE)/esim_bridge.uc $(1)$(UCDIR)/
endef endef
# ---------------------------------------------------------------------------
# wwand-datapath-rmnet_nss: Qualcomm NSS offload behind the VENDOR qmi_wwan_q.
# A datapath add-on, not a control backend — it plugs into wwand's datapath
# interface (`option mux 'rmnet_nss'`, or picked up on its own under 'auto').
# Underscore in the name on purpose: the datapath name doubles as the ucode
# module name (wwand.datapath_rmnet_nss), and the daemon's "package not
# installed" note is built from it, so the two must read alike.
# ---------------------------------------------------------------------------
define Package/wwand-datapath-rmnet_nss
SECTION:=net
CATEGORY:=Network
SUBMENU:=WWAN
TITLE:=NSS-offloaded QMAP datapath (vendor qmi_wwan_q) for wwand
# +wwand-qmi: it is a QMI datapath. The vendor driver and the NSS shim are
# NOT dependencies — they come from the board's own kernel tree (QSDK/NSS
# builds), and this package must stay installable next to them rather than
# try to name them.
DEPENDS:=+wwand-qmi
endef
define Package/wwand-datapath-rmnet_nss/description
Datapath add-on for Qualcomm NSS builds (ipq807x and friends), where the modem
datapath is offloaded to the NSS cores. The attach point is a global callback
contract: rmnet_nss publishes rmnet_nss_callbacks and the vendor qmi_wwan_q
driver calls nss_create() on each QMAP netdev it registers. Mainline rmnet
makes no such call, so wwand's built-in rmnet/qmimux datapaths produce children
that never reach the NSS shim — traffic forwards, but on the CPU.
This datapath therefore creates nothing: qmi_wwan_q registers the children in
its USB probe, one per its qmap_mode module parameter, and wwand adopts them,
drives the per-channel link_state gate and binds each WDS session to the QMAP
id the driver expects (0x81 upwards, not the config channel number).
It claims any parent carrying those vendor children, with or without the NSS
shim: they need adopting either way, and leaving a non-NSS vendor box to
mainline rmnet makes it build a second set of children on a parent that
already demuxes QMAP itself. For the offload, rmnet_nss must be LOADED BEFORE
the modem's driver binds — qmi_wwan_q captures whether NSS is available at the
moment it creates each child, so a module loaded afterwards leaves them
without an NSS context; that case is logged and shown on the status page.
Install it on a box with the vendor qmi_wwan_q driver; anywhere else it probes
false and changes nothing.
endef
define Package/wwand-datapath-rmnet_nss/install
$(INSTALL_DIR) $(1)$(UCDIR)
$(INSTALL_DATA) $(WWAND_UCODE)/datapath_rmnet_nss.uc $(1)$(UCDIR)/
endef
$(eval $(call BuildPackage,wwand)) $(eval $(call BuildPackage,wwand))
$(eval $(call BuildPackage,wwand-qmi)) $(eval $(call BuildPackage,wwand-qmi))
$(eval $(call BuildPackage,wwand-mbim)) $(eval $(call BuildPackage,wwand-mbim))
$(eval $(call BuildPackage,wwand-ncm)) $(eval $(call BuildPackage,wwand-ncm))
$(eval $(call BuildPackage,wwand-mhi)) $(eval $(call BuildPackage,wwand-mhi))
$(eval $(call BuildPackage,wwand-esim)) $(eval $(call BuildPackage,wwand-esim))
define Package/wwand-datapath-rmnet_nss_mhi
SECTION:=net
CATEGORY:=Network
SUBMENU:=WWAN
TITLE:=NSS-offloaded QMAP datapath (vendor PCIe/MHI) for wwand
# +wwand: it serves both control protocols, so it names neither backend
# package. The vendor pcie_mhi driver and the NSS shim come from the board's
# own kernel tree and are deliberately not named here.
DEPENDS:=+wwand
endef
define Package/wwand-datapath-rmnet_nss_mhi/description
The PCIe/MHI sibling of wwand-datapath-rmnet_nss, for Quectel's vendor
pcie_mhi driver (mhi_netdev_quectel). That driver registers the QMAP children
itself and calls the rmnet_nss callbacks on each, so wwand adopts them rather
than creating any -- mainline rmnet would build a second set on a parent that
already demuxes.
It serves QMI and MBIM alike, because the driver does. The wire id differs:
QMAP framing uses 0x81 upwards, MBIM framing uses the MBIM session id, which
equals wwand's channel number on ordinary hardware and is offset by 112 on an
SDX7x (PCI 17cb:0309).
NOT hardware-verified: written from the driver sources. On any board without
that driver it probes false and changes nothing.
endef
define Package/wwand-datapath-rmnet_nss_mhi/install
$(INSTALL_DIR) $(1)$(UCDIR)
$(INSTALL_DATA) $(WWAND_UCODE)/datapath_rmnet_nss_mhi.uc $(1)$(UCDIR)/
endef
$(eval $(call BuildPackage,wwand-datapath-rmnet_nss))
$(eval $(call BuildPackage,wwand-datapath-rmnet_nss_mhi))