mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-14 20:34:19 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ab5dde746 | ||
|
|
a0412f91a9 | ||
|
|
221a4bc6ae | ||
|
|
cf0c9e3564 | ||
|
|
b975999290 | ||
|
|
bb4b6094e3 | ||
|
|
dd41e8d648 | ||
|
|
5093acfe28 |
@@ -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
|
||||||
|
|||||||
@@ -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)))
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
config agentflow
|
||||||
|
option enabled '0'
|
||||||
|
option data_dir ''
|
||||||
|
option host '0.0.0.0'
|
||||||
|
option port '9000'
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
@@ -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>
|
||||||
@@ -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 服务运行中。"
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
zh-cn
|
||||||
@@ -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 = ' ';
|
o.title = ' ';
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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 "阿里云公共 DNS(DNS over HTTPS)"
|
|||||||
msgid "Aliyun Public DNS (DNS over QUIC)"
|
msgid "Aliyun Public DNS (DNS over QUIC)"
|
||||||
msgstr "阿里云公共 DNS(DNS over QUIC)"
|
msgstr "阿里云公共 DNS(DNS 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 "思科公共 DNS(208.67.220.220)"
|
|||||||
msgid "Cisco Public DNS (208.67.222.222)"
|
msgid "Cisco Public DNS (208.67.222.222)"
|
||||||
msgstr "思科公共 DNS(208.67.222.222)"
|
msgstr "思科公共 DNS(208.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 公共 DNS(149.112.112.112)"
|
|||||||
msgid "Quad9 Public DNS (9.9.9.9)"
|
msgid "Quad9 Public DNS (9.9.9.9)"
|
||||||
msgstr "Quad9 公共 DNS(9.9.9.9)"
|
msgstr "Quad9 公共 DNS(9.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 "火山引擎公共 DNS(180.184.1.1)"
|
msgstr "火山引擎公共 DNS(180.184.1.1)"
|
||||||
@@ -775,7 +977,7 @@ msgstr "火山引擎公共 DNS(180.184.1.1)"
|
|||||||
msgid "TrafficRoute Public DNS (180.184.2.2)"
|
msgid "TrafficRoute Public DNS (180.184.2.2)"
|
||||||
msgstr "火山引擎公共 DNS(180.184.2.2)"
|
msgstr "火山引擎公共 DNS(180.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 "信风公共 DNS(114.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 页)"
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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[
|
||||||
|
|||||||
@@ -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 %>
|
||||||
|
|||||||
@@ -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 "زمان معتبر (ساعت:میلیمتر)"
|
||||||
|
|
||||||
|
|||||||
@@ -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 "Время действия (чч:мм)"
|
||||||
|
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:=
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user