diff --git a/clashoo/Makefile b/clashoo/Makefile
index f25397580..4e33cff40 100644
--- a/clashoo/Makefile
+++ b/clashoo/Makefile
@@ -5,14 +5,14 @@ PKG_UPSTREAM_VERSION:=1.19.33
PKG_UPSTREAM_TAG:=v$(PKG_UPSTREAM_VERSION)
PKG_SHORT_SHA:=3f27ef3
-PKG_VERSION:=2026.10.25.$(PKG_UPSTREAM_VERSION)
-PKG_RELEASE:=19
+PKG_VERSION:=2026.10.26-r3
+PKG_RELEASE:=20
PKG_SOURCE:=$(PKG_NAME)-stable-$(PKG_UPSTREAM_TAG)-$(PKG_SHORT_SHA).tar.gz
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-stable-$(PKG_UPSTREAM_TAG)-$(PKG_SHORT_SHA)
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR)
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-clashoo/releases/download/mihomo-src
-PKG_SOURCE_VERSION:=57468807be288af4250181a024e29c2971eb228c
+PKG_SOURCE_VERSION:=392b4ed33f99b4025cf2ec0054993d01d86a085a
PKG_HASH:=skip
PKG_BUILD_VERSION:=$(PKG_UPSTREAM_TAG)
@@ -128,6 +128,13 @@ if [ -z "$${IPKG_INSTROOT}" ]; then
rm -f /usr/share/clashoo/cuslist.sh /usr/share/clashoo/groups.sh /usr/share/clashoo/load_groups.sh /usr/share/clashoo/panel_diag.sh
rm -f /usr/share/clashoo/rmlist.sh /usr/share/clashoo/uplist.sh /usr/share/clashoo/rpc_restart_async.sh /usr/share/clashoo/rpc_update_china_ip_async.sh
rm -rf /usr/share/clashoo/create
+ # 旧方案把 stable 内核放在 /usr/bin/mihomo-stable,现方案改用
+ # /usr/libexec/clashoo/mihomo-stable(随包)+ /usr/share/clashoo/bin/(自定义下载)。
+ # preinst 已把它抄到 /usr/share/clashoo/bin/mihomo-stable,确认内容相同后才删
+ # 旧位置;目标已有其他自定义内核时保留旧文件,避免误删。
+ if [ -f /usr/share/clashoo/bin/mihomo-stable ] && [ -f /usr/bin/mihomo-stable ] && [ ! -L /usr/bin/mihomo-stable ] && cmp -s /usr/bin/mihomo-stable /usr/share/clashoo/bin/mihomo-stable; then
+ rm -f /usr/bin/mihomo-stable
+ fi
/usr/share/clashoo/migrate_lan_acl.sh >/dev/null 2>&1 || true
/usr/share/clashoo/migrate_fake_ip_filter.sh >/dev/null 2>&1 || true
/usr/share/clashoo/detect_core_arch.sh >/dev/null 2>&1 || true
diff --git a/clashoo/files/etc/init.d/clashoo b/clashoo/files/etc/init.d/clashoo
index f926fe636..fea070f01 100755
--- a/clashoo/files/etc/init.d/clashoo
+++ b/clashoo/files/etc/init.d/clashoo
@@ -2099,7 +2099,11 @@ _finish_geo_bootstrap() {
read need_mmdb need_geosite < /var/run/clashoo_bootstrap_pending 2>/dev/null
log_msg "Bootstrap: fetching geo data via proxy" "引导模式:正在通过代理补全 Geo 数据"
- /usr/share/clashoo/update/geoip.sh >/dev/null 2>&1
+ if [ "$need_geosite" = "1" ]; then
+ /usr/share/clashoo/update/geoip.sh --require-geosite >/dev/null 2>&1
+ else
+ /usr/share/clashoo/update/geoip.sh >/dev/null 2>&1
+ fi
still=0
[ "$need_mmdb" = "1" ] && ! _has_valid_local_mmdb && still=1
diff --git a/clashoo/files/usr/share/clashoo/nftables/geoip6_cn.nft b/clashoo/files/usr/share/clashoo/nftables/geoip6_cn.nft
index 69367b5b5..5cd2612d3 100644
--- a/clashoo/files/usr/share/clashoo/nftables/geoip6_cn.nft
+++ b/clashoo/files/usr/share/clashoo/nftables/geoip6_cn.nft
@@ -120,7 +120,6 @@ set clashoo_china6 {
2400:5280:1400::/38,
2400:5280:1c00::/38,
2400:5280:3400::/38,
- 2400:5280:4c00::/38,
2400:5280:5000::/36,
2400:5280:6000::/35,
2400:5280:8000::/34,
@@ -962,6 +961,7 @@ set clashoo_china6 {
2402:7d80::/32,
2402:7da0::/32,
2402:7e20::/32,
+ 2402:7ee0::/32,
2402:8180::/32,
2402:8300::/32,
2402:8380::/32,
@@ -1791,7 +1791,11 @@ set clashoo_china6 {
2405:84c0:8050::/48,
2405:84c0:8053::/48,
2405:84c0:8058::/45,
- 2405:84c0:8060::/43,
+ 2405:84c0:8061::/48,
+ 2405:84c0:8062::/47,
+ 2405:84c0:8064::/46,
+ 2405:84c0:8068::/45,
+ 2405:84c0:8070::/44,
2405:84c0:8080::/41,
2405:84c0:8100::/40,
2405:84c0:8200::/39,
@@ -2050,7 +2054,10 @@ set clashoo_china6 {
2406:840:8a0::/43,
2406:840:8c0::/42,
2406:840:900::/40,
- 2406:840:a00::/44,
+ 2406:840:a01::/48,
+ 2406:840:a02::/47,
+ 2406:840:a04::/46,
+ 2406:840:a08::/45,
2406:840:a12::/47,
2406:840:a14::/46,
2406:840:a18::/45,
@@ -2248,7 +2255,8 @@ set clashoo_china6 {
2406:840:9610::/44,
2406:840:9630::/44,
2406:840:9640::/43,
- 2406:840:9660::/46,
+ 2406:840:9660::/48,
+ 2406:840:9662::/47,
2406:840:9664::/47,
2406:840:9667::/48,
2406:840:9668::/45,
@@ -2272,6 +2280,7 @@ set clashoo_china6 {
2406:840:9900::/42,
2406:840:9940::/43,
2406:840:9960::/47,
+ 2406:840:9963::/48,
2406:840:9965::/48,
2406:840:9968::/46,
2406:840:996d::/48,
@@ -3144,8 +3153,7 @@ set clashoo_china6 {
240b:4006::/48,
240b:4006:1000::/43,
240b:4006:1020::/44,
- 240b:4006:1100::/45,
- 240b:4006:110c::/46,
+ 240b:4006:1100::/44,
240b:4006:1110::/45,
240b:4006:1118::/46,
240b:4007::/32,
@@ -3261,8 +3269,6 @@ set clashoo_china6 {
2605:9d80:9042::/48,
2605:9d80:9071::/48,
2605:9d80:9092::/48,
- 2620:57:4004::/47,
- 2804:1e48:9002::/48,
2a03:f900::/29,
2a04:3e00::/48,
2a04:3e00:2::/47,
diff --git a/clashoo/files/usr/share/clashoo/nftables/geoip_cn.nft b/clashoo/files/usr/share/clashoo/nftables/geoip_cn.nft
index 7661766ef..26ac0cf79 100644
--- a/clashoo/files/usr/share/clashoo/nftables/geoip_cn.nft
+++ b/clashoo/files/usr/share/clashoo/nftables/geoip_cn.nft
@@ -394,16 +394,13 @@ set clashoo_china {
43.92.0.0/15,
43.94.0.0/18,
43.96.0.0/23,
- 43.96.2.130/32,
43.96.3.0/24,
43.96.4.0/22,
43.96.8.0/22,
- 43.96.16.130/32,
43.96.18.0/24,
43.96.20.0/22,
43.96.24.0/22,
43.96.32.0/22,
- 43.96.36.130/32,
43.96.40.0/24,
43.96.42.0/24,
43.96.44.0/23,
@@ -414,7 +411,6 @@ set clashoo_china {
43.96.62.0/23,
43.96.64.0/21,
43.96.72.0/22,
- 43.96.77.131/32,
43.96.78.0/24,
43.96.80.0/23,
43.96.85.0/24,
@@ -459,8 +455,7 @@ set clashoo_china {
43.123.0.0/17,
43.123.128.0/18,
43.124.0.0/16,
- 43.125.0.0/20,
- 43.125.24.0/21,
+ 43.125.0.0/19,
43.125.32.0/20,
43.125.48.0/21,
43.126.0.0/17,
@@ -805,6 +800,7 @@ set clashoo_china {
45.117.68.0/22,
45.117.124.0/22,
45.117.252.0/22,
+ 45.119.42.0/23,
45.119.60.0/22,
45.119.64.0/21,
45.119.72.0/22,
@@ -1124,6 +1120,7 @@ set clashoo_china {
47.246.192.0/20,
47.246.208.0/23,
47.246.212.0/22,
+ 47.246.216.0/22,
47.250.0.0/18,
47.250.68.0/22,
47.250.72.0/21,
@@ -1478,8 +1475,7 @@ set clashoo_china {
103.8.108.0/22,
103.8.156.0/22,
103.8.200.0/21,
- 103.8.221.0/24,
- 103.8.222.0/23,
+ 103.8.220.0/22,
103.9.8.0/22,
103.9.24.0/22,
103.9.108.0/22,
@@ -2113,7 +2109,6 @@ set clashoo_china {
103.89.184.0/21,
103.89.192.0/19,
103.89.224.0/21,
- 103.90.51.0/24,
103.90.52.0/22,
103.90.56.0/23,
103.90.92.0/22,
@@ -2683,6 +2678,7 @@ set clashoo_china {
103.186.228.0/23,
103.189.92.0/23,
103.189.152.0/22,
+ 103.189.197.0/24,
103.190.20.0/23,
103.190.71.0/24,
103.190.104.0/23,
@@ -4062,10 +4058,6 @@ set clashoo_china {
124.67.0.0/16,
124.68.16.0/20,
124.68.64.0/18,
- 124.68.176.0/20,
- 124.68.224.0/20,
- 124.68.244.0/22,
- 124.68.248.0/21,
124.69.0.0/16,
124.70.0.0/16,
124.71.0.0/17,
@@ -4445,6 +4437,7 @@ set clashoo_china {
160.202.62.0/24,
160.202.148.0/22,
160.202.152.0/22,
+ 160.202.210.0/23,
160.202.212.0/22,
160.202.216.0/21,
160.202.224.0/19,
@@ -4741,6 +4734,7 @@ set clashoo_china {
192.140.160.0/19,
192.140.192.0/20,
192.140.208.0/21,
+ 192.140.244.0/23,
192.144.128.0/17,
192.163.11.0/24,
192.232.34.0/23,
@@ -6497,12 +6491,14 @@ set clashoo_china {
223.112.0.0/14,
223.116.0.0/15,
223.121.128.0/17,
- 223.122.4.0/22,
- 223.122.12.0/22,
+ 223.122.5.0/24,
+ 223.122.6.0/23,
+ 223.122.13.0/24,
+ 223.122.14.0/23,
223.122.18.0/23,
223.122.20.0/22,
223.122.26.0/23,
- 223.122.28.0/22,
+ 223.122.28.0/23,
223.123.196.0/22,
223.123.204.0/22,
223.123.208.0/20,
diff --git a/clashoo/files/usr/share/clashoo/ruleset/cn-ip.srs b/clashoo/files/usr/share/clashoo/ruleset/cn-ip.srs
index d0b0b7454..ef9118faf 100644
Binary files a/clashoo/files/usr/share/clashoo/ruleset/cn-ip.srs and b/clashoo/files/usr/share/clashoo/ruleset/cn-ip.srs differ
diff --git a/clashoo/files/usr/share/clashoo/ruleset/cn.mrs b/clashoo/files/usr/share/clashoo/ruleset/cn.mrs
index 536c85a75..610f2d2dd 100644
Binary files a/clashoo/files/usr/share/clashoo/ruleset/cn.mrs and b/clashoo/files/usr/share/clashoo/ruleset/cn.mrs differ
diff --git a/clashoo/files/usr/share/clashoo/ruleset/geolocation-!cn.srs b/clashoo/files/usr/share/clashoo/ruleset/geolocation-!cn.srs
index e3c6a5138..6f26b8989 100644
Binary files a/clashoo/files/usr/share/clashoo/ruleset/geolocation-!cn.srs and b/clashoo/files/usr/share/clashoo/ruleset/geolocation-!cn.srs differ
diff --git a/clashoo/files/usr/share/clashoo/ruleset/geosite-cn.srs b/clashoo/files/usr/share/clashoo/ruleset/geosite-cn.srs
index db4f38eb2..216609fe4 100644
Binary files a/clashoo/files/usr/share/clashoo/ruleset/geosite-cn.srs and b/clashoo/files/usr/share/clashoo/ruleset/geosite-cn.srs differ
diff --git a/clashoo/files/usr/share/clashoo/ruleset/private-ip.srs b/clashoo/files/usr/share/clashoo/ruleset/private-ip.srs
index 7fa17b9af..f825d93c0 100644
Binary files a/clashoo/files/usr/share/clashoo/ruleset/private-ip.srs and b/clashoo/files/usr/share/clashoo/ruleset/private-ip.srs differ
diff --git a/clashoo/files/usr/share/clashoo/update/component_update.sh b/clashoo/files/usr/share/clashoo/update/component_update.sh
index 3243fb432..ba0b92bc3 100755
--- a/clashoo/files/usr/share/clashoo/update/component_update.sh
+++ b/clashoo/files/usr/share/clashoo/update/component_update.sh
@@ -296,20 +296,27 @@ load_github_urls() {
return 0
}
+# `~` 是版本里的预发布分隔符,而 release 资产文件名会把它换成 `.`
+# (clashoo_2026.10.25.6780eee-r3_x86_64.ipk 对应已安装的 2026.10.25~6780eee-r3)。
+# 只比分隔符就会误判成「校验失败」,所以比较前统一归一化,解析时保持文件名原样。
+version_equal() {
+ [ -n "$1" ] && [ "$(printf '%s' "$1" | tr '~' '.')" = "$(printf '%s' "$2" | tr '~' '.')" ]
+}
+
package_version_from_url() {
file="${1##*/}"
case "$file" in
clashoo_*_"$ARCH".ipk)
v="${file#clashoo_}"
- v="${v%_${ARCH}.ipk}"
- printf '%s\n' "$v" | sed 's/^\([0-9][0-9][0-9][0-9]\.[0-9][0-9]*\.[0-9][0-9]*\)\./\1~/'
+ printf '%s\n' "${v%_${ARCH}.ipk}"
;;
- clashoo_*.ipk) printf '%s\n' "$file" | sed -n 's/^clashoo_\(.*\)_[^_][^_]*\.ipk$/\1/p' | sed 's/^\([0-9][0-9][0-9][0-9]\.[0-9][0-9]*\.[0-9][0-9]*\)\./\1~/' ;;
+ # 架构名本身可能带下划线(aarch64_generic / arm_cortex-a7_neon-vfpv4),
+ # 只截到版本号为止,否则尾巴会混进版本串。
+ clashoo_*.ipk) printf '%s\n' "$file" | sed -n 's/^clashoo_\([^_]*\)_.*\.ipk$/\1/p' ;;
luci-app-clashoo_*.ipk) printf '%s\n' "$file" | sed -n 's/^luci-app-clashoo_\(.*\)_all\.ipk$/\1/p' ;;
luci-i18n-clashoo-zh-cn_*.ipk) printf '%s\n' "$file" | sed -n 's/^luci-i18n-clashoo-zh-cn_\(.*\)_all\.ipk$/\1/p' ;;
clashoo-*.apk)
- v="$(printf '%s\n' "$file" | sed -n -e 's/^clashoo-\(.*-r[0-9][0-9]*\)-.*\.apk$/\1/p' -e 's/^clashoo-\(.*-r[0-9][0-9]*\)\.apk$/\1/p' | head -n 1)"
- printf '%s\n' "$v" | sed 's/^\([0-9][0-9][0-9][0-9]\.[0-9][0-9]*\.[0-9][0-9]*\)\./\1~/'
+ printf '%s\n' "$file" | sed -n -e 's/^clashoo-\(.*-r[0-9][0-9]*\)-.*\.apk$/\1/p' -e 's/^clashoo-\(.*-r[0-9][0-9]*\)\.apk$/\1/p' | head -n 1
;;
luci-app-clashoo-*.apk) printf '%s\n' "$file" | sed -n -e 's/^luci-app-clashoo-\(.*-r[0-9][0-9]*\)-.*\.apk$/\1/p' -e 's/^luci-app-clashoo-\(.*-r[0-9][0-9]*\)\.apk$/\1/p' | head -n 1 ;;
luci-i18n-clashoo-zh-cn-*.apk) printf '%s\n' "$file" | sed -n -e 's/^luci-i18n-clashoo-zh-cn-\(.*-r[0-9][0-9]*\)-.*\.apk$/\1/p' -e 's/^luci-i18n-clashoo-zh-cn-\(.*-r[0-9][0-9]*\)\.apk$/\1/p' | head -n 1 ;;
@@ -405,14 +412,14 @@ run_pkg_update() {
core_ver_before="$(installed_package_version clashoo)"
apk_core_ready() {
- package_installed clashoo && [ "$(installed_package_version clashoo)" = "$core_ver" ] \
+ package_installed clashoo && version_equal "$(installed_package_version clashoo)" "$core_ver" \
&& [ -x /etc/init.d/clashoo ] && [ -x /usr/share/clashoo/update/component_update.sh ]
}
apk_luci_ready() {
- package_installed luci-app-clashoo && [ "$(installed_package_version luci-app-clashoo)" = "$luci_ver" ] \
+ package_installed luci-app-clashoo && version_equal "$(installed_package_version luci-app-clashoo)" "$luci_ver" \
&& { [ -z "$I18N_URL" ] || { package_installed luci-i18n-clashoo-zh-cn \
- && [ "$(installed_package_version luci-i18n-clashoo-zh-cn)" = "$i18n_ver" ]; }; } \
+ && version_equal "$(installed_package_version luci-i18n-clashoo-zh-cn)" "$i18n_ver"; }; } \
&& [ -r /usr/share/luci/menu.d/luci-app-clashoo.json ] \
&& [ -r /usr/share/rpcd/ucode/luci.clashoo ]
}
@@ -434,7 +441,7 @@ run_pkg_update() {
cur_ver="$(installed_package_version clashoo)"
if [ "$PM" = "apk" ] && apk_update_ready; then
finish 0 "Clashoo 核心已是最新版本"
- elif [ "$PM" != "apk" ] && [ -n "$core_ver" ] && [ "$cur_ver" = "$core_ver" ]; then
+ elif [ "$PM" != "apk" ] && version_equal "$cur_ver" "$core_ver"; then
finish 0 "Clashoo 核心已是最新版本"
fi
else
@@ -442,7 +449,7 @@ run_pkg_update() {
cur_ver="$(installed_package_version luci-app-clashoo)"
if [ "$PM" = "apk" ] && apk_update_ready; then
finish 0 "客户端已是最新版本"
- elif [ "$PM" != "apk" ] && [ -n "$luci_ver" ] && [ "$cur_ver" = "$luci_ver" ]; then
+ elif [ "$PM" != "apk" ] && version_equal "$cur_ver" "$luci_ver"; then
finish 0 "客户端已是最新版本"
fi
fi
@@ -532,11 +539,11 @@ run_pkg_update() {
apk_update_ready || validation_failed
fi
elif [ "$which" = "clashoo" ]; then
- package_installed clashoo && [ "$(installed_package_version clashoo)" = "$core_ver" ] \
+ package_installed clashoo && version_equal "$(installed_package_version clashoo)" "$core_ver" \
&& [ -x /etc/init.d/clashoo ] && [ -x /usr/share/clashoo/update/component_update.sh ] \
|| validation_failed
else
- package_installed luci-app-clashoo && [ "$(installed_package_version luci-app-clashoo)" = "$luci_ver" ] \
+ package_installed luci-app-clashoo && version_equal "$(installed_package_version luci-app-clashoo)" "$luci_ver" \
&& [ -r /usr/share/luci/menu.d/luci-app-clashoo.json ] \
&& [ -r /usr/share/rpcd/ucode/luci.clashoo ] \
|| validation_failed
diff --git a/clashoo/files/usr/share/clashoo/update/geoip.sh b/clashoo/files/usr/share/clashoo/update/geoip.sh
index 387c634f7..9c699e30a 100755
--- a/clashoo/files/usr/share/clashoo/update/geoip.sh
+++ b/clashoo/files/usr/share/clashoo/update/geoip.sh
@@ -12,6 +12,8 @@ cfg_mmdb_url="$(uci -q get clashoo.config.geoip_mmdb_url 2>/dev/null)"
cfg_geosite_url="$(uci -q get clashoo.config.geosite_url 2>/dev/null)"
cfg_geoip_dat_url="$(uci -q get clashoo.config.geoip_dat_url 2>/dev/null)"
cfg_geoip_asn_url="$(uci -q get clashoo.config.geoip_asn_url 2>/dev/null)"
+require_geosite=0
+[ "$1" = "--require-geosite" ] && require_geosite=1
DEFAULT_MMDB_URL="https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb"
DEFAULT_GEOSITE_URL="https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/release/geosite.dat"
@@ -36,6 +38,10 @@ cleanup() {
}
config_needs_geosite() {
+ _cfg="$(uci -q get clashoo.config.use_config 2>/dev/null)"
+ if [ -s "$_cfg" ] && grep -Eq "^[[:space:]]*-[[:space:]]*[\"']?GEOSITE,|^[[:space:]]*geosite:[[:space:]]*|^[[:space:]]*'geosite:|^[[:space:]]*-[[:space:]]*[\"']?geosite:" "$_cfg"; then
+ return 0
+ fi
_cfg="/etc/clashoo/config.yaml"
[ -f "$_cfg" ] || return 1
grep -Eq "^[[:space:]]*-[[:space:]]*[\"']?GEOSITE,|^[[:space:]]*geosite:[[:space:]]*|^[[:space:]]*'geosite:|^[[:space:]]*-[[:space:]]*[\"']?geosite:" "$_cfg"
@@ -133,19 +139,19 @@ download_geosite() {
size=0
if [ -z "$url" ]; then
log "GeoSite.dat skip (url empty)"
- return 0
+ return 1
fi
rm -f "$tmp_target" >/dev/null 2>&1
if ! download_to "$url" "$tmp_target"; then
rm -f "$tmp_target" >/dev/null 2>&1
log "GeoSite.dat update failed"
- return 0
+ return 1
fi
size=$(wc -c <"$tmp_target" 2>/dev/null)
if [ -z "$size" ] || [ "$size" -lt "$GEOSITE_MIN_SIZE" ]; then
log "GeoSite.dat download invalid or incomplete (${size:-0} bytes)"
rm -f "$tmp_target" >/dev/null 2>&1
- return 0
+ return 1
fi
mv -f "$tmp_target" /etc/clashoo/GeoSite.dat >/dev/null 2>&1 || return 1
cp -f /etc/clashoo/GeoSite.dat /etc/clashoo/geosite.dat >/dev/null 2>&1 || true
@@ -248,8 +254,10 @@ if [ "$geoip_source" != "1" ]; then
fi
log "Country.mmdb updated"
- if config_needs_geosite; then
- download_geosite "$geosite_url"
+ if [ "$require_geosite" -eq 1 ] || config_needs_geosite; then
+ if ! download_geosite "$geosite_url"; then
+ exit 1
+ fi
else
log "GeoSite.dat skip (no geosite reference in config)"
fi
@@ -257,6 +265,13 @@ if [ "$geoip_source" != "1" ]; then
download_optional "$geoip_asn_url" /etc/clashoo/GeoLite2-ASN.mmdb "GeoLite2-ASN.mmdb"
fi
+if [ "$geoip_source" = "1" ] && { [ "$require_geosite" -eq 1 ] || config_needs_geosite; }; then
+ geosite_url="${cfg_geosite_url:-$DEFAULT_GEOSITE_URL}"
+ if ! download_geosite "$geosite_url"; then
+ exit 1
+ fi
+fi
+
touch /var/run/geoip_down_complete >/dev/null 2>&1
if pidof mihomo >/dev/null 2>&1 || pidof clash-meta >/dev/null 2>&1; then
diff --git a/cloudflared-next/Makefile b/cloudflared-next/Makefile
index 8f6f8bb3c..db31c53c2 100644
--- a/cloudflared-next/Makefile
+++ b/cloudflared-next/Makefile
@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=cloudflared-next
-PKG_VERSION:=2026.10.3
-PKG_RELEASE:=6
+PKG_VERSION:=2026.10.4
+PKG_RELEASE:=7
# Architecture mapping for pre-built binaries
ifeq ($(ARCH),x86_64)
diff --git a/gately/Makefile b/gately/Makefile
index faa025c9b..38144ecf5 100644
--- a/gately/Makefile
+++ b/gately/Makefile
@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=gately
-PKG_VERSION:=0.5.9
-PKG_RELEASE:=12
+PKG_VERSION:=0.5.9.1
+PKG_RELEASE:=13
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/wangwanjie/gately/tar.gz/v$(PKG_VERSION)?
diff --git a/kai-agent/Makefile b/kai-agent/Makefile
index 34ddc2ff6..ca2373507 100644
--- a/kai-agent/Makefile
+++ b/kai-agent/Makefile
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=kai-agent
-PKG_VERSION:=0.0.27
-PKG_RELEASE:=5
+PKG_VERSION:=0.0.28
+PKG_RELEASE:=6
PKG_SOURCE:=kai-agent-bundle-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/kai-runtime-v$(PKG_VERSION)/
PKG_HASH:=skip
diff --git a/kai/Makefile b/kai/Makefile
index 86aab9513..52bb70f83 100644
--- a/kai/Makefile
+++ b/kai/Makefile
@@ -10,8 +10,8 @@ include $(TOPDIR)/rules.mk
PKG_ARCH_kai:=$(ARCH)
PKG_NAME:=kai
-PKG_VERSION:=0.0.27
-PKG_RELEASE:=10
+PKG_VERSION:=0.0.28
+PKG_RELEASE:=11
PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/kai-runtime-v$(PKG_VERSION)/
PKG_HASH:=skip
@@ -28,7 +28,7 @@ define Package/$(PKG_NAME)
SUBMENU:=
TITLE:=KAI
DEPENDS:=+kai_session +kai-agent +luci-lib-linkeaseauth +linkease-app-entry
- EXTRA_DEPENDS:=kai-agent (>=0.0.27~0)
+ EXTRA_DEPENDS:=kai-agent (>=0.0.28~0), kai_session (>=0.0.28~0)
URL:=https://github.com/linkease/istore-ai-helper
endef
diff --git a/kai/files/kai-session-launch b/kai/files/kai-session-launch
index 8f8dabe6d..5c81c18fc 100755
--- a/kai/files/kai-session-launch
+++ b/kai/files/kai-session-launch
@@ -7,4 +7,7 @@ if [ "$#" -ne 1 ] || [ -z "$1" ]; then
fi
cd "$1"
+OPENCODE_PASSWORD="$(cat "${KAI_ENGINE_PASSWORD_FILE:?missing KAI engine credential file}")"
+[ -n "$OPENCODE_PASSWORD" ] || exit 1
+export OPENCODE_PASSWORD
exec /usr/sbin/kai_session serve --port 8196 --hostname 127.0.0.1
diff --git a/kai/files/kai.init b/kai/files/kai.init
index 30ce62a67..d15afbe58 100755
--- a/kai/files/kai.init
+++ b/kai/files/kai.init
@@ -12,7 +12,9 @@ get_config() {
start_kai_bin(){
procd_open_instance kai_bin
- procd_set_param env KAI_AUTH_MODE="$auth_mode"
+ procd_set_param env KAI_AUTH_MODE="$auth_mode" \
+ KAI_ENGINE_API=v2 KAI_ENGINE_PASSWORD_FILE=/var/run/kai/engine-password \
+ XDG_CONFIG_HOME="${data_dir}/config" XDG_DATA_HOME="${data_dir}/data"
procd_set_param command /usr/sbin/kai_bin -a 0.0.0.0 -p "$port"
procd_set_param stderr 1
procd_set_param respawn 3600 5 5
@@ -31,6 +33,8 @@ start_kai_session(){
return 1
fi
procd_set_param env \
+ KAI_ENGINE_API=v2 \
+ KAI_ENGINE_PASSWORD_FILE=/var/run/kai/engine-password \
OPENCODE_CONFIG="${agent_root}/opencode.json" \
KAIPLUS_SKILLS_DIR="${agent_root}/skills" \
ISTORE_AI_HELPER_BASE="http://127.0.0.1:${port}" \
@@ -61,6 +65,16 @@ start_service() {
mkdir_cwd "${data_dir}/data"
mkdir_cwd "${data_dir}/config"
mkdir_cwd "${data_dir}/state"
+ # Both processes read a private credential. Never put it in procd's JSON
+ # service definition or send it to the browser.
+ (umask 077
+ mkdir -p /var/run/kai
+ chmod 700 /var/run/kai
+ if [ ! -s /var/run/kai/engine-password ]; then
+ head -c 32 /dev/urandom | hexdump -v -e '1/1 "%02x"' > /var/run/kai/engine-password
+ fi
+ chmod 600 /var/run/kai/engine-password
+ ) || return 1
start_kai_session || return 1
start_kai_bin
logger -t kai "Starting KAI Service Completed"
diff --git a/kai_session/Makefile b/kai_session/Makefile
index f1697fe5a..9bb8a0204 100644
--- a/kai_session/Makefile
+++ b/kai_session/Makefile
@@ -10,8 +10,8 @@ include $(TOPDIR)/rules.mk
PKG_ARCH_kai_session:=$(ARCH)
PKG_NAME:=kai_session
-PKG_VERSION:=0.0.27
-PKG_RELEASE:=9
+PKG_VERSION:=0.0.28
+PKG_RELEASE:=10
PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/kai-runtime-v$(PKG_VERSION)/
PKG_HASH:=skip
diff --git a/luci-app-bandix/Makefile b/luci-app-bandix/Makefile
index f434174fa..9a47358ad 100644
--- a/luci-app-bandix/Makefile
+++ b/luci-app-bandix/Makefile
@@ -11,7 +11,7 @@ LUCI_DEPENDS:=+luci-base +luci-lib-jsonc +curl +bandix
PKG_MAINTAINER:=timsaya
PKG_VERSION:=0.12.12
-PKG_RELEASE:=13
+PKG_RELEASE:=14
# Rebuild language packages when a package revision changes without a PO commit.
PKG_PO_VERSION=$(if $(DUMP),x,$(strip $(call findrev)))-r$(PKG_RELEASE)
diff --git a/luci-app-bandix/htdocs/luci-static/resources/bandix/devices.js b/luci-app-bandix/htdocs/luci-static/resources/bandix/devices.js
new file mode 100644
index 000000000..2ee4c79bc
--- /dev/null
+++ b/luci-app-bandix/htdocs/luci-static/resources/bandix/devices.js
@@ -0,0 +1,135 @@
+'use strict';
+'require baseclass';
+'require rpc';
+'require ui';
+
+var callDeleteDevices = rpc.declare({
+ object: 'luci.bandix', method: 'deleteDevices', params: ['macs'], nobatch: true, expect: {}
+});
+
+function key(mac) { return String(mac || '').toLowerCase(); }
+
+// Validate per-device results: HTTP success alone does not mean deletion succeeded.
+function result(reply, requested) {
+ if (!reply || reply.status !== 'success' || !reply.data) {
+ var message = reply && (reply.message || reply.error);
+ if (message === 'Batch deletion is unavailable. Update the Bandix service.') message = _('Batch deletion is unavailable. Update the Bandix service.');
+ throw new Error(message || _('Failed to delete devices'));
+ }
+ var data = reply.data, remaining = new Set(requested);
+ ['deleted', 'not_found', 'failed'].forEach(function (field) {
+ if (!Array.isArray(data[field])) throw new Error(_('Invalid batch deletion response'));
+ data[field].forEach(function (entry) {
+ var mac = field === 'failed' ? entry && entry.mac : entry;
+ if (typeof mac !== 'string' || !remaining.delete(key(mac))) throw new Error(_('Invalid batch deletion response'));
+ });
+ });
+ if (remaining.size) throw new Error(_('Invalid batch deletion response'));
+ return data;
+}
+
+return baseclass.extend({
+ create: function (options) {
+ var selected = new Set(), visible = new Map(), inputs = [], busy = false;
+ var writable = options.writable !== false;
+ var checkboxStyle = 'appearance:none;-webkit-appearance:none;display:block;position:static;transform:none;flex:0 0 16px;box-sizing:border-box;width:16px;height:16px;margin:0;padding:0;align-self:center';
+ var selectAll = E('input', { type: 'checkbox', 'class': 'bandix-device-checkbox', disabled: true, style: checkboxStyle });
+ var remove = E('button', {
+ type: 'button', 'class': 'cbi-button cbi-button-negative', disabled: true,
+ style: 'margin:0;min-height:32px;box-sizing:border-box;align-self:center'
+ }, _('Delete selected'));
+ var toolbar = E('div', { style: 'display:flex;align-items:center;gap:12px;margin:0;font-size:0.875rem;font-weight:normal;white-space:nowrap' }, [
+ E('label', { style: 'display:inline-flex;align-items:center;gap:6px;height:32px;margin:0;padding:0;width:auto;line-height:16px;box-sizing:border-box;white-space:nowrap' }, [
+ selectAll, E('span', { style: 'display:block;font-size:13px;line-height:16px;margin:0;padding:0' }, _('Select all'))
+ ]), remove
+ ]);
+ function sync() {
+ selectAll.checked = visible.size > 0 && selected.size === visible.size;
+ selectAll.indeterminate = selected.size > 0 && selected.size < visible.size;
+ selectAll.disabled = busy || !writable || !visible.size;
+ remove.disabled = busy || !writable || !selected.size;
+ remove.textContent = _('Delete selected') + (selected.size ? ' (' + selected.size + ')' : '');
+ inputs.forEach(function (item) {
+ item.input.checked = selected.has(item.mac);
+ item.input.disabled = busy || !writable;
+ });
+ }
+ selectAll.addEventListener('change', function () {
+ if (busy || !writable) return;
+ selected.clear();
+ if (selectAll.checked) visible.forEach(function (_, mac) { selected.add(mac); });
+ sync();
+ });
+ remove.addEventListener('click', function () {
+ if (busy || !writable || !selected.size) return;
+ // Capture what was selected when opening confirmation, even if polling redraws the list.
+ var macs = Array.from(selected);
+ options.confirm(_('Delete selected'), _('Delete selected devices (%d)? Their traffic history, hostname bindings, rate limits and quotas will also be removed. Online devices may reappear.').format(macs.length), function () {
+ if (busy) return;
+ busy = true; sync();
+ var counts = { deleted: 0, not_found: 0, failed: 0 }, failures = [];
+ function report(error) {
+ if (!error && !counts.failed) return;
+ var text = _('Deleted: %d; not found: %d; failed: %d.').format(counts.deleted, counts.not_found, counts.failed);
+ if (failures.length) text += '\n' + failures.join('\n');
+ if (error) text = _('Failed to delete devices') + ': ' + (error.message || error) + '\n' + text;
+ ui.addNotification(null, E('p', { style: 'white-space:pre-line' }, text), error ? 'error' : 'warning');
+ }
+ var chain = Promise.resolve();
+ // Smaller requests leave time for cleanup within LuCI's RPC timeout.
+ for (var offset = 0; offset < macs.length; offset += 50) {
+ (function (batch) {
+ chain = chain.then(function () {
+ return callDeleteDevices(JSON.stringify(batch)).then(function (reply) {
+ var data = result(reply, batch);
+ ['deleted', 'not_found'].forEach(function (field) {
+ counts[field] += data[field].length;
+ data[field].forEach(function (mac) { selected.delete(key(mac)); });
+ });
+ counts.failed += data.failed.length;
+ data.failed.forEach(function (entry) { failures.push(entry.mac + ': ' + (entry.message || _('Failed to delete device'))); });
+ });
+ });
+ })(macs.slice(offset, offset + 50));
+ }
+ return chain.then(function () {
+ report();
+ }).catch(function (error) {
+ report(error);
+ }).finally(function () {
+ busy = false; sync();
+ return options.refresh();
+ });
+ });
+ });
+ return {
+ toolbar: toolbar,
+ setDevices: function (devices) {
+ visible.clear(); inputs = [];
+ devices.forEach(function (device) { if (device.mac) visible.set(key(device.mac), device); });
+ // Selection follows the displayed list; hidden devices are never deleted by Select all.
+ selected.forEach(function (mac) { if (!visible.has(mac)) selected.delete(mac); });
+ sync();
+ },
+ checkbox: function (device) {
+ var mac = key(device.mac);
+ var input = E('input', {
+ type: 'checkbox', 'class': 'bandix-device-checkbox', 'aria-label': _('Select device') + ': ' + (device.host || device.mac),
+ style: checkboxStyle
+ });
+ // E() stringifies attributes: checked="false" still checks the box.
+ // Set boolean DOM properties after creation instead.
+ input.checked = selected.has(mac);
+ input.disabled = busy || !writable;
+ input.addEventListener('click', function (event) { event.stopPropagation(); });
+ input.addEventListener('change', function () {
+ if (busy || !writable) return;
+ if (input.checked) selected.add(mac); else selected.delete(mac);
+ sync();
+ });
+ inputs.push({ mac: mac, input: input });
+ return input;
+ }
+ };
+ }
+});
diff --git a/luci-app-bandix/htdocs/luci-static/resources/bandix/flows.js b/luci-app-bandix/htdocs/luci-static/resources/bandix/flows.js
new file mode 100644
index 000000000..9f308f32b
--- /dev/null
+++ b/luci-app-bandix/htdocs/luci-static/resources/bandix/flows.js
@@ -0,0 +1,44 @@
+'use strict';
+'require baseclass';
+'require bandix.geoip as geoip';
+
+function normalize(value) {
+ return String(value == null ? '' : value).normalize('NFKC').toLowerCase();
+}
+
+// Index a loaded page once, including information hidden in tooltips/reply columns.
+return baseclass.extend({
+ index: function (items, information) {
+ information = information || {};
+ return items.map(function (flow) {
+ var fields = [flow.protocol, flow.state];
+ fields.push(Array.isArray(flow.flags) ? flow.flags.join(' ') : flow.flags);
+ [flow.orig, flow.repl].forEach(function (direction) {
+ if (!direction) return;
+ [['src', 'sport'], ['dst', 'dport']].forEach(function (endpoint) {
+ var address = direction[endpoint[0]], port = direction[endpoint[1]];
+ fields.push(address, port);
+ if (address != null && port != null) {
+ fields.push(address + ':' + port);
+ if (String(address).indexOf(':') >= 0) fields.push('[' + address + ']:' + port);
+ }
+ var info = information[address];
+ if (!info) return;
+ fields.push(info.country_code, info.organization, info.asn, info.asn != null ? 'AS' + info.asn : '');
+ ['country_names', 'region_names', 'city_names'].forEach(function (field) {
+ Object.keys(info[field] || {}).forEach(function (language) { fields.push(info[field][language]); });
+ });
+ var description = geoip.describe(info);
+ if (description) fields.push(description.summary, description.details);
+ });
+ });
+ return { flow: flow, text: fields.map(normalize).join('\n') };
+ });
+ },
+ filter: function (index, query) {
+ var terms = normalize(query).trim().split(/\s+/).filter(Boolean);
+ return index.filter(function (entry) {
+ return terms.every(function (term) { return entry.text.indexOf(term) >= 0; });
+ }).map(function (entry) { return entry.flow; });
+ }
+});
diff --git a/luci-app-bandix/htdocs/luci-static/resources/bandix/geoip.js b/luci-app-bandix/htdocs/luci-static/resources/bandix/geoip.js
index c4876b507..431e9d5aa 100644
--- a/luci-app-bandix/htdocs/luci-static/resources/bandix/geoip.js
+++ b/luci-app-bandix/htdocs/luci-static/resources/bandix/geoip.js
@@ -50,6 +50,8 @@ return baseclass.extend({
var country = countryName(info), region = localized(info.region_names), city = localized(info.city_names);
var location = [country, region, city].filter(function (value, index, all) { return value && all.indexOf(value) === index; }).join(' · ');
return {
+ location: location || scopes.unknown,
+ organization: info.organization || '',
summary: [location, info.organization].filter(Boolean).join(' · ') || scopes.unknown,
details: [location, info.asn ? 'AS' + info.asn : '', info.organization].filter(Boolean).join('\n')
};
@@ -59,13 +61,14 @@ return baseclass.extend({
if (this._root) return this._root;
var root, current = null, pending = false, refreshing = false, dirtyProfile = false;
var badge = E('strong', {}, _('Loading…'));
- var enabled = E('input', { type: 'checkbox', disabled: true });
- var profile = E('select', { 'class': 'cbi-input-select', 'aria-label': _('Database contents') }, [
- E('option', { value: 'country' }, _('Country/region — approximately 3.3 MiB')),
- E('option', { value: 'country_asn' }, _('Country/region + network organization — approximately 12 MiB')),
- E('option', { value: 'city_asn' }, _('Province/city + network organization — approximately 57 MiB'))
- ]);
- var importedCity = null;
+ var controlStyle = 'appearance:auto;display:block;position:static;transform:none;flex:0 0 16px;box-sizing:border-box;width:16px;height:16px;margin:0;padding:0;align-self:center;';
+ var enabled = E('input', { type: 'checkbox', 'class': 'bandix-geoip-control', style: controlStyle + '-webkit-appearance:checkbox', disabled: true });
+ var country = E('input', { type: 'radio', 'class': 'bandix-geoip-control', style: controlStyle + '-webkit-appearance:radio', name: 'bandix-geoip-location', value: 'country', checked: true, disabled: true });
+ var city = E('input', { type: 'radio', 'class': 'bandix-geoip-control', style: controlStyle + '-webkit-appearance:radio', name: 'bandix-geoip-location', value: 'city', disabled: true });
+ var asn = E('input', { type: 'checkbox', 'class': 'bandix-geoip-control', style: controlStyle + '-webkit-appearance:checkbox', disabled: true, 'aria-label': _('ASN / network organization') });
+ var choices = [country, city, asn];
+ var selection = E('div', { style: 'font-size:0.9em;margin:8px 0' });
+ var installed = E('div', { style: 'margin-bottom:6px' });
var details = E('div', { style: 'color:var(--text-color-secondary,#888);font-size:0.9em;line-height:1.6;overflow-wrap:anywhere' });
var message = E('div', { role: 'status', 'aria-live': 'polite', style: 'font-size:0.9em;overflow-wrap:anywhere' });
var progressFill = E('div', { style: 'width:0%' });
@@ -75,6 +78,26 @@ return baseclass.extend({
var cancel = E('button', { type: 'button', 'class': 'btn cbi-button', style: 'display:none' }, _('Cancel'));
var remove = E('button', { type: 'button', 'class': 'btn cbi-button cbi-button-negative', style: 'display:none' }, _('Delete'));
+ function controlLabel(input, text) {
+ return E('label', { style: 'display:inline-flex;align-items:center;gap:6px;min-height:32px;margin:0;padding:0;width:auto;line-height:16px;box-sizing:border-box' }, [
+ input, E('span', { style: 'display:block;font-size:13px;line-height:16px;margin:0;padding:0' }, text)
+ ]);
+ }
+ function selectedProfile() {
+ return (city.checked ? 'city' : 'country') + (asn.checked ? '_asn' : '');
+ }
+ function setProfile(value) {
+ city.checked = value === 'city' || value === 'city_asn';
+ country.checked = !city.checked;
+ asn.checked = value === 'country_asn' || value === 'city_asn';
+ }
+ function databaseName(kind) {
+ return { country: _('Country/region'), city: _('Country/region + province/city'), asn: _('ASN / network organization') }[kind] || kind;
+ }
+ function showSelection() {
+ selection.textContent = _('Download contents') + ': ' + [databaseName(city.checked ? 'city' : 'country'), asn.checked ? databaseName('asn') : ''].filter(Boolean).join(' + ');
+ selection.style.display = current && current.databases && current.databases.length && current.profile !== selectedProfile() ? '' : 'none';
+ }
function display(status) {
var wasBusy = current && current.job && current.job.busy;
if (wasBusy && status.job && !status.job.busy && status.job.phase === 'completed') dirtyProfile = false;
@@ -83,29 +106,28 @@ return baseclass.extend({
badge.style.color = status.enabled ? 'var(--success-color,#2f9e44)' : '';
badge.textContent = status.enabled ? _('Enabled') : databases.length ? _('Disabled') : _('Not installed');
enabled.checked = !!status.enabled; enabled.disabled = busy || !databases.length;
- if (!dirtyProfile && status.profile) {
- if (status.profile === 'city' && !importedCity) {
- importedCity = E('option', { value: 'city' }, _('Province/city (uploaded database)')); profile.appendChild(importedCity);
- }
- profile.value = status.profile;
- }
- if (job.busy && job.profile) profile.value = job.profile;
- profile.disabled = busy;
+ if (!dirtyProfile && status.profile) setProfile(status.profile);
+ if (job.busy && job.profile) setProfile(job.profile);
+ choices.forEach(function (input) { input.disabled = busy; });
+ showSelection();
download.disabled = busy; upload.disabled = busy;
- download.textContent = databases.length ? _('Update database') : _('Download and enable');
+ download.textContent = databases.length && status.profile === selectedProfile() ? _('Update database') : _('Download and enable');
remove.style.display = databases.length || status.load_error ? '' : 'none'; remove.disabled = busy;
cancel.style.display = job.busy && job.phase !== 'activating' ? '' : 'none'; cancel.disabled = pending;
+ installed.textContent = _('Installed databases') + ': ' + (databases.length ? databases.map(function (db) { return databaseName(db.kind); }).join(' + ') : _('Not installed'));
+ installed.style.display = databases.length ? '' : 'none';
var builds = databases.map(function (db) {
- var name = { country: _('Country/region'), city: _('Province/city'), asn: _('Network organization') }[db.kind] || db.kind;
var date = '—';
if (db.build_epoch) {
try { date = new Date(db.build_epoch * 1000).toLocaleDateString((L.env.lang || 'en').replace(/_/g, '-')); }
catch (e) { date = new Date(db.build_epoch * 1000).toLocaleDateString(); }
}
- return name + ' ' + date;
+ return databaseName(db.kind) + ': ' + date;
});
details.textContent = [
- builds.length ? _('Version') + ': ' + builds.join(' · ') : '',
+ status.source ? _('Installed data source') + ': ' + status.source : '',
+ status.version ? _('Release version') + ': ' + status.version : '',
+ builds.length ? _('Database build dates') + ':\n' + builds.join('\n') : '',
_('Storage used') + ': ' + size(status.installed_bytes) + ' · ' + _('Available space') + ': ' + size(status.free_bytes),
_('Database directory') + ': ' + status.directory
].filter(Boolean).join('\n'); details.style.whiteSpace = 'pre-line';
@@ -141,6 +163,7 @@ return baseclass.extend({
message.textContent = _('GeoIP service is unavailable. Enable connection monitoring and apply settings, or update Bandix.');
message.title = error.message || String(error);
download.disabled = true; upload.disabled = true; enabled.disabled = true;
+ choices.forEach(function (input) { input.disabled = true; });
}).finally(function () { refreshing = false; });
}
function manage(action, selected, checked) {
@@ -152,9 +175,18 @@ return baseclass.extend({
ui.addNotification(null, E('p', _('Database operation failed') + ': ' + (error.message || error)), 'error');
}).finally(function () { pending = false; if (current) display(current); });
}
- profile.addEventListener('change', function () { dirtyProfile = true; });
+ choices.forEach(function (input) {
+ input.addEventListener('change', function () {
+ if (input === country || input === city) {
+ country.checked = input === country;
+ city.checked = input === city;
+ }
+ dirtyProfile = true;
+ if (current) display(current); else showSelection();
+ });
+ });
enabled.addEventListener('change', function () { manage('enable', null, enabled.checked); });
- download.addEventListener('click', function () { manage('download', profile.value); });
+ download.addEventListener('click', function () { manage('download', selectedProfile()); });
cancel.addEventListener('click', function () { manage('cancel'); });
remove.addEventListener('click', function () {
ui.showModal(_('Delete GeoIP databases'), [
@@ -202,20 +234,36 @@ return baseclass.extend({
}).finally(function () { pending = false; if (current) display(current); });
});
ui.showModal(_('Upload database'), [
+ E('p', {}, _('Upload an uncompressed Country, City or ASN .mmdb file. To add ASN, install a Country or City database first.')),
E('p', {}, _('Maximum upload size') + ': ' + size(limit)), selection, uploadError,
E('div', { 'class': 'right' }, [E('button', { type: 'button', 'class': 'btn', click: ui.hideModal }, _('Cancel')), ' ', uploadAction])
]);
});
root = E('div', { style: 'max-width:780px' }, [
+ E('style', {}, '.bandix-geoip-control::before,.bandix-geoip-control::after{content:none!important;display:none!important}'),
E('div', { style: 'display:flex;flex-wrap:wrap;align-items:center;gap:16px;margin-bottom:12px' }, [
- E('label', { style: 'display:flex;align-items:center;gap:8px' }, [enabled, _('Enable IP location lookup')]), badge
+ controlLabel(enabled, _('Enable IP location lookup')), badge
]),
- E('div', { style: 'display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:10px' }, [profile, download, upload, cancel, remove]),
- details, progress, message,
+ E('fieldset', { style: 'border:1px solid var(--border-color,#aaa);border-radius:6px;padding:12px;margin:0 0 10px' }, [
+ E('legend', {}, _('Database contents')),
+ E('div', { style: 'display:flex;flex-wrap:wrap;gap:12px;margin-bottom:10px' }, [
+ controlLabel(country, _('Country/region')),
+ controlLabel(city, _('Country/region + province/city'))
+ ]),
+ controlLabel(asn, _('ASN / network organization')),
+ selection
+ ]),
+ E('div', { style: 'display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:10px' }, [download, upload, cancel, remove]),
+ progress, message,
E('div', { style: 'font-size:0.85em;opacity:0.75;margin-top:8px' }, [
- _('Data source') + ': ', E('a', { href: 'https://github.com/P3TERX/GeoLite.mmdb', target: '_blank', rel: 'noopener noreferrer' }, 'P3TERX / MaxMind GeoLite2'),
- ' · ', E('a', { href: 'https://www.maxmind.com/en/geolite2/eula', target: '_blank', rel: 'noopener noreferrer' }, _('Data terms')),
- E('div', {}, _('Upload an uncompressed Country, City or ASN .mmdb file. To add ASN, install a Country or City database first.'))
+ _('Data source') + ': ', E('a', { href: 'https://github.com/P3TERX/GeoLite.mmdb/releases/latest', target: '_blank', rel: 'noopener noreferrer' }, 'P3TERX / MaxMind GeoLite2')
+ ]),
+ E('details', { style: 'margin-top:8px' }, [
+ E('summary', { style: 'cursor:pointer' }, _('Database details')),
+ installed, details,
+ E('div', { style: 'font-size:0.85em;margin-top:6px' }, [
+ E('a', { href: 'https://www.maxmind.com/en/geolite2/eula', target: '_blank', rel: 'noopener noreferrer' }, _('Data terms'))
+ ])
])
]);
this._root = root;
diff --git a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/connection.js b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/connection.js
index 24852fb83..3af9908b4 100644
--- a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/connection.js
+++ b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/connection.js
@@ -5,6 +5,7 @@
'require rpc';
'require poll';
'require bandix.geoip as geoip';
+'require bandix.flows as flowSearch';
var BANDIX_MODAL_BG_OPENWRT2020_LIGHT = '#ffffff';
var BANDIX_MODAL_BG_OPENWRT2020_DARK = '#2a2a2a';
@@ -148,9 +149,11 @@ function flowEndpointCell(addr, port, information) {
E('span', { 'class': 'flows-port' }, port ? ':' + port : '')
])];
if (description) children.push(E('div', {
- 'class': 'flows-geoip', title: description.details,
- style: 'font-size:0.8rem;opacity:0.75;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px'
- }, description.summary));
+ 'class': 'flows-geoip', title: description.details
+ }, [
+ E('div', {}, description.location || description.summary),
+ description.organization ? E('div', { 'class': 'flows-geoip-network' }, description.organization) : null
+ ]));
return E('td', { 'class': 'flows-addr-cell' }, children);
}
@@ -645,8 +648,8 @@ return view.extend({
.flows-modal-overlay .flows-modal-content {
margin: 16px;
- width: 95vw;
- max-width: 1200px;
+ width: calc(100vw - 32px);
+ max-width: 1680px;
max-height: 85vh;
display: flex;
flex-direction: column;
@@ -710,6 +713,7 @@ return view.extend({
}
.flows-modal-content .flows-filters select,
+ .flows-modal-content .flows-filters input[type="search"],
.flows-modal-content .flows-footer select {
padding: 6px 10px;
border-radius: 4px;
@@ -718,12 +722,30 @@ return view.extend({
}
.flows-modal-content.theme-dark .flows-filters select,
+ .flows-modal-content.theme-dark .flows-filters input[type="search"],
.flows-modal-content.theme-dark .flows-footer select {
background-color: #3a3a3a;
border-color: rgba(255,255,255,0.2);
color: #e5e5e5;
}
+ .flows-modal-content .flows-search {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1 1 320px;
+ min-width: 0;
+ max-width: 100%;
+ margin: 0;
+ }
+
+ .flows-modal-content .flows-search input {
+ flex: 1;
+ min-width: 0;
+ width: 100%;
+ box-sizing: border-box;
+ }
+
.flows-modal-content .flows-table-wrap {
overflow: auto;
flex: 1;
@@ -732,6 +754,7 @@ return view.extend({
.flows-modal-content .flows-table {
width: 100%;
+ min-width: 1200px;
table-layout: fixed;
border-collapse: collapse;
font-size: 0.85rem;
@@ -757,6 +780,10 @@ return view.extend({
.flows-modal-content .flows-table td:nth-child(9) { width: 8%; }
/* IPv6 没有 NAT,回复方向恒为源方向的镜像,隐藏后地址列能放下整条 IPv6 地址 */
+ .flows-modal-content .flows-table.hide-repl {
+ min-width: 900px;
+ }
+
.flows-modal-content .flows-table.hide-repl th:nth-child(6),
.flows-modal-content .flows-table.hide-repl td:nth-child(6),
.flows-modal-content .flows-table.hide-repl th:nth-child(7),
@@ -806,6 +833,21 @@ return view.extend({
white-space: nowrap;
}
+ .flows-modal-content .flows-geoip {
+ margin-top: 6px;
+ font-family: sans-serif;
+ font-size: 0.8rem;
+ line-height: 1.5;
+ opacity: 0.75;
+ white-space: normal;
+ word-break: normal;
+ overflow-wrap: anywhere;
+ }
+
+ .flows-modal-content .flows-geoip-network {
+ margin-top: 2px;
+ }
+
/* ESTABLISHED 是单个长单词,宽度不够时必须允许断开,否则会压到相邻列上 */
.flows-modal-content .flows-table td.flows-state-cell {
overflow-wrap: anywhere;
@@ -1136,6 +1178,15 @@ return view.extend({
var totalPages = 1;
var totalItems = 0;
var currentPageSize = 100;
+ var cachedPage = null;
+ var searchIndex = [];
+ var matchedItems = 0;
+ var requestVersion = 0;
+ var searchInput = E('input', {
+ type: 'search', id: 'flows-search', 'class': 'cbi-input-text',
+ placeholder: _('Search this page: protocol, IP, port, location, ASN…'),
+ 'aria-label': _('Search connections on this page'), autocomplete: 'off'
+ });
var protocolSelect = E('select', { 'class': 'cbi-input-select', 'id': 'flows-protocol' }, [
E('option', { 'value': '' }, _('All')),
@@ -1186,6 +1237,9 @@ return view.extend({
function updatePaginationUi() {
if (pageInfo) {
pageInfo.textContent = _('Page') + ' ' + currentPage + '/' + totalPages + ' · ' + _('Total') + ': ' + totalItems;
+ if (cachedPage && searchInput.value.trim()) {
+ pageInfo.textContent += ' · ' + _('Matches on this page') + ': ' + matchedItems + '/' + cachedPage.items.length;
+ }
}
if (prevPageBtn) {
prevPageBtn.disabled = currentPage <= 1;
@@ -1197,7 +1251,7 @@ return view.extend({
function normalizeFlowsResponse(res) {
if (!res || res.status !== 'success') {
- return { items: [], total: 0, page: currentPage, page_size: currentPageSize, total_pages: 1 };
+ throw new Error(res && (res.message || res.error) || _('Failed to load'));
}
if (Array.isArray(res.data)) {
@@ -1223,10 +1277,51 @@ return view.extend({
};
}
- return { items: [], total: 0, page: currentPage, page_size: currentPageSize, total_pages: 1 };
+ throw new Error(_('Failed to load'));
+ }
+
+ function renderFlows() {
+ if (!cachedPage) return;
+ var flows = flowSearch.filter(searchIndex, searchInput.value);
+ var information = cachedPage.geoip || {};
+ matchedItems = flows.length;
+ updatePaginationUi();
+ tableBody.innerHTML = '';
+ if (!flows.length) {
+ tableBody.appendChild(E('tr', {}, [
+ E('td', { 'colspan': 9, 'class': 'loading-state' }, cachedPage.items.length && searchInput.value.trim() ? _('No matching connections on this page') : _('No connections'))
+ ]));
+ return;
+ }
+ flows.forEach(function (f) {
+ var stateCls = '';
+ if (f.state) {
+ if (f.state.indexOf('ESTABLISHED') >= 0) stateCls = 'flows-state-est';
+ else if (f.state.indexOf('TIME_WAIT') >= 0) stateCls = 'flows-state-wait';
+ else if (f.state.indexOf('CLOSE') >= 0) stateCls = 'flows-state-close';
+ }
+ var sendStr = (f.orig_packets || 0) + ' ' + _('pkts') + ' / ' + formatSize(f.orig_bytes || 0);
+ var replyStr = (f.repl_packets || 0) + ' ' + _('pkts') + ' / ' + formatSize(f.repl_bytes || 0);
+ var flagsStr = (f.flags && f.flags.length) ? f.flags.join(' ') : '-';
+ var protoCls = (f.protocol || '').toLowerCase() === 'tcp' ? 'flows-protocol-tcp' : 'flows-protocol-udp';
+ tableBody.appendChild(E('tr', {}, [
+ E('td', { 'class': protoCls }, (f.protocol || '-').toUpperCase()),
+ E('td', { 'class': 'flows-state-cell ' + stateCls }, f.state || '-'),
+ flowEndpointCell(f.orig && f.orig.src, f.orig && f.orig.sport, information[f.orig && f.orig.src]),
+ flowEndpointCell(f.orig && f.orig.dst, f.orig && f.orig.dport, information[f.orig && f.orig.dst]),
+ E('td', {}, sendStr),
+ flowEndpointCell(f.repl && f.repl.src, f.repl && f.repl.sport, information[f.repl && f.repl.src]),
+ flowEndpointCell(f.repl && f.repl.dst, f.repl && f.repl.dport, information[f.repl && f.repl.dst]),
+ E('td', {}, replyStr),
+ E('td', { 'style': 'font-size: 0.8rem;' }, flagsStr)
+ ]));
+ });
}
function loadFlows(resetPage) {
+ var version = ++requestVersion;
+ cachedPage = null;
+ searchIndex = [];
if (resetPage) {
currentPage = 1;
}
@@ -1238,45 +1333,18 @@ return view.extend({
E('td', { 'colspan': 9, 'class': 'loading-state' }, _('Loading…'))
]));
return callGetConnectionFlows(deviceIp, protocol || null, state || null, currentPage, currentPageSize).then(function (res) {
- tableBody.innerHTML = '';
+ if (version !== requestVersion) return;
var normalized = normalizeFlowsResponse(res);
- var flows = normalized.items;
- var information = normalized.geoip || {};
totalItems = normalized.total;
currentPage = normalized.page;
totalPages = Math.max(1, normalized.total_pages);
- updatePaginationUi();
-
- if (!flows || flows.length === 0) {
- tableBody.appendChild(E('tr', {}, [
- E('td', { 'colspan': 9, 'class': 'loading-state' }, _('No connections'))
- ]));
- return;
- }
- flows.forEach(function (f) {
- var stateCls = '';
- if (f.state) {
- if (f.state.indexOf('ESTABLISHED') >= 0) stateCls = 'flows-state-est';
- else if (f.state.indexOf('TIME_WAIT') >= 0) stateCls = 'flows-state-wait';
- else if (f.state.indexOf('CLOSE') >= 0) stateCls = 'flows-state-close';
- }
- var sendStr = (f.orig_packets || 0) + ' ' + _('pkts') + ' / ' + formatSize(f.orig_bytes || 0);
- var replyStr = (f.repl_packets || 0) + ' ' + _('pkts') + ' / ' + formatSize(f.repl_bytes || 0);
- var flagsStr = (f.flags && f.flags.length) ? f.flags.join(' ') : '-';
- var protoCls = (f.protocol || '').toLowerCase() === 'tcp' ? 'flows-protocol-tcp' : 'flows-protocol-udp';
- tableBody.appendChild(E('tr', {}, [
- E('td', { 'class': protoCls }, (f.protocol || '-').toUpperCase()),
- E('td', { 'class': 'flows-state-cell ' + stateCls }, f.state || '-'),
- flowEndpointCell(f.orig && f.orig.src, f.orig && f.orig.sport, information[f.orig && f.orig.src]),
- flowEndpointCell(f.orig && f.orig.dst, f.orig && f.orig.dport, information[f.orig && f.orig.dst]),
- E('td', {}, sendStr),
- flowEndpointCell(f.repl && f.repl.src, f.repl && f.repl.sport, information[f.repl && f.repl.src]),
- flowEndpointCell(f.repl && f.repl.dst, f.repl && f.repl.dport, information[f.repl && f.repl.dst]),
- E('td', {}, replyStr),
- E('td', { 'style': 'font-size: 0.8rem;' }, flagsStr)
- ]));
- });
+ searchIndex = flowSearch.index(normalized.items, normalized.geoip);
+ cachedPage = normalized;
+ renderFlows();
}).catch(function (err) {
+ if (version !== requestVersion) return;
+ cachedPage = null;
+ searchIndex = [];
totalItems = 0;
totalPages = 1;
currentPage = 1;
@@ -1293,6 +1361,7 @@ return view.extend({
}
function hideFlowsModal() {
+ requestVersion++;
var overlay = document.getElementById('flows-modal-overlay');
if (overlay) overlay.classList.remove('show');
}
@@ -1312,6 +1381,7 @@ return view.extend({
pageSizeSelect.addEventListener('change', function () {
loadFlows(true);
});
+ searchInput.addEventListener('input', renderFlows);
var modalContent = E('div', { 'class': 'flows-modal-content theme-' + currentTheme }, [
E('div', { 'class': 'flows-modal-header' }, [
@@ -1319,6 +1389,9 @@ return view.extend({
E('div', { 'class': 'flows-modal-subtitle' }, (deviceInfo.ip4 || '') + (deviceInfo.mac ? ' · ' + deviceInfo.mac : ''))
]),
E('div', { 'class': 'flows-filters' }, [
+ E('label', { 'class': 'flows-search', 'for': 'flows-search' }, [
+ E('span', {}, _('Search')), searchInput
+ ]),
E('label', {}, _('Protocol') + ':'),
protocolSelect,
E('label', {}, _('State') + ':'),
diff --git a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/index.js b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/index.js
index 384f0668b..6fb39d7f2 100644
--- a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/index.js
+++ b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/index.js
@@ -4,6 +4,7 @@
'require uci';
'require rpc';
'require poll';
+'require bandix.devices as devices';
// 颜色常量定义
var BANDIX_COLOR_UPLOAD = '#f97316'; // 橙色 - 上传/上行
@@ -504,6 +505,20 @@ return view.extend({
render: function (data) {
+ var deviceSelection = devices.create({
+ writable: !!L.hasViewPermission(),
+ confirm: function (title, message, callback) { showConfirmDialog(title, message, callback); },
+ refresh: function () {
+ return fetchAllTrafficQuotas(true).then(function () {
+ return updateDeviceData();
+ }).then(function () {
+ updateTrafficStatistics();
+ }).catch(function (error) {
+ ui.addNotification(null, E('p', {}, _('Unable to fetch data') + ': ' + (error.message || error)), 'error');
+ });
+ }
+ });
+
// 生成样式字符串的函数
function generateStyles(colorScheme) {
var scheme = colorScheme || 'light';
@@ -685,6 +700,68 @@ return view.extend({
width: 100%;
table-layout: fixed;
}
+
+ .bandix-device-checkbox::before,
+ .bandix-device-checkbox::after {
+ content: none !important;
+ display: none !important;
+ }
+
+ .bandix-container input.bandix-device-checkbox {
+ border: 1px solid rgba(100, 116, 139, 0.55) !important;
+ border-radius: 4px !important;
+ background-color: transparent !important;
+ background-image: none !important;
+ box-shadow: none !important;
+ cursor: pointer;
+ transition: border-color 0.15s ease, background-color 0.15s ease;
+ }
+
+ .bandix-container.theme-dark input.bandix-device-checkbox {
+ border-color: rgba(148, 163, 184, 0.65) !important;
+ }
+
+ .bandix-container input.bandix-device-checkbox:hover:not(:disabled),
+ .bandix-container input.bandix-device-checkbox:focus-visible {
+ border-color: #3b82f6 !important;
+ }
+
+ .bandix-container input.bandix-device-checkbox:focus-visible {
+ outline: 2px solid rgba(59, 130, 246, 0.5);
+ outline-offset: 2px;
+ }
+
+ .bandix-container input.bandix-device-checkbox:checked,
+ .bandix-container input.bandix-device-checkbox:indeterminate {
+ border-color: #3b82f6 !important;
+ background-color: #3b82f6 !important;
+ background-repeat: no-repeat !important;
+ background-position: center !important;
+ background-size: 12px 12px !important;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 6l2.3 2.3 4.7-4.6' fill='none' stroke='%23fff' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
+ }
+
+ .bandix-container input.bandix-device-checkbox:indeterminate {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M3 6h6' stroke='%23fff' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E") !important;
+ }
+
+ .bandix-container input.bandix-device-checkbox:disabled {
+ opacity: 0.45;
+ cursor: default;
+ }
+
+ @media (forced-colors: active) {
+ .bandix-container input.bandix-device-checkbox {
+ appearance: auto !important;
+ -webkit-appearance: checkbox !important;
+ background-image: none !important;
+ }
+ }
+
+ .bandix-table th,
+ .bandix-table td {
+ box-sizing: border-box;
+ }
.bandix-table th {
padding: 10px 16px;
@@ -796,6 +873,16 @@ return view.extend({
.bandix-table td:nth-child(5) {
width: 16.5%;
}
+
+ .device-info-selection {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .device-info-selection .device-info {
+ min-width: 0;
+ }
.device-actions {
display: flex;
@@ -1639,12 +1726,29 @@ return view.extend({
}
.device-card {
+ display: grid;
+ grid-template-columns: 16px minmax(0, 1fr);
+ align-items: start;
+ column-gap: 6px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
+
+ .device-card-selection {
+ grid-column: 1;
+ grid-row: 1;
+ display: flex;
+ justify-content: center;
+ padding-top: 4px;
+ }
+
+ .device-card > :not(.device-card-selection) {
+ grid-column: 2;
+ min-width: 0;
+ }
.device-card-header {
@@ -2959,8 +3063,11 @@ return view.extend({
// 主要内容卡片
E('div', { 'class': 'cbi-section' }, [
- E('h3', { 'class': 'history-header', 'style': 'display: flex; align-items: center; justify-content: space-between;' }, [
- E('span', {}, _('Device List')),
+ E('h3', { 'class': 'history-header', 'style': 'display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px' }, [
+ E('div', { style: 'display:flex;align-items:center;flex-wrap:wrap;gap:16px' }, [
+ E('span', { style: 'white-space:nowrap' }, _('Device List')),
+ deviceSelection.toolbar
+ ]),
E('div', { 'class': 'device-toolbar' }, [
E('div', { 'class': 'device-group' }, [
E('span', { 'class': 'device-group-label' }, _('Period')),
@@ -5372,38 +5479,30 @@ return view.extend({
return timeDiff <= offlineThreshold;
}
- function channelToBand(ch) {
- var c = Number(ch);
- if (c < 1) return null;
- if (c <= 14) return '2.4 GHz';
- if (c <= 165) return '5 GHz';
- if (c <= 233) return '6 GHz';
+ function wifiBand(device) {
+ if (device.conn !== 'wifi') return null;
+ var frequency = Number(device.w_freq);
+ if (!Number.isInteger(frequency)) return null;
+ if (frequency >= 2400 && frequency < 2500) return { label: '2.4 GHz', css: 'g24' };
+ if (frequency >= 4900 && frequency < 5925) return { label: '5 GHz', css: 'g5' };
+ if (frequency >= 5925 && frequency <= 7125) return { label: '6 GHz', css: 'g6' };
return null;
}
- function channelToBandClass(ch) {
- var c = Number(ch);
- if (c <= 14) return 'g24';
- if (c <= 165) return 'g5';
- if (c <= 233) return 'g6';
- return '';
- }
-
function buildDeviceUplinkChBadges(device) {
var badges = [];
if (device.uplink && String(device.uplink).trim() !== '') {
badges.push(E('span', { 'class': 'device-uplink-badge', 'title': _('Interface') }, device.uplink.trim()));
}
- if (device.w_ch && Number(device.w_ch) > 0) {
+ if (device.conn === 'wifi' && Number(device.w_ch) > 0) {
var ch = Number(device.w_ch);
- var band = channelToBand(ch);
- var bandClass = channelToBandClass(ch);
- if (band && bandClass) {
- badges.push(E('span', {
- 'class': 'device-ch-badge device-ch-' + bandClass,
- 'title': _('WiFi Channel') + ' ' + ch
- }, band));
- }
+ var band = wifiBand(device);
+ var title = _('WiFi Channel') + ' ' + ch;
+ if (band) title += ' · ' + Number(device.w_freq) + ' MHz';
+ badges.push(E('span', {
+ 'class': 'device-ch-badge ' + (band ? 'device-ch-' + band.css : 'device-uplink-badge'),
+ 'title': title
+ }, band ? band.label : _('WiFi Channel') + ' ' + ch));
}
return badges;
}
@@ -5411,9 +5510,9 @@ return view.extend({
function formatDeviceUplinkCh(device) {
var parts = [];
if (device.uplink && String(device.uplink).trim() !== '') parts.push(device.uplink.trim());
- if (device.w_ch && Number(device.w_ch) > 0) {
- var band = channelToBand(Number(device.w_ch));
- if (band) parts.push(band);
+ if (device.conn === 'wifi' && Number(device.w_ch) > 0) {
+ var band = wifiBand(device);
+ parts.push(band ? band.label : _('WiFi Channel') + ' ' + Number(device.w_ch));
}
return parts.join(' · ');
}
@@ -5984,6 +6083,7 @@ return view.extend({
var stats = result;
if (!stats || !stats.d) {
+ deviceSelection.setDevices([]);
if (trafficDiv) {
trafficDiv.innerHTML = '
' + _('Unable to fetch data') + '
';
}
@@ -6266,6 +6366,7 @@ return view.extend({
// 应用排序
filteredDevices = sortDevices(filteredDevices, currentSortBy, currentSortOrder);
+ deviceSelection.setDevices(filteredDevices);
// 检查是否有任何设备有 IPv6 地址
var hasAnyIPv6 = filteredDevices.some(function (device) {
@@ -6375,7 +6476,10 @@ return view.extend({
var row = E('tr', {}, [
// 设备信息
E('td', {}, [
- E('div', { 'class': 'device-info' }, deviceInfoElements)
+ E('div', { 'class': 'device-info-selection' }, [
+ deviceSelection.checkbox(device),
+ E('div', { 'class': 'device-info' }, deviceInfoElements)
+ ])
]),
// LAN 流量
@@ -6563,6 +6667,7 @@ return view.extend({
// 创建移动端卡片
var card = E('div', { 'class': 'device-card' }, [
+ E('div', { 'class': 'device-card-selection' }, [deviceSelection.checkbox(device)]),
// 卡片头部
E('div', { 'class': 'device-card-header' }, [
E('div', { 'class': 'device-card-name' }, [
diff --git a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/settings.js b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/settings.js
index 5b472cc53..cf825a7e9 100644
--- a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/settings.js
+++ b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/settings.js
@@ -796,8 +796,7 @@ return view.extend({
o = s.option(form.DummyValue, '_geoip_manager', _('IP location database'));
o.renderWidget = function () { return geoip.render(); };
- o = s.option(form.Value, 'geoip_dir', _('Database directory'),
- _('Use persistent storage or an external drive. Save and apply directory changes before downloading.'));
+ o = s.option(form.Value, 'geoip_dir', _('Database directory'));
o.default = '/usr/share/bandix-geoip';
o.placeholder = '/usr/share/bandix-geoip';
o.datatype = 'directory';
diff --git a/luci-app-bandix/po/es/bandix.po b/luci-app-bandix/po/es/bandix.po
index ac2ce1fc9..9af8203e3 100644
--- a/luci-app-bandix/po/es/bandix.po
+++ b/luci-app-bandix/po/es/bandix.po
@@ -1510,3 +1510,71 @@ msgstr "Dirección de escucha"
msgid "IP address for Bandix to listen on. Use 127.0.0.1 for local access or 0.0.0.0 / :: for all interfaces."
msgstr "Dirección IP de escucha de Bandix. Use 127.0.0.1 para acceso local o 0.0.0.0 / :: para todas las interfaces."
+
+msgid "ASN / network organization"
+msgstr "ASN / organización de red"
+
+msgid "Country/region + province/city"
+msgstr "País/región + provincia/ciudad"
+
+msgid "Database build dates"
+msgstr "Fechas de creación de las bases de datos"
+
+msgid "Download contents"
+msgstr "Contenido de la descarga"
+
+msgid "Installed data source"
+msgstr "Origen de los datos instalados"
+
+msgid "Installed databases"
+msgstr "Bases de datos instaladas"
+
+msgid "Release version"
+msgstr "Versión publicada"
+
+msgid "Location and ASN databases work together."
+msgstr "Las bases de ubicación y ASN funcionan juntas."
+
+msgid "Database details"
+msgstr "Detalles de las bases de datos"
+
+msgid "Apply directory changes before downloading."
+msgstr "Aplique los cambios de directorio antes de descargar."
+
+msgid "Batch deletion is unavailable. Update the Bandix service."
+msgstr "La eliminación por lotes no está disponible. Actualice el servicio Bandix."
+
+#, c-format
+msgid "Delete selected devices (%d)? Their traffic history, hostname bindings, rate limits and quotas will also be removed. Online devices may reappear."
+msgstr "¿Eliminar los dispositivos seleccionados (%d)? También se eliminarán su historial de tráfico, sus asociaciones de nombres, sus límites de velocidad y sus cuotas. Los dispositivos conectados pueden reaparecer."
+
+msgid "Delete selected"
+msgstr "Eliminar seleccionados"
+
+#, c-format
+msgid "Deleted: %d; not found: %d; failed: %d."
+msgstr "Eliminados: %d; no encontrados: %d; fallidos: %d."
+
+msgid "Failed to delete devices"
+msgstr "No se pudieron eliminar los dispositivos"
+
+msgid "Invalid batch deletion response"
+msgstr "Respuesta de eliminación por lotes no válida"
+
+msgid "Select all"
+msgstr "Seleccionar todos"
+
+msgid "Select device"
+msgstr "Seleccionar dispositivo"
+
+msgid "Search this page: protocol, IP, port, location, ASN…"
+msgstr "Buscar en esta página: protocolo, IP, puerto, ubicación, ASN…"
+
+msgid "Search connections on this page"
+msgstr "Buscar conexiones en esta página"
+
+msgid "Matches on this page"
+msgstr "Coincidencias en esta página"
+
+msgid "No matching connections on this page"
+msgstr "No hay conexiones coincidentes en esta página"
diff --git a/luci-app-bandix/po/fr/bandix.po b/luci-app-bandix/po/fr/bandix.po
index 5bdbad4c4..4695932db 100644
--- a/luci-app-bandix/po/fr/bandix.po
+++ b/luci-app-bandix/po/fr/bandix.po
@@ -1510,3 +1510,71 @@ msgstr "Adresse d’écoute"
msgid "IP address for Bandix to listen on. Use 127.0.0.1 for local access or 0.0.0.0 / :: for all interfaces."
msgstr "Adresse IP d’écoute de Bandix. Utilisez 127.0.0.1 pour un accès local ou 0.0.0.0 / :: pour toutes les interfaces."
+
+msgid "ASN / network organization"
+msgstr "ASN / organisation réseau"
+
+msgid "Country/region + province/city"
+msgstr "Pays/région + province/ville"
+
+msgid "Database build dates"
+msgstr "Dates de création des bases de données"
+
+msgid "Download contents"
+msgstr "Contenu à télécharger"
+
+msgid "Installed data source"
+msgstr "Source des données installées"
+
+msgid "Installed databases"
+msgstr "Bases de données installées"
+
+msgid "Release version"
+msgstr "Version publiée"
+
+msgid "Location and ASN databases work together."
+msgstr "Les bases de localisation et ASN fonctionnent ensemble."
+
+msgid "Database details"
+msgstr "Détails des bases de données"
+
+msgid "Apply directory changes before downloading."
+msgstr "Appliquez les changements de répertoire avant le téléchargement."
+
+msgid "Batch deletion is unavailable. Update the Bandix service."
+msgstr "La suppression groupée est indisponible. Mettez à jour le service Bandix."
+
+#, c-format
+msgid "Delete selected devices (%d)? Their traffic history, hostname bindings, rate limits and quotas will also be removed. Online devices may reappear."
+msgstr "Supprimer les appareils sélectionnés (%d) ? Leur historique de trafic, leurs associations de noms, leurs limites de débit et leurs quotas seront aussi supprimés. Les appareils en ligne peuvent réapparaître."
+
+msgid "Delete selected"
+msgstr "Supprimer la sélection"
+
+#, c-format
+msgid "Deleted: %d; not found: %d; failed: %d."
+msgstr "Supprimés : %d ; introuvables : %d ; échecs : %d."
+
+msgid "Failed to delete devices"
+msgstr "Échec de la suppression des appareils"
+
+msgid "Invalid batch deletion response"
+msgstr "Réponse de suppression groupée invalide"
+
+msgid "Select all"
+msgstr "Tout sélectionner"
+
+msgid "Select device"
+msgstr "Sélectionner un appareil"
+
+msgid "Search this page: protocol, IP, port, location, ASN…"
+msgstr "Rechercher sur cette page : protocole, IP, port, localisation, ASN…"
+
+msgid "Search connections on this page"
+msgstr "Rechercher les connexions sur cette page"
+
+msgid "Matches on this page"
+msgstr "Correspondances sur cette page"
+
+msgid "No matching connections on this page"
+msgstr "Aucune connexion correspondante sur cette page"
diff --git a/luci-app-bandix/po/ja/bandix.po b/luci-app-bandix/po/ja/bandix.po
index f3c1f4c78..1aa14ad0c 100644
--- a/luci-app-bandix/po/ja/bandix.po
+++ b/luci-app-bandix/po/ja/bandix.po
@@ -1510,3 +1510,71 @@ msgstr "待ち受けアドレス"
msgid "IP address for Bandix to listen on. Use 127.0.0.1 for local access or 0.0.0.0 / :: for all interfaces."
msgstr "Bandix が待ち受ける IP アドレス。ローカルアクセスには 127.0.0.1、すべてのインターフェースには 0.0.0.0 / :: を指定します。"
+
+msgid "ASN / network organization"
+msgstr "ASN / ネットワーク組織"
+
+msgid "Country/region + province/city"
+msgstr "国・地域 + 都道府県・市区町村"
+
+msgid "Database build dates"
+msgstr "データベースの作成日"
+
+msgid "Download contents"
+msgstr "ダウンロード内容"
+
+msgid "Installed data source"
+msgstr "インストール済みデータの提供元"
+
+msgid "Installed databases"
+msgstr "インストール済みデータベース"
+
+msgid "Release version"
+msgstr "リリースバージョン"
+
+msgid "Location and ASN databases work together."
+msgstr "位置情報と ASN のデータベースは併用されます。"
+
+msgid "Database details"
+msgstr "データベースの詳細"
+
+msgid "Apply directory changes before downloading."
+msgstr "ダウンロードする前にディレクトリの変更を適用してください。"
+
+msgid "Batch deletion is unavailable. Update the Bandix service."
+msgstr "一括削除を利用できません。Bandix サービスを更新してください。"
+
+#, c-format
+msgid "Delete selected devices (%d)? Their traffic history, hostname bindings, rate limits and quotas will also be removed. Online devices may reappear."
+msgstr "選択したデバイス(%d 台)を削除しますか?通信履歴、ホスト名の割り当て、速度制限、通信量の上限設定も削除されます。オンラインのデバイスは再表示されることがあります。"
+
+msgid "Delete selected"
+msgstr "選択したデバイスを削除"
+
+#, c-format
+msgid "Deleted: %d; not found: %d; failed: %d."
+msgstr "削除済み: %d、未検出: %d、失敗: %d。"
+
+msgid "Failed to delete devices"
+msgstr "デバイスの削除に失敗しました"
+
+msgid "Invalid batch deletion response"
+msgstr "一括削除の応答が無効です"
+
+msgid "Select all"
+msgstr "すべて選択"
+
+msgid "Select device"
+msgstr "デバイスを選択"
+
+msgid "Search this page: protocol, IP, port, location, ASN…"
+msgstr "このページを検索: プロトコル、IP、ポート、位置、ASN…"
+
+msgid "Search connections on this page"
+msgstr "このページの接続を検索"
+
+msgid "Matches on this page"
+msgstr "このページの一致件数"
+
+msgid "No matching connections on this page"
+msgstr "このページに一致する接続はありません"
diff --git a/luci-app-bandix/po/pl/bandix.po b/luci-app-bandix/po/pl/bandix.po
index 5401ef158..9cfc198fa 100644
--- a/luci-app-bandix/po/pl/bandix.po
+++ b/luci-app-bandix/po/pl/bandix.po
@@ -1510,3 +1510,71 @@ msgstr "Adres nasłuchiwania"
msgid "IP address for Bandix to listen on. Use 127.0.0.1 for local access or 0.0.0.0 / :: for all interfaces."
msgstr "Adres IP nasłuchiwania Bandix. Użyj 127.0.0.1 dla dostępu lokalnego lub 0.0.0.0 / :: dla wszystkich interfejsów."
+
+msgid "ASN / network organization"
+msgstr "ASN / organizacja sieciowa"
+
+msgid "Country/region + province/city"
+msgstr "Kraj/region + województwo/miasto"
+
+msgid "Database build dates"
+msgstr "Daty utworzenia baz danych"
+
+msgid "Download contents"
+msgstr "Zawartość do pobrania"
+
+msgid "Installed data source"
+msgstr "Źródło zainstalowanych danych"
+
+msgid "Installed databases"
+msgstr "Zainstalowane bazy danych"
+
+msgid "Release version"
+msgstr "Wersja wydania"
+
+msgid "Location and ASN databases work together."
+msgstr "Bazy lokalizacji i ASN działają wspólnie."
+
+msgid "Database details"
+msgstr "Szczegóły baz danych"
+
+msgid "Apply directory changes before downloading."
+msgstr "Zastosuj zmiany katalogu przed pobraniem."
+
+msgid "Batch deletion is unavailable. Update the Bandix service."
+msgstr "Usuwanie zbiorcze jest niedostępne. Zaktualizuj usługę Bandix."
+
+#, c-format
+msgid "Delete selected devices (%d)? Their traffic history, hostname bindings, rate limits and quotas will also be removed. Online devices may reappear."
+msgstr "Usunąć wybrane urządzenia (%d)? Ich historia ruchu, powiązania nazw, limity prędkości i limity transferu również zostaną usunięte. Urządzenia online mogą pojawić się ponownie."
+
+msgid "Delete selected"
+msgstr "Usuń wybrane"
+
+#, c-format
+msgid "Deleted: %d; not found: %d; failed: %d."
+msgstr "Usunięto: %d; nie znaleziono: %d; błędy: %d."
+
+msgid "Failed to delete devices"
+msgstr "Nie udało się usunąć urządzeń"
+
+msgid "Invalid batch deletion response"
+msgstr "Nieprawidłowa odpowiedź usuwania zbiorczego"
+
+msgid "Select all"
+msgstr "Zaznacz wszystkie"
+
+msgid "Select device"
+msgstr "Wybierz urządzenie"
+
+msgid "Search this page: protocol, IP, port, location, ASN…"
+msgstr "Szukaj na tej stronie: protokół, IP, port, lokalizacja, ASN…"
+
+msgid "Search connections on this page"
+msgstr "Szukaj połączeń na tej stronie"
+
+msgid "Matches on this page"
+msgstr "Dopasowania na stronie"
+
+msgid "No matching connections on this page"
+msgstr "Brak pasujących połączeń na tej stronie"
diff --git a/luci-app-bandix/po/ru/bandix.po b/luci-app-bandix/po/ru/bandix.po
index 62ed7228f..fa27d4fa2 100644
--- a/luci-app-bandix/po/ru/bandix.po
+++ b/luci-app-bandix/po/ru/bandix.po
@@ -1510,3 +1510,71 @@ msgstr "Адрес прослушивания"
msgid "IP address for Bandix to listen on. Use 127.0.0.1 for local access or 0.0.0.0 / :: for all interfaces."
msgstr "IP-адрес прослушивания Bandix. Используйте 127.0.0.1 для локального доступа или 0.0.0.0 / :: для всех интерфейсов."
+
+msgid "ASN / network organization"
+msgstr "ASN / сетевая организация"
+
+msgid "Country/region + province/city"
+msgstr "Страна/регион + область/город"
+
+msgid "Database build dates"
+msgstr "Даты сборки баз данных"
+
+msgid "Download contents"
+msgstr "Содержимое загрузки"
+
+msgid "Installed data source"
+msgstr "Источник установленных данных"
+
+msgid "Installed databases"
+msgstr "Установленные базы данных"
+
+msgid "Release version"
+msgstr "Версия выпуска"
+
+msgid "Location and ASN databases work together."
+msgstr "Базы геолокации и ASN работают совместно."
+
+msgid "Database details"
+msgstr "Сведения о базах данных"
+
+msgid "Apply directory changes before downloading."
+msgstr "Примените изменения каталога перед загрузкой."
+
+msgid "Batch deletion is unavailable. Update the Bandix service."
+msgstr "Пакетное удаление недоступно. Обновите службу Bandix."
+
+#, c-format
+msgid "Delete selected devices (%d)? Their traffic history, hostname bindings, rate limits and quotas will also be removed. Online devices may reappear."
+msgstr "Удалить выбранные устройства (%d)? История трафика, привязки имён, ограничения скорости и квоты также будут удалены. Активные устройства могут появиться снова."
+
+msgid "Delete selected"
+msgstr "Удалить выбранные"
+
+#, c-format
+msgid "Deleted: %d; not found: %d; failed: %d."
+msgstr "Удалено: %d; не найдено: %d; ошибок: %d."
+
+msgid "Failed to delete devices"
+msgstr "Не удалось удалить устройства"
+
+msgid "Invalid batch deletion response"
+msgstr "Некорректный ответ пакетного удаления"
+
+msgid "Select all"
+msgstr "Выбрать все"
+
+msgid "Select device"
+msgstr "Выбрать устройство"
+
+msgid "Search this page: protocol, IP, port, location, ASN…"
+msgstr "Поиск на странице: протокол, IP, порт, геолокация, ASN…"
+
+msgid "Search connections on this page"
+msgstr "Поиск соединений на этой странице"
+
+msgid "Matches on this page"
+msgstr "Совпадения на странице"
+
+msgid "No matching connections on this page"
+msgstr "На этой странице нет подходящих соединений"
diff --git a/luci-app-bandix/po/zh_Hans/bandix.po b/luci-app-bandix/po/zh_Hans/bandix.po
index 65379d5bb..f24b9c3f8 100644
--- a/luci-app-bandix/po/zh_Hans/bandix.po
+++ b/luci-app-bandix/po/zh_Hans/bandix.po
@@ -1537,3 +1537,71 @@ msgstr "监听地址"
msgid "IP address for Bandix to listen on. Use 127.0.0.1 for local access or 0.0.0.0 / :: for all interfaces."
msgstr "Bandix 监听的 IP 地址。127.0.0.1 仅供本机访问,0.0.0.0 / :: 监听所有接口。"
+
+msgid "ASN / network organization"
+msgstr "ASN / 网络组织"
+
+msgid "Country/region + province/city"
+msgstr "国家/地区 + 省市"
+
+msgid "Database build dates"
+msgstr "数据库构建日期"
+
+msgid "Download contents"
+msgstr "待下载内容"
+
+msgid "Installed data source"
+msgstr "已安装数据来源"
+
+msgid "Installed databases"
+msgstr "已安装数据库"
+
+msgid "Release version"
+msgstr "发布版本"
+
+msgid "Location and ASN databases work together."
+msgstr "位置库与 ASN 库共同生效。"
+
+msgid "Database details"
+msgstr "数据库详情"
+
+msgid "Apply directory changes before downloading."
+msgstr "更改目录后,请先保存并应用,再下载。"
+
+msgid "Batch deletion is unavailable. Update the Bandix service."
+msgstr "批量删除不可用,请更新 Bandix 服务端。"
+
+#, c-format
+msgid "Delete selected devices (%d)? Their traffic history, hostname bindings, rate limits and quotas will also be removed. Online devices may reappear."
+msgstr "删除所选的 %d 台设备?流量历史、主机名绑定、限速规则及配额也将被清除。在线设备可能重新出现。"
+
+msgid "Delete selected"
+msgstr "删除所选设备"
+
+#, c-format
+msgid "Deleted: %d; not found: %d; failed: %d."
+msgstr "已删除:%d;不存在:%d;失败:%d。"
+
+msgid "Failed to delete devices"
+msgstr "删除设备失败"
+
+msgid "Invalid batch deletion response"
+msgstr "批量删除响应无效"
+
+msgid "Select all"
+msgstr "全选"
+
+msgid "Select device"
+msgstr "选择设备"
+
+msgid "Search this page: protocol, IP, port, location, ASN…"
+msgstr "搜索本页:协议、IP、端口、归属地、ASN…"
+
+msgid "Search connections on this page"
+msgstr "搜索本页连接"
+
+msgid "Matches on this page"
+msgstr "本页匹配"
+
+msgid "No matching connections on this page"
+msgstr "本页没有匹配的连接"
diff --git a/luci-app-bandix/po/zh_Hant/bandix.po b/luci-app-bandix/po/zh_Hant/bandix.po
index e12464726..4656e5173 100644
--- a/luci-app-bandix/po/zh_Hant/bandix.po
+++ b/luci-app-bandix/po/zh_Hant/bandix.po
@@ -1510,3 +1510,71 @@ msgstr "監聽位址"
msgid "IP address for Bandix to listen on. Use 127.0.0.1 for local access or 0.0.0.0 / :: for all interfaces."
msgstr "Bandix 監聽的 IP 位址。127.0.0.1 僅供本機存取,0.0.0.0 / :: 監聽所有介面。"
+
+msgid "ASN / network organization"
+msgstr "ASN / 網路組織"
+
+msgid "Country/region + province/city"
+msgstr "國家/地區 + 省市"
+
+msgid "Database build dates"
+msgstr "資料庫建置日期"
+
+msgid "Download contents"
+msgstr "待下載內容"
+
+msgid "Installed data source"
+msgstr "已安裝資料來源"
+
+msgid "Installed databases"
+msgstr "已安裝資料庫"
+
+msgid "Release version"
+msgstr "發佈版本"
+
+msgid "Location and ASN databases work together."
+msgstr "位置資料庫與 ASN 資料庫共同生效。"
+
+msgid "Database details"
+msgstr "資料庫詳細資訊"
+
+msgid "Apply directory changes before downloading."
+msgstr "變更目錄後,請先儲存並套用,再下載。"
+
+msgid "Batch deletion is unavailable. Update the Bandix service."
+msgstr "批次刪除無法使用,請更新 Bandix 服務端。"
+
+#, c-format
+msgid "Delete selected devices (%d)? Their traffic history, hostname bindings, rate limits and quotas will also be removed. Online devices may reappear."
+msgstr "刪除所選的 %d 台裝置?流量歷史、主機名稱綁定、限速規則及配額也將被清除。線上裝置可能重新出現。"
+
+msgid "Delete selected"
+msgstr "刪除所選裝置"
+
+#, c-format
+msgid "Deleted: %d; not found: %d; failed: %d."
+msgstr "已刪除:%d;不存在:%d;失敗:%d。"
+
+msgid "Failed to delete devices"
+msgstr "刪除裝置失敗"
+
+msgid "Invalid batch deletion response"
+msgstr "批次刪除回應無效"
+
+msgid "Select all"
+msgstr "全選"
+
+msgid "Select device"
+msgstr "選擇裝置"
+
+msgid "Search this page: protocol, IP, port, location, ASN…"
+msgstr "搜尋本頁:協定、IP、連接埠、位置、ASN…"
+
+msgid "Search connections on this page"
+msgstr "搜尋本頁連線"
+
+msgid "Matches on this page"
+msgstr "本頁符合"
+
+msgid "No matching connections on this page"
+msgstr "本頁沒有符合的連線"
diff --git a/luci-app-bandix/root/usr/libexec/rpcd/luci.bandix b/luci-app-bandix/root/usr/libexec/rpcd/luci.bandix
index d00115a7c..f8daf0b67 100755
--- a/luci-app-bandix/root/usr/libexec/rpcd/luci.bandix
+++ b/luci-app-bandix/root/usr/libexec/rpcd/luci.bandix
@@ -225,6 +225,48 @@ delete_device() {
echo "$response"
}
+# Batch deletion receives a JSON-encoded MAC array, matching the string RPC
+# parameters used by other structured requests in this plugin.
+delete_devices() {
+ local macs="$1" type keys count=0 key mac validated="" payload response http_code
+ [ "${#macs}" -le 65536 ] || { make_error "Too many MAC addresses"; return; }
+ json_init
+ json_load "{\"macs\":$macs}" 2>/dev/null || { make_error "Invalid MAC array"; return; }
+ json_get_type type macs
+ [ "$type" = array ] || { make_error "Invalid MAC array"; return; }
+ json_select macs
+ json_get_keys keys
+ for key in $keys; do
+ count=$((count + 1))
+ [ "$count" -le 1000 ] || { make_error "macs must contain between 1 and 1000 MAC addresses"; return; }
+ json_get_type type "$key"
+ [ "$type" = string ] || { make_error "Invalid MAC address"; return; }
+ json_get_var mac "$key"
+ case "$mac" in
+ [[:xdigit:]][[:xdigit:]]:[[:xdigit:]][[:xdigit:]]:[[:xdigit:]][[:xdigit:]]:[[:xdigit:]][[:xdigit:]]:[[:xdigit:]][[:xdigit:]]:[[:xdigit:]][[:xdigit:]]) ;;
+ *) make_error "Invalid MAC address"; return ;;
+ esac
+ validated="$validated $mac"
+ done
+ [ "$count" -gt 0 ] || { make_error "macs must contain between 1 and 1000 MAC addresses"; return; }
+ json_init
+ json_add_array macs
+ for mac in $validated; do json_add_string "" "$mac"; done
+ json_close_array
+ payload=$(json_dump)
+ json_cleanup
+ response=$(curl -s --connect-timeout 3 --max-time 15 -w '\n%{http_code}' -X DELETE -H 'Content-Type: application/json' --data-binary "$payload" "$BANDIX_DEVICES_API/batch" 2>/dev/null)
+ if [ $? -ne 0 ] || [ -z "$response" ]; then
+ make_error "Failed to connect to Bandix service"
+ return
+ fi
+ http_code=$(printf '%s\n' "$response" | tail -n 1)
+ case "$http_code" in
+ 404|405) make_error "Batch deletion is unavailable. Update the Bandix service." ;;
+ *) printf '%s\n' "$response" | sed '$d' ;;
+ esac
+}
+
# 获取设备状态
# GET /api/traffic/devices -> $.data
# {
@@ -236,6 +278,7 @@ delete_device() {
# conn, # 接入类型 (wifi/wired/router)
# uplink, # 接入接口名 (eth1/lan2/phy0-ap0),无则空串
# w_ch, # WiFi 频道号 (1/36/149),非 WiFi 为 0
+# w_freq, # WiFi 主信道频率 (MHz),不可用时为 0
# last, # 最后在线时间戳(ms)
# t_rx_b, t_tx_b, # 总下/上行字节
# t_rx_r, t_tx_r, # 总下/上行速率
@@ -1356,6 +1399,9 @@ case "$1" in
json_add_object "deleteDevice"
json_add_string "mac"
json_close_object
+ json_add_object "deleteDevices"
+ json_add_string "macs"
+ json_close_object
json_add_object "getGeoipStatus"
json_close_object
@@ -1579,6 +1625,13 @@ case "$1" in
fi
delete_device "$mac"
;;
+ deleteDevices)
+ input=""
+ read -r input
+ macs="$(printf '%s' "$input" | jsonfilter -e '$.macs' 2>/dev/null)"
+ [ -z "$macs" ] && macs="$(printf '%s' "$input" | jsonfilter -e '$[0]' 2>/dev/null)"
+ delete_devices "$macs"
+ ;;
getGeoipStatus)
get_geoip_status
;;
diff --git a/luci-app-bandix/root/usr/share/rpcd/acl.d/luci-app-bandix.json b/luci-app-bandix/root/usr/share/rpcd/acl.d/luci-app-bandix.json
index 573f9d0c7..f381c3f6a 100644
--- a/luci-app-bandix/root/usr/share/rpcd/acl.d/luci-app-bandix.json
+++ b/luci-app-bandix/root/usr/share/rpcd/acl.d/luci-app-bandix.json
@@ -47,6 +47,7 @@
"getConnectionFlows",
"setHostname",
"deleteDevice",
+ "deleteDevices",
"getDnsQueries",
"getDnsStats",
"getScheduleLimits",
diff --git a/luci-app-clashoo/Makefile b/luci-app-clashoo/Makefile
index 742d691f8..bd4b7483b 100644
--- a/luci-app-clashoo/Makefile
+++ b/luci-app-clashoo/Makefile
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-clashoo
PKG_VERSION:=1.31.0
-PKG_RELEASE:=9
+PKG_RELEASE:=10
PKG_MAINTAINER:=kenzok8
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
diff --git a/luci-app-clashoo/root/usr/share/rpcd/ucode/luci.clashoo b/luci-app-clashoo/root/usr/share/rpcd/ucode/luci.clashoo
index 73ba1c98f..9b128e33a 100755
--- a/luci-app-clashoo/root/usr/share/rpcd/ucode/luci.clashoo
+++ b/luci-app-clashoo/root/usr/share/rpcd/ucode/luci.clashoo
@@ -273,6 +273,8 @@ if (clashoo_running()) return 'mihomo';
return '';
}
+let selected_mihomo_stable;
+
function find_binary_for_family(family) {
if (family === 'mihomo') {
let pid = procd_instance_pid('clashoo');
@@ -889,7 +891,7 @@ if (m) return m[1];
return full;
}
-function selected_mihomo_stable() {
+selected_mihomo_stable = function() {
let bundled = '/usr/libexec/clashoo/mihomo-stable';
let manual = '/usr/share/clashoo/bin/mihomo-stable';
if (access(manual, 'x') && read_first_line('/usr/share/clashoo/clash_meta_version') == 'custom-url') return manual;
@@ -904,7 +906,7 @@ for (let i = 1; i <= 3; i++) {
if (int(mv[i]) < int(bv[i])) return bundled;
}
return manual;
-}
+};
function component_installed_label(id) {
if (id == 'clashoo') return pkg_version('clashoo') || '未安装';
diff --git a/luci-app-kai/Makefile b/luci-app-kai/Makefile
deleted file mode 100755
index 05a70680a..000000000
--- a/luci-app-kai/Makefile
+++ /dev/null
@@ -1,28 +0,0 @@
-#
-# Copyright (C) 2008-2014 The LuCI Team
-#
-# This is free software, licensed under the Apache License, Version 2.0 .
-#
-
-include $(TOPDIR)/rules.mk
-
-LUCI_TITLE:=kai
-PKG_VERSION:=1.0.2-r5
-PKG_RELEASE:=12
-LUCI_DEPENDS:=+kai +luci-compat +luci-lib-linkeaseauth +linkease-app-entry
-LUCI_MINIFY_CSS:=0
-LUCI_MINIFY_JS:=0
-
-define Package/luci-app-kai/conffiles
-/etc/config/kai
-endef
-
-define Package/luci-app-kai/postrm
-#!/bin/sh
-rm -f /tmp/luci-indexcache
-exit 0
-endef
-
-include $(TOPDIR)/feeds/luci/luci.mk
-
-# call BuildPackage - OpenWrt buildroot signature
diff --git a/luci-app-kai/luasrc/controller/kai.lua b/luci-app-kai/luasrc/controller/kai.lua
deleted file mode 100644
index 128d247ae..000000000
--- a/luci-app-kai/luasrc/controller/kai.lua
+++ /dev/null
@@ -1,29 +0,0 @@
-local http = require "luci.http"
-
-module("luci.controller.kai", package.seeall)
-
-function index()
- if not nixio.fs.access("/etc/config/kai") then
- return
- end
- local page
- page = entry({"admin", "services","kai"}, cbi("kai"), _("KAI"), 100)
- page.dependent = true
- entry({"admin", "services", "kai_status"}, call("kai_status"))
-
-end
-
-function kai_status()
- local sys = require "luci.sys"
- local uci = require "luci.model.uci".cursor()
- local port = tonumber(uci:get_first("kai", "kai", "port")) or 8197
- if port < 1 or port > 65535 or port % 1 ~= 0 then
- port = 8197
- end
- local status = {
- running = (sys.call("pidof kai_bin >/dev/null") == 0),
- port = port
- }
- http.prepare_content("application/json")
- http.write_json(status)
-end
diff --git a/luci-app-kai/luasrc/model/cbi/kai.lua b/luci-app-kai/luasrc/model/cbi/kai.lua
deleted file mode 100644
index 8c8fb48a2..000000000
--- a/luci-app-kai/luasrc/model/cbi/kai.lua
+++ /dev/null
@@ -1,32 +0,0 @@
-local m, s
-
-m = Map("kai", translate("KAI"), translate("KAI is an efficient AI tool."))
-m:section(SimpleSection).template = "kai/kai_status"
-
-s=m:section(TypedSection, "kai", translate("Global settings"))
-s.addremove=false
-s.anonymous=true
-
-s:option(Flag, "enabled", translate("Enable")).rmempty=false
-
-local kai_model = require "luci.model.kai"
-local blocks = kai_model.blocks()
-local home = kai_model.home()
-
-local data_dir = s:option(Value, "data_dir", translate("Data directory"))
-data_dir.rmempty = false
-data_dir.description = translate("Required. KAI session will store cwd/cache/data/config/state under this directory (subfolders: cwd, cache, data, config, state).")
-
-local paths, default_path = kai_model.find_paths(blocks, home, "Configs")
-for _, val in pairs(paths) do
- data_dir:value(val, val)
-end
-data_dir.default = default_path
-
-local port = s:option(Value, "port", translate("API port"))
-port.default = "8197"
-port.rmempty = false
-port.datatype = "port"
-port.description = translate("Port for KAI HTTP server (kai_bin). kai_session will read OPENCODE_CONFIG via 127.0.0.1:.")
-
-return m
diff --git a/luci-app-kai/luasrc/model/kai.lua b/luci-app-kai/luasrc/model/kai.lua
deleted file mode 100644
index b4ef84996..000000000
--- a/luci-app-kai/luasrc/model/kai.lua
+++ /dev/null
@@ -1,54 +0,0 @@
-local jsonc = require "luci.jsonc"
-
-local kai = {}
-
-kai.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
-
-kai.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")
- home_dirs["Public"] = uci:get_first("quickstart", "main", "pub_dir", home_dirs["main_dir"] .. "/Public")
- home_dirs["Downloads"] = uci:get_first("quickstart", "main", "dl_dir", home_dirs["Public"] .. "/Downloads")
- home_dirs["Caches"] = uci:get_first("quickstart", "main", "tmp_dir", home_dirs["main_dir"] .. "/Caches")
- return home_dirs
-end
-
-kai.find_paths = function(blocks, home_dirs, path_name)
- local default_path = ""
- local paths = {}
-
- default_path = home_dirs[path_name] .. "/KAI"
- if #blocks == 0 then
- table.insert(paths, default_path)
- else
- for _, val in pairs(blocks) do
- table.insert(paths, val .. "/" .. path_name .. "/KAI")
- end
- local without_conf_dir = "/root/" .. path_name .. "/KAI"
- if default_path == without_conf_dir then
- default_path = paths[1]
- end
- end
-
- return paths, default_path
-end
-
-return kai
-
diff --git a/luci-app-kai/luasrc/view/kai/kai_status.htm b/luci-app-kai/luasrc/view/kai/kai_status.htm
deleted file mode 100644
index 43c2c0e37..000000000
--- a/luci-app-kai/luasrc/view/kai/kai_status.htm
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
- <%:Status%>
-
- <%:Collecting data...%>
-
-
diff --git a/luci-app-kaiplus/Makefile b/luci-app-kaiplus/Makefile
index d8cf3c305..7e97adc0d 100644
--- a/luci-app-kaiplus/Makefile
+++ b/luci-app-kaiplus/Makefile
@@ -7,8 +7,8 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=KaiPlus
-PKG_VERSION:=1.0.9-r8
-PKG_RELEASE:=13
+PKG_VERSION:=1.0.9-r9
+PKG_RELEASE:=14
LUCI_DEPENDS:=+kaiplus +luci-compat +luci-lib-taskd +luci-lib-linkeaseauth +linkease-app-entry
LUCI_MINIFY_CSS:=0
LUCI_MINIFY_JS:=0
diff --git a/luci-app-kaiplus/luasrc/controller/kaiplus.lua b/luci-app-kaiplus/luasrc/controller/kaiplus.lua
index 67fa9c39f..f5871ec43 100644
--- a/luci-app-kaiplus/luasrc/controller/kaiplus.lua
+++ b/luci-app-kaiplus/luasrc/controller/kaiplus.lua
@@ -13,7 +13,11 @@ function index()
if not nixio.fs.access("/etc/config/kaiplus") then return end
local install_kai = entry({"admin", "services", "kaiplus", "install_kai"}, call("install_kai"))
install_kai.leaf = true
- entry({"admin", "services", "kaiplus"}, cbi("kaiplus"), _("KaiPlus"), 100).dependent = true
+ entry({"admin", "services", "kaiplus", "update_kai"}, call("update_kai")).leaf = true
+ entry({"admin", "services", "kaiplus"}, alias("admin", "services", "kaiplus", "settings"), _("KaiPlus"), 100).dependent = true
+ entry({"admin", "services", "kaiplus", "settings"}, cbi("kaiplus"), _("KaiPlus"), 10)
+ entry({"admin", "services", "kaiplus", "classic"}, cbi("kaiplus/classic"), _("CLASSIC KAI"), 20)
+ entry({"admin", "services", "kaiplus", "classic_status"}, call("classic_status")).leaf = true
end
local function write_json(obj)
@@ -51,15 +55,11 @@ local function compat()
end
function kaiplus_status()
- local fs = require "nixio.fs"
local sys = require "luci.sys"
local uci = require("luci.model.uci").cursor()
compat():legacy_status("kaiplus", {
running = sys.call("pidof kaiplus >/dev/null") == 0,
- kai_installed = not not (
- fs.access("/usr/share/kai/kai-plugin.json")
- and fs.access("/usr/lib/lua/luci/controller/kai.lua")
- ),
+ kai_installed = require("luci.model.kaiplus.classic").installed(),
port = uci:get_first("kaiplus", "kaiplus", "port") or "8189",
base_path = "/apps/kaiplus/",
listen_mode = "tcp",
@@ -72,7 +72,11 @@ function kaiplus_open()
compat():open("kaiplus")
end
-function install_kai()
+function classic_status()
+ write_json(require("luci.model.kaiplus.classic").status())
+end
+
+local function classic_package_task(action)
local fs = require "nixio.fs"
local jsonc = require "luci.jsonc"
local sys = require "luci.sys"
@@ -83,11 +87,16 @@ function install_kai()
return
end
- if fs.access("/usr/share/kai/kai-plugin.json")
- and fs.access("/usr/lib/lua/luci/controller/kai.lua") then
+ local installed = require("luci.model.kaiplus.classic").installed()
+ if action == "install" and installed then
write_json({ ok = true, installed = true })
return
end
+ if action == "update" and not installed then
+ http.status(409, "Conflict")
+ write_json({ ok = false, error = "CLASSIC KAI is not installed" })
+ return
+ end
if not fs.access("/etc/init.d/tasks") then
write_json({ ok = false, error = "taskd is not available" })
return
@@ -96,6 +105,10 @@ function install_kai()
write_json({ ok = false, error = "iStore package installer is not available" })
return
end
+ if not fs.access("/usr/libexec/kaiplus-classic", "x") then
+ write_json({ ok = false, error = "CLASSIC KAI package helper is not available" })
+ return
+ end
local task_status = sys.exec("/etc/init.d/tasks task_status " .. task_id .. " 2>/dev/null")
local status = jsonc.parse(task_status) or {}
@@ -105,19 +118,20 @@ function install_kai()
return
end
- local install_script = table.concat({
- 'set -e',
- 'echo "[KaiPlus] Installing classic KAI..."',
- '/bin/is-opkg install luci-app-kai',
- 'rm -f /tmp/luci-indexcache',
- 'echo "[KaiPlus] Classic KAI installed successfully."'
- }, "\n")
- local command = "/bin/sh -c " .. util.shellquote(install_script)
+ local command = "/usr/libexec/kaiplus-classic " .. action
local rc = sys.call("/etc/init.d/tasks task_add " .. task_id .. " " .. util.shellquote(command) .. " >/dev/null 2>&1")
if rc ~= 0 then
- write_json({ ok = false, error = "failed to start KAI installation", task_id = task_id })
+ write_json({ ok = false, error = "failed to start CLASSIC KAI package task", task_id = task_id })
return
end
write_json({ ok = true, task_id = task_id })
end
+
+function install_kai()
+ classic_package_task("install")
+end
+
+function update_kai()
+ classic_package_task("update")
+end
diff --git a/luci-app-kaiplus/luasrc/model/cbi/kaiplus/classic.lua b/luci-app-kaiplus/luasrc/model/cbi/kaiplus/classic.lua
new file mode 100644
index 000000000..ed1fdb2ee
--- /dev/null
+++ b/luci-app-kaiplus/luasrc/model/cbi/kaiplus/classic.lua
@@ -0,0 +1,48 @@
+local classic = require "luci.model.kaiplus.classic"
+local sys = require "luci.sys"
+local m
+
+local description = translate("CLASSIC KAI can run alongside KaiPlus. Its settings and data are independent.")
+if not classic.installed() then
+ m = SimpleForm("classic_kai", translate("CLASSIC KAI"), description)
+ m.submit = false
+ m.reset = false
+ m:section(SimpleSection).template = "kaiplus/classic_status"
+ return m
+end
+
+m = Map("kai", translate("CLASSIC KAI"), description)
+m:section(SimpleSection).template = "kaiplus/classic_status"
+
+m.on_after_commit = function(self)
+ local action = self.uci:get_first("kai", "kai", "enabled") == "1" and "restart" or "stop"
+ if sys.call("/etc/init.d/kai " .. action .. " >/dev/null 2>&1") ~= 0 then
+ self.message = translate("CLASSIC KAI could not apply the service settings. Check its configuration and system log.")
+ end
+end
+
+local s = m:section(TypedSection, "kai", translate("Global settings"))
+s.addremove = false
+s.anonymous = true
+
+local enabled = s:option(Flag, "enabled", translate("Enable"))
+enabled.rmempty = false
+enabled.description = translate("Save & Apply starts or stops CLASSIC KAI without restarting KaiPlus.")
+
+local model = require "luci.model.kaiplus"
+local paths, default_path = model.find_paths(model.blocks(), model.home(), "Configs", "KAI")
+local data_dir = s:option(Value, "data_dir", translate("Data directory"))
+data_dir.rmempty = false
+data_dir.description = translate("Required. CLASSIC KAI stores its workspace, cache, config, and state here. Use a different directory from KaiPlus.")
+for _, path in ipairs(paths) do
+ data_dir:value(path, path)
+end
+data_dir.default = default_path
+
+local port = s:option(Value, "port", translate("Access port"))
+port.default = "8197"
+port.rmempty = false
+port.datatype = "port"
+port.description = translate("CLASSIC KAI is available at http://router-address:port/apps/kai/web/. Use a different port from KaiPlus.")
+
+return m
diff --git a/luci-app-kaiplus/luasrc/model/kaiplus.lua b/luci-app-kaiplus/luasrc/model/kaiplus.lua
index b592386a5..48bfc036e 100644
--- a/luci-app-kaiplus/luasrc/model/kaiplus.lua
+++ b/luci-app-kaiplus/luasrc/model/kaiplus.lua
@@ -30,17 +30,18 @@ kaiplus.home = function()
return home_dirs
end
-kaiplus.find_paths = function(blocks, home_dirs, path_name)
- local default_path = home_dirs[path_name] .. "/KAIPlus"
+kaiplus.find_paths = function(blocks, home_dirs, path_name, product)
+ product = product or "KAIPlus"
+ local default_path = home_dirs[path_name] .. "/" .. product
local paths = {}
if #blocks == 0 then
table.insert(paths, default_path)
else
for _, val in pairs(blocks) do
- table.insert(paths, val .. "/" .. path_name .. "/KAIPlus")
+ table.insert(paths, val .. "/" .. path_name .. "/" .. product)
end
- local without_conf_dir = "/root/" .. path_name .. "/KAIPlus"
+ local without_conf_dir = "/root/" .. path_name .. "/" .. product
if default_path == without_conf_dir then
default_path = paths[1]
end
diff --git a/luci-app-kaiplus/luasrc/model/kaiplus/classic.lua b/luci-app-kaiplus/luasrc/model/kaiplus/classic.lua
new file mode 100644
index 000000000..77eb689c7
--- /dev/null
+++ b/luci-app-kaiplus/luasrc/model/kaiplus/classic.lua
@@ -0,0 +1,24 @@
+local fs = require "nixio.fs"
+local sys = require "luci.sys"
+
+local classic = {}
+
+function classic.installed()
+ return not not (
+ fs.access("/usr/sbin/kai_bin", "x")
+ and fs.access("/usr/sbin/kai_session", "x")
+ and fs.access("/etc/init.d/kai", "x")
+ and fs.access("/etc/config/kai")
+ and fs.access("/usr/share/kai/kai-plugin.json")
+ )
+end
+
+function classic.status()
+ local installed = classic.installed()
+ return {
+ installed = installed,
+ running = installed and sys.call("pidof kai_bin >/dev/null") == 0
+ }
+end
+
+return classic
diff --git a/luci-app-kaiplus/luasrc/view/kaiplus/classic_status.htm b/luci-app-kaiplus/luasrc/view/kaiplus/classic_status.htm
new file mode 100644
index 000000000..94cf2e3e8
--- /dev/null
+++ b/luci-app-kaiplus/luasrc/view/kaiplus/classic_status.htm
@@ -0,0 +1,99 @@
+<%+tasks/embed%>
+<% local installed = require("luci.model.kaiplus.classic").installed() %>
+
+
+
+
+
+
+ <%:Status%>
+ <%:Collecting data...%>
+ <%:Updates come from the iStore package feed and include CLASSIC KAI and its runtime engine.%>
+
+
+
+
+
+
diff --git a/luci-app-kaiplus/luasrc/view/kaiplus/kaiplus_status.htm b/luci-app-kaiplus/luasrc/view/kaiplus/kaiplus_status.htm
index ddb8a4228..37db650fb 100644
--- a/luci-app-kaiplus/luasrc/view/kaiplus/kaiplus_status.htm
+++ b/luci-app-kaiplus/luasrc/view/kaiplus/kaiplus_status.htm
@@ -1,5 +1,3 @@
-<%+tasks/embed%>
-
", template, re.S).group(1)
+ script = re.sub(r'<%=\s*installed and "true" or "false"\s*%>', "PAGE_INSTALLED", script)
+ script = re.sub(r'<%=url\("([^"]+)"\)%>', r"/cgi-bin/luci/\1", script)
+ script = re.sub(r"<%:([^%]+)%>", r"\1", script)
+ self.assertNotIn("<%", script)
+ harness = (APP_DIR / "tests/classic_kai_browser.cjs").read_text()
+ result = subprocess.run([node, "-e", "const source = " + json.dumps(script) + ";\n" + harness],
+ text=True, capture_output=True, timeout=15)
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
- def test_entry_opens_kai_luci_and_prompts_to_install_when_missing(self):
- controller = self.read("luasrc/controller/kaiplus.lua")
- status = self.read("luasrc/view/kaiplus/kaiplus_status.htm")
+ def test_package_updates_preserve_state_and_stop_on_failure(self):
+ source = (APP_DIR / "root/usr/libexec/kaiplus-classic").read_text()
+ with tempfile.TemporaryDirectory(prefix="classic-kai-test-") as directory:
+ root = Path(directory)
+ stub = '''#!/bin/sh
+case "${0##*/}" in
+ pidof) [ "$TEST_RUNNING" = 1 ]; exit $?;;
+ uci) printf '%s\\n' "$TEST_ENABLED"; exit 0;;
+esac
+printf '%s %s\\n' "${0##*/}" "$*" >> "$TEST_LOG"
+if [ "$TEST_FAIL" = "${0##*/}:$1" ]; then exit 1; fi
+'''
+ for name in ("is-opkg", "kai", "pidof", "uci"):
+ path = root / name
+ path.write_text(stub)
+ path.chmod(0o755)
+ script = source.replace("/bin/is-opkg", shlex.quote(str(root / "is-opkg")))
+ script = script.replace("/etc/init.d/kai", shlex.quote(str(root / "kai")))
+ path = root / "helper.sh"
+ path.write_text(script)
- self.assertNotIn('entry({"admin", "services", "kaiplus", "open_kai"}', controller)
- self.assertIn('url("admin/services/kai")', status)
- self.assertIn("st.kai_installed === true", status)
- self.assertIn("function openClassicKai()", status)
- self.assertIn("Classic KAI is not installed. Install it now?", status)
- self.assertIn("window.location.href = kaiLuciUrl", status)
+ def run(action, running="1", enabled="1", fail=""):
+ log = root / "commands.log"
+ log.write_text("")
+ env = {**os.environ, "PATH": str(root) + os.pathsep + os.environ["PATH"],
+ "TEST_LOG": str(log), "TEST_RUNNING": running,
+ "TEST_ENABLED": enabled, "TEST_FAIL": fail}
+ result = subprocess.run(["sh", str(path), action], env=env,
+ text=True, capture_output=True, timeout=10)
+ return result, log.read_text().splitlines()
- def test_missing_kai_starts_taskd_install_and_opens_log(self):
- makefile = self.read("Makefile")
- controller = self.read("luasrc/controller/kaiplus.lua")
- status = self.read("luasrc/view/kaiplus/kaiplus_status.htm")
-
- self.assertIn("+luci-lib-taskd", makefile)
- self.assertIn('<%+tasks/embed%>', status)
- self.assertIn('entry({"admin", "services", "kaiplus", "install_kai"}', controller)
- self.assertIn("require_post_csrf()", controller)
- self.assertIn("/bin/is-opkg install luci-app-kai", controller)
- self.assertNotIn("app-meta-kai", controller)
- self.assertIn("/etc/init.d/tasks task_add ", controller)
- self.assertIn("window.taskd.show_log", status)
- self.assertIn("token: kaiCsrfToken", status)
-
- def test_action_buttons_share_one_row(self):
- status = self.read("luasrc/view/kaiplus/kaiplus_status.htm")
-
- actions = status.index('')
- kaiplus = status.index('id="kaiplus-open"', actions)
- classic = status.index('id="kaiplus-open-kai"', actions)
- actions_end = status.index("
", classic)
- self.assertLess(kaiplus, classic)
- self.assertLess(classic, actions_end)
- self.assertIn("margin: 16px;", status)
+ result, commands = run("install")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(commands, ["is-opkg install kai"])
+ for running, enabled in (("1", "1"), ("0", "1"), ("1", "0"), ("0", "0")):
+ with self.subTest(running=running, enabled=enabled):
+ result, commands = run("update", running, enabled)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ operation = "restart" if running == enabled == "1" else "stop"
+ self.assertEqual(commands, ["is-opkg update", "is-opkg upgrade kai kai_session", "kai " + operation])
+ for failure, count in (("is-opkg:update", 1), ("is-opkg:upgrade", 2), ("kai:restart", 3)):
+ with self.subTest(failure=failure):
+ result, commands = run("update", fail=failure)
+ self.assertNotEqual(result.returncode, 0)
+ self.assertEqual(len(commands), count)
+ self.assertNotIn("update completed", result.stdout)
+ result, commands = run("unknown")
+ self.assertEqual(result.returncode, 2)
+ self.assertEqual(commands, [])
if __name__ == "__main__":
diff --git a/luci-app-open-box/Makefile b/luci-app-open-box/Makefile
index a3509f70a..3c83023b6 100644
--- a/luci-app-open-box/Makefile
+++ b/luci-app-open-box/Makefile
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-open-box
-PKG_VERSION:=0.1.366
-PKG_RELEASE:=48
+PKG_VERSION:=0.1.369
+PKG_RELEASE:=49
PKG_MAINTAINER:=liandu2024
PKG_LICENSE:=GPL-3.0-or-later
diff --git a/luci-app-openvpn-plus/Makefile b/luci-app-openvpn-plus/Makefile
index db90c47b7..1f03366f9 100755
--- a/luci-app-openvpn-plus/Makefile
+++ b/luci-app-openvpn-plus/Makefile
@@ -10,7 +10,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-openvpn-plus
PKG_VERSION:=1.0.0
-PKG_RELEASE:=6
+PKG_RELEASE:=7
PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=Manfred Jaider
diff --git a/luci-app-openvpn-plus/root/usr/libexec/luci-app-openvpn-plus b/luci-app-openvpn-plus/root/usr/libexec/luci-app-openvpn-plus
index 55bb34a3b..96492edbf 100755
--- a/luci-app-openvpn-plus/root/usr/libexec/luci-app-openvpn-plus
+++ b/luci-app-openvpn-plus/root/usr/libexec/luci-app-openvpn-plus
@@ -9,15 +9,50 @@
# luci-app-openvpn-plus : helper tools and background workers
# /usr/libexec/luci-app-openvpn-plus
-# Get mac address of lan interface
+# Get the live hardware device name behind the logical 'lan' interface
+getl3dev() {
+ local dump device
+
+ # Step 1: Fetch network interface status from ubus
+ dump=$(ubus call network.interface.lan status 2>/dev/null)
+ if [ -z "$dump" ]; then
+ # Fallback if ubus fails
+ echo "br-lan"
+ return 1
+ fi
+
+ # Step 2: Try to get l3_device, then fallback to standard device
+ device=$(echo "$dump" | jsonfilter -e '@.l3_device' 2>/dev/null)
+ [ -z "$device" ] && device=$(echo "$dump" | jsonfilter -e '@.device' 2>/dev/null)
+
+ # Step 3: Return the found device or the default fallback
+ if [ -n "$device" ]; then
+ echo "$device"
+ return 0
+ else
+ echo "br-lan"
+ return 1
+ fi
+}
+
+# Get mac address of lan interface using the dynamic device name
getmac() {
- # no arguments (no shift)
- if [ -f "/sys/class/net/br-lan/address" ]; then
- cat /sys/class/net/br-lan/address
+ local device mac
+
+ # Step 1: Get the dynamic device name (e.g. br-lan, eth0, lan0)
+ device=$(getl3dev)
+
+ # Step 2: Fetch the live MAC address of that specific device
+ mac=$(ubus call network.device status "{\"name\":\"${device}\"}" 2>/dev/null | jsonfilter -e '@.macaddr' 2>/dev/null)
+
+ # Step 3: Return the MAC or the default fallback address
+ if [ -n "$mac" ]; then
+ echo "$mac"
+ return 0
else
echo "00:00:00:00:00:00"
+ return 1
fi
- return 0
}
# Get the active LAN IP with mask
@@ -2052,6 +2087,7 @@ usage() {
# Main entry point
case "$1" in
+ getl3dev) getl3dev ;;
getmac) getmac ;;
lanip) lanip "$@" ;;
getnetstat) getnetstat "$@" ;;
diff --git a/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn.js b/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn.js
index 5760073fb..476f6e16c 100755
--- a/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn.js
+++ b/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn.js
@@ -219,6 +219,7 @@ const CFG = Object.freeze({
}),
LIBEXEC: Object.freeze({
luci_app_openvpn_plus: '/usr/libexec/luci-app-openvpn-plus',
+ getl3dev: 'getl3dev',
lanip: 'lanip',
bestcrypto: 'bestcrypto',
readfile: 'readfile',
@@ -4785,6 +4786,13 @@ const generate_jmp_nft_file = async function (proto, port, instance_id, jmpScrip
* Generate static rules file for Site-To-Site outbound and inbound forward
*/
const generate_s2s_nft_file = async function (deviceName, localSubnet, remoteSubnets, instance_id, s2sScriptPath) {
+
+ let lanDevice = 'br-lan';
+ const res = await luci_app_openvpn_plus([CFG.LIBEXEC.getl3dev]);
+ if (res && res !='' && !res.includes("ERROR:")) {
+ lanDevice = res;
+ }
+
let s2sNftContent = '#!/usr/sbin/nft -f\n';
s2sNftContent += '# Automatically generated by luci-app-openvpn-plus - OpenVPN Site-to-Site Firewall Rules\n';
@@ -4795,7 +4803,7 @@ const generate_s2s_nft_file = async function (deviceName, localSubnet, remoteSub
if (!remoteSubnet) {
continue;
}
- s2sNftContent += 'iifname "br-lan" ip daddr ' + remoteSubnet + ' accept comment "luci-app-openvpn-plus: Static Site-To-Site inbound forward for ' + instance_id + '";\n';
+ s2sNftContent += 'iifname "' + lanDevice + '" ip daddr ' + remoteSubnet + ' accept comment "luci-app-openvpn-plus: Static Site-To-Site inbound forward for ' + instance_id + '";\n';
}
s2sNftContent += ';\n';
diff --git a/luci-app-tailscale-community/root/etc/init.d/tailscale-settings b/luci-app-tailscale-community/root/etc/init.d/tailscale-settings
index af252d6da..85a92a136 100755
--- a/luci-app-tailscale-community/root/etc/init.d/tailscale-settings
+++ b/luci-app-tailscale-community/root/etc/init.d/tailscale-settings
@@ -172,6 +172,11 @@ apply_settings() {
config_load tailscale
+ if [ -x "$TAILSCALE_INIT" ] && ! tailscale_running; then
+ logger -t "$NAME" "tailscale is not running, skip applying settings"
+ return 0
+ fi
+
local accept_routes advertise_exit_node exit_node exit_node_allow_lan_access
local ssh shields_up runwebclient nosnat hostname
local enable_relay relay_server_port disable_fw_config
@@ -439,5 +444,6 @@ service_triggers() {
reload_service() {
mkdir -p "$STATE_DIR"
+ config_load tailscale
handle_service_state
}
diff --git a/luci-theme-argon/Makefile b/luci-theme-argon/Makefile
index 5b4d81d47..79f07b7f2 100644
--- a/luci-theme-argon/Makefile
+++ b/luci-theme-argon/Makefile
@@ -12,7 +12,7 @@ LUCI_DEPENDS:=+luci-base +USE_APK:wget-any +!USE_APK:wget +jsonfilter +ucode-mod
# Keep existing translation package names and the argon-config message domain.
LUCI_BASENAME:=argon-config
PKG_VERSION:=3.1.0
-PKG_RELEASE:=33
+PKG_RELEASE:=34
PKG_LICENSE:=Apache-2.0 MIT
# LuCI derives translations independently of the theme version. Keep release
diff --git a/luci-theme-argon/htdocs/luci-static/argon/css/cascade.css b/luci-theme-argon/htdocs/luci-static/argon/css/cascade.css
index 8c8fb034a..9d1102817 100644
--- a/luci-theme-argon/htdocs/luci-static/argon/css/cascade.css
+++ b/luci-theme-argon/htdocs/luci-static/argon/css/cascade.css
@@ -4129,6 +4129,23 @@ td > .ifacebadge,
.ifacebadge > img + img {
margin: 0 0.2rem 0 0;
}
+.ifacebadge[data-signal] {
+ min-width: min-content;
+ padding-inline: 0.5rem;
+ white-space: nowrap;
+}
+.ifacebadge[data-signal] > img {
+ flex: none;
+ margin: 0;
+}
+.ifacebadge[data-signal] > span {
+ flex-shrink: 0;
+ max-width: none;
+ direction: ltr;
+ unicode-bidi: isolate;
+ white-space: nowrap;
+ overflow-wrap: normal;
+}
.network-status-table {
display: flex;
flex-wrap: wrap;
@@ -6762,6 +6779,7 @@ pre.command-output {
[data-page="admin-status"] .argon-port-traffic > div,
[data-page="admin-status-overview"] .argon-port-traffic > div {
display: flex;
+ flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
@@ -6770,6 +6788,7 @@ pre.command-output {
[data-page="admin-status"] .argon-port-traffic dt,
[data-page="admin-status-overview"] .argon-port-traffic dt {
display: inline-flex;
+ flex: none;
align-items: center;
gap: 0.25rem;
color: var(--muted-text);
@@ -6791,11 +6810,14 @@ pre.command-output {
[data-page="admin-status"] .argon-port-traffic dd,
[data-page="admin-status-overview"] .argon-port-traffic dd {
margin: 0;
+ margin-inline-start: auto;
+ max-width: 100%;
color: var(--text-strong);
font-size: 0.75rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
- white-space: nowrap;
+ white-space: normal;
+ overflow-wrap: anywhere;
}
[data-page="admin-status-realtime"] .cbi-section > div:has(> svg),
[data-page="admin-status-realtime-load"] .cbi-section > div:has(> svg),
@@ -8397,8 +8419,37 @@ pre.command-output {
border-bottom: thin solid var(--border-subtle);
box-shadow: 0 -1px 0 0 var(--border-subtle);
}
-#wifi_assoclist_table > .tr > .td[data-title="RX Rate / TX Rate"] {
- width: 23rem;
+#wifi_assoclist_table .ifacebadge[data-ssid] {
+ display: flex;
+ width: 100%;
+ gap: var(--control-gap);
+ padding: var(--control-gap);
+}
+#wifi_assoclist_table .ifacebadge[data-ssid] > img {
+ flex: none;
+ margin: 0;
+}
+#wifi_assoclist_table .ifacebadge[data-ssid] > span {
+ flex: 1 1 0;
+ min-width: 0;
+ text-align: start;
+}
+#wifi_assoclist_table .ifacebadge[data-ssid] small {
+ display: block;
+ direction: ltr;
+ unicode-bidi: isolate;
+}
+@media screen and (min-width: 1153px) {
+ #wifi_assoclist_table > .tr > :is(.th, .td):first-child {
+ width: 22%;
+ min-width: 14rem;
+ }
+}
+@media screen and (max-width: 600px) {
+ #wifi_assoclist_table .td:has(> .ifacebadge[data-ssid]) {
+ flex-basis: 100%;
+ padding-inline: var(--control-gap);
+ }
}
#cbi-wireless,
#cbi-wireless *,
@@ -8664,6 +8715,11 @@ pre.command-output {
flex-wrap: wrap;
justify-content: center;
}
+ .table .td:has(.ifacebadge[data-signal]),
+ #cbi-wireless .td:has(.ifacebadge[data-signal]) {
+ min-width: min-content;
+ padding-inline: var(--control-gap);
+ }
.td select,
.td input[type="text"] {
width: 100%;
diff --git a/luci-theme-argon/htdocs/luci-static/resources/menu-argon.js b/luci-theme-argon/htdocs/luci-static/resources/menu-argon.js
index dd84579ba..07dfee30e 100644
--- a/luci-theme-argon/htdocs/luci-static/resources/menu-argon.js
+++ b/luci-theme-argon/htdocs/luci-static/resources/menu-argon.js
@@ -201,6 +201,7 @@ return baseclass.extend({
* Load menu data and trigger rendering
*/
__init__: function () {
+ this.bindSidebarToggle();
this.patchDynamicListFocus();
ui.menu.load().then(L.bind(this.render, this));
},
@@ -222,6 +223,25 @@ return baseclass.extend({
prototype.setFocus = guardedFocus;
},
+ bindSidebarToggle: function () {
+ var sidebarToggle = document.querySelector('.showSide');
+ var darkMask = document.querySelector('.darkMask');
+ // Navigation is synchronous: keep its controls enabled and focused.
+ var toggle = L.bind(this.handleSidebarToggle, this);
+
+ if (sidebarToggle)
+ sidebarToggle.addEventListener('click', toggle);
+ if (darkMask)
+ darkMask.addEventListener('click', toggle);
+
+ document.addEventListener('keydown', function(ev) {
+ if (ev.key === 'Escape' && sidebarToggle && sidebarToggle.classList.contains('active')) {
+ sidebarToggle.click();
+ sidebarToggle.focus();
+ }
+ });
+ },
+
/**
* Main render function for the menu system
* @param {Object} tree - Menu tree structure from LuCI
@@ -235,7 +255,8 @@ return baseclass.extend({
// Render tab menu if we're deep enough in the navigation hierarchy
if (L.env.dispatchpath.length >= 3) {
for (var i = 0; i < 3 && node; i++) {
- node = node.children[L.env.dispatchpath[i]];
+ // A cached menu can predate the current page's route.
+ node = node.children ? node.children[L.env.dispatchpath[i]] : null;
url = url + (url ? '/' : '') + L.env.dispatchpath[i];
}
@@ -243,23 +264,6 @@ return baseclass.extend({
this.renderTabMenu(node, url);
}
}
-
- // Attach event listeners for sidebar toggle functionality
- var sidebarToggle = document.querySelector('.showSide');
- var darkMask = document.querySelector('.darkMask');
-
- if (sidebarToggle) {
- sidebarToggle.addEventListener('click', ui.createHandlerFn(this, 'handleSidebarToggle'));
- }
- if (darkMask) {
- darkMask.addEventListener('click', ui.createHandlerFn(this, 'handleSidebarToggle'));
- }
- document.addEventListener('keydown', function(ev) {
- if (ev.key === 'Escape' && sidebarToggle && sidebarToggle.classList.contains('active')) {
- sidebarToggle.click();
- sidebarToggle.focus();
- }
- });
},
/**
diff --git a/luci-theme-argon/less/components/network.less b/luci-theme-argon/less/components/network.less
index 6f49cecc9..d574ce931 100644
--- a/luci-theme-argon/less/components/network.less
+++ b/luci-theme-argon/less/components/network.less
@@ -33,6 +33,28 @@ td>.ifacebadge,
margin: 0 .2rem 0 0;
}
+// Signal/noise readings are measurements, not descriptive interface names.
+// Keep the value intact while allowing the icon to stack on narrow tables.
+.ifacebadge[data-signal] {
+ min-width: min-content;
+ padding-inline: .5rem;
+ white-space: nowrap;
+
+ > img {
+ flex: none;
+ margin: 0;
+ }
+
+ > span {
+ flex-shrink: 0;
+ max-width: none;
+ direction: ltr;
+ unicode-bidi: isolate;
+ white-space: nowrap;
+ overflow-wrap: normal;
+ }
+}
+
.network-status-table {
display: flex;
flex-wrap: wrap;
diff --git a/luci-theme-argon/less/pages/overview.less b/luci-theme-argon/less/pages/overview.less
index d73ba6e25..17b362e71 100644
--- a/luci-theme-argon/less/pages/overview.less
+++ b/luci-theme-argon/less/pages/overview.less
@@ -127,6 +127,7 @@
> div {
display: flex;
+ flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: .5rem;
@@ -135,6 +136,7 @@
dt {
display: inline-flex;
+ flex: none;
align-items: center;
gap: .25rem;
color: var(--muted-text);
@@ -156,11 +158,14 @@
dd {
margin: 0;
+ margin-inline-start: auto;
+ max-width: 100%;
color: var(--text-strong);
font-size: .75rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
- white-space: nowrap;
+ white-space: normal;
+ overflow-wrap: anywhere;
}
}
}
diff --git a/luci-theme-argon/less/pages/wireless.less b/luci-theme-argon/less/pages/wireless.less
index 1acef8421..feac02b58 100644
--- a/luci-theme-argon/less/pages/wireless.less
+++ b/luci-theme-argon/less/pages/wireless.less
@@ -29,9 +29,43 @@
}
#wifi_assoclist_table {
- >.tr {
- >.td[data-title="RX Rate / TX Rate"] {
- width: 23rem;
+ // Keep the network identity readable without reserving the rate column
+ // by its translated heading. Let the remaining columns share the space.
+ .ifacebadge[data-ssid] {
+ display: flex;
+ width: 100%;
+ gap: var(--control-gap);
+ padding: var(--control-gap);
+
+ > img {
+ flex: none;
+ margin: 0;
+ }
+
+ > span {
+ flex: 1 1 0;
+ min-width: 0;
+ text-align: start;
+ }
+
+ small {
+ display: block;
+ direction: ltr;
+ unicode-bidi: isolate;
+ }
+ }
+
+ @media screen and (min-width: 1153px) {
+ > .tr > :is(.th, .td):first-child {
+ width: 22%;
+ min-width: 14rem;
+ }
+ }
+
+ @media screen and (max-width: 600px) {
+ .td:has(> .ifacebadge[data-ssid]) {
+ flex-basis: 100%;
+ padding-inline: var(--control-gap);
}
}
}
diff --git a/luci-theme-argon/less/responsive.less b/luci-theme-argon/less/responsive.less
index b0e748b79..a276a89b9 100644
--- a/luci-theme-argon/less/responsive.less
+++ b/luci-theme-argon/less/responsive.less
@@ -259,6 +259,13 @@
}
+ // Let a complete signal/noise reading move to its own line when needed.
+ .table .td:has(.ifacebadge[data-signal]),
+ #cbi-wireless .td:has(.ifacebadge[data-signal]) {
+ min-width: min-content;
+ padding-inline: var(--control-gap);
+ }
+
.td select,
.td input[type="text"] {
width: 100%;
diff --git a/luci-theme-argon/tests/menu-navigation.test.js b/luci-theme-argon/tests/menu-navigation.test.js
new file mode 100644
index 000000000..4621968d9
--- /dev/null
+++ b/luci-theme-argon/tests/menu-navigation.test.js
@@ -0,0 +1,109 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const test = require('node:test');
+
+const source = fs.readFileSync(path.join(__dirname, '../htdocs/luci-static/resources/menu-argon.js'), 'utf8');
+
+function fixture() {
+ function element() {
+ const classes = new Set();
+ return {
+ attributes: {}, listeners: {}, disabled: false, focused: false,
+ classList: {
+ add: name => classes.add(name),
+ remove: name => classes.delete(name),
+ contains: name => classes.has(name)
+ },
+ setAttribute(name, value) { this.attributes[name] = value; },
+ addEventListener(type, handler) { (this.listeners[type] ||= []).push(handler); },
+ click() { for (const handler of this.listeners.click || []) handler({ currentTarget: this }); },
+ focus() { this.focused = true; }
+ };
+ }
+
+ const button = element(), sidebar = element(), mask = element(), content = element();
+ const nodes = { '.showSide': button, '#mainmenu': sidebar, '.darkMask': mask, '.main-right': content };
+ const document = {
+ listeners: {},
+ querySelector: selector => nodes[selector] || null,
+ addEventListener(type, handler) { (this.listeners[type] ||= []).push(handler); }
+ };
+ let finishLoad;
+ const loaded = new Promise(resolve => { finishLoad = resolve; });
+ const menu = new Function('baseclass', 'ui', 'L', 'document', source)(
+ { extend: object => object },
+ {
+ menu: { load: () => loaded },
+ createHandlerFn() { throw new Error('Synchronous navigation must not disable its controls'); }
+ },
+ {
+ bind: (fn, context) => fn.bind(context),
+ env: { dispatchpath: ['admin', 'status', 'overview'] }
+ },
+ document
+ );
+ let mainRenders = 0, tabRenders = 0;
+ menu.renderModeMenu = () => { mainRenders++; };
+ menu.renderTabMenu = () => { tabRenders++; };
+ return { menu, button, sidebar, mask, content, document, loaded, finishLoad,
+ mainRenders: () => mainRenders, tabRenders: () => tabRenders };
+}
+
+function assertOpen(f, open) {
+ for (const node of [f.button, f.sidebar, f.mask, f.content])
+ assert.equal(node.classList.contains('active'), open);
+ assert.equal(f.button.attributes['aria-expanded'], String(open));
+ assert.equal(f.button.disabled, false);
+}
+
+test('the first sidebar tap is preserved while menu data is still loading', async () => {
+ const f = fixture();
+ f.menu.__init__();
+ f.button.click();
+ assertOpen(f, true);
+ assert.equal(f.mainRenders(), 0);
+
+ f.finishLoad({ children: { admin: { children: { status: {} } } } });
+ await f.loaded;
+ assert.equal(f.mainRenders(), 1);
+ assertOpen(f, true);
+ f.mask.click();
+ assertOpen(f, false);
+});
+
+test('cached menus missing the current route do not abort navigation rendering', () => {
+ for (const tree of [
+ { children: { admin: { children: { status: {} } } } },
+ { children: { admin: { children: {} } } },
+ {}
+ ]) {
+ const f = fixture();
+ f.menu.__init__();
+ assert.doesNotThrow(() => f.menu.render(tree));
+ assert.equal(f.mainRenders(), 1);
+ assert.equal(f.tabRenders(), 0);
+ f.button.click();
+ assertOpen(f, true);
+ }
+});
+
+test('rendering tabs again does not duplicate sidebar events, and Escape restores focus', async () => {
+ const f = fixture();
+ const tree = { children: { admin: { children: { status: { children: { overview: {} } } } } } };
+ f.menu.__init__();
+ f.finishLoad(tree);
+ await f.loaded;
+ f.menu.render(tree);
+ assert.equal(f.tabRenders(), 2);
+ assert.equal(f.button.listeners.click.length, 1);
+ assert.equal(f.mask.listeners.click.length, 1);
+ f.button.click();
+ assertOpen(f, true);
+ for (const handler of f.document.listeners.keydown)
+ handler({ key: 'Escape' });
+ assertOpen(f, false);
+ assert.equal(f.button.focused, true);
+});
diff --git a/minigate/Makefile b/minigate/Makefile
index f999657a1..9488a9b75 100644
--- a/minigate/Makefile
+++ b/minigate/Makefile
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=minigate
-PKG_VERSION:=2026.10.25
-PKG_RELEASE:=6
+PKG_VERSION:=2026.10.26
+PKG_RELEASE:=7
PKG_LICENSE:=MIT
PKG_MAINTAINER:=MiniGate
diff --git a/minigate/README.md b/minigate/README.md
index d8d9ab669..b82bbc507 100644
--- a/minigate/README.md
+++ b/minigate/README.md
@@ -2,9 +2,9 @@
一个类似 Lucky 的轻量级 OpenWrt 应用,提供四大核心功能:DDNS、SSL 证书、反向代理、登录防护。
-当前版本:**v2026.10.25**。项目名称、仓库名及 OpenWrt 内部包名均为 **`minigate`**。
+当前版本:**v2026.10.26**。项目名称、仓库名及 OpenWrt 内部包名均为 **`minigate`**。
-> OpenWrt 用户请从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载 `minigate_2026.10.25_all.ipk`。不要把 GitHub 自动生成的 `Source code (zip)` / `Source code (tar.gz)` 当安装包上传到 LuCI。
+> OpenWrt 用户请从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载 `minigate_2026.10.26_all.ipk`。不要把 GitHub 自动生成的 `Source code (zip)` / `Source code (tar.gz)` 当安装包上传到 LuCI。
## 项目定位
@@ -74,8 +74,8 @@ minigate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户
- 后台 watchdog 每 5 分钟自检 nft set,防 fw4 reload 后规则丢失
### 👁 访客追踪
-- 总览页面实时显示访客 IP、归属地、在线状态
-- 5 分钟内有访问记为「在线」(绿点),否则「离线」(灰点)
+- 总览页面显示来源 IP、归属地、最近请求结果、HTTP 状态及转发/拦截次数;被拒绝的扫描也保留在记录中
+- 「近5分钟有请求」仅表示近期出现请求,不代表访客在线;已转发也不代表已登录。旧日志缺少上游响应信息时,只能标记为已响应、请求失败或结果未知,不推断为准入放行
- IP 归属地后端查询(ip9.com.cn → ip-api.com → pconline 多源回退)
### 软件更新
@@ -93,20 +93,20 @@ minigate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户
从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载:
-`minigate_2026.10.25_all.ipk`
+`minigate_2026.10.26_all.ipk`
**通过 LuCI 界面安装:**
1. 打开 LuCI → **系统** → **软件包**
2. 点击 **上传软件包**
-3. 选择 `minigate_2026.10.25_all.ipk`,点击安装
+3. 选择 `minigate_2026.10.26_all.ipk`,点击安装
**通过命令行安装:**
```bash
cd /tmp
-wget -O minigate_2026.10.25_all.ipk https://github.com/tpxcer/minigate/releases/download/v2026.10.25/minigate_2026.10.25_all.ipk
+wget -O minigate_2026.10.26_all.ipk https://github.com/tpxcer/minigate/releases/download/v2026.10.26/minigate_2026.10.26_all.ipk
opkg update
-opkg install /tmp/minigate_2026.10.25_all.ipk
+opkg install /tmp/minigate_2026.10.26_all.ipk
rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null
/etc/init.d/uhttpd restart
/etc/init.d/minigate restart
@@ -125,7 +125,7 @@ Release 用的 `.ipk` 请用仓库脚本生成,避免生成 Debian 风格 ar
脚本会输出:
```text
-dist/minigate_2026.10.25_all.ipk
+dist/minigate_2026.10.26_all.ipk
```
该文件外层是 OpenWrt/ImmortalWrt 24.10 兼容的 `tar.gz`,内部成员顺序为 `debian-binary`、`control.tar.gz`、`data.tar.gz`。
@@ -153,7 +153,7 @@ wget -qO- https://raw.githubusercontent.com/tpxcer/minigate/main/scripts/install
需要指定并核对具体版本时,执行:
```bash
-d="$(mktemp -d /tmp/minigate.XXXXXX)" && cd "$d" && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.25/minigate-v2026.10.25-src.tar.gz && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.25/SHA256SUMS && grep 'minigate-v2026.10.25-src.tar.gz$' SHA256SUMS | sha256sum -c - && tar xzf minigate-v2026.10.25-src.tar.gz && sh install.sh && /etc/init.d/uhttpd restart && /etc/init.d/minigate restart
+d="$(mktemp -d /tmp/minigate.XXXXXX)" && cd "$d" && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.26/minigate-v2026.10.26-src.tar.gz && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.26/SHA256SUMS && grep 'minigate-v2026.10.26-src.tar.gz$' SHA256SUMS | sha256sum -c - && tar xzf minigate-v2026.10.26-src.tar.gz && sh install.sh && /etc/init.d/uhttpd restart && /etc/init.d/minigate restart
```
其中 `minigate.XXXXXX` 是临时目录模板,系统会自动把六个 `X` 替换为随机字符,避免与已有目录重名。该命令会校验源码包的 SHA-256、保留现有 minigate 配置并重启 LuCI 与 minigate 服务。
@@ -168,14 +168,14 @@ ssh root@192.168.1.1
# 2. 在路由器上直接下载 Release 源码包
cd /tmp
-wget -O minigate-src.tar.gz https://github.com/tpxcer/minigate/releases/download/v2026.10.25/minigate-v2026.10.25-src.tar.gz
-wget -O minigate-SHA256SUMS https://github.com/tpxcer/minigate/releases/download/v2026.10.25/SHA256SUMS
-awk '$2 == "minigate-v2026.10.25-src.tar.gz" {print $1 " minigate-src.tar.gz"}' minigate-SHA256SUMS | sha256sum -c - || exit 1
+wget -O minigate-src.tar.gz https://github.com/tpxcer/minigate/releases/download/v2026.10.26/minigate-v2026.10.26-src.tar.gz
+wget -O minigate-SHA256SUMS https://github.com/tpxcer/minigate/releases/download/v2026.10.26/SHA256SUMS
+awk '$2 == "minigate-v2026.10.26-src.tar.gz" {print $1 " minigate-src.tar.gz"}' minigate-SHA256SUMS | sha256sum -c - || exit 1
# 3. 解压到独立目录并安装
-mkdir -p /tmp/minigate-v2026.10.25
-tar xzf minigate-src.tar.gz -C /tmp/minigate-v2026.10.25
-cd /tmp/minigate-v2026.10.25
+mkdir -p /tmp/minigate-v2026.10.26
+tar xzf minigate-src.tar.gz -C /tmp/minigate-v2026.10.26
+cd /tmp/minigate-v2026.10.26
sh install.sh
# 4. 启动服务
@@ -225,7 +225,7 @@ SSH 登录后执行上方「方法 2」的一条命令,保留原有配置并
### IPK 升级
```bash
-opkg install --force-reinstall /tmp/minigate_2026.10.25_all.ipk
+opkg install --force-reinstall /tmp/minigate_2026.10.26_all.ipk
rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null
/etc/init.d/minigate restart
```
@@ -408,6 +408,11 @@ minigate/
## 更新日志
+### v2026.10.26
+- 访问记录区分「已拦截、已转发、已响应、请求失败、结果未知」,显示最近请求的 HTTP 状态与拦截/转发/其他次数
+- 「在线/离线」改为近期请求说明,避免把被拒绝的外地扫描误认为访问成功;保留拦截记录便于核查
+- 新日志记录上游响应,后端 401/403 不误判为地区拦截;兼容旧日志,缺少信息时不推断转发或登录成功
+
### v2026.10.25
- 新增可配置的「公网访问范围」:选择一个或多个省级地区,仅允许匹配的公网 IP 访问所有反代站点,其他及未知来源在首次转发前直接断开
- 同时检查 IPv4、IPv6,覆盖 HTTP 跳转与 WebSocket;保留局域网访问,不修改防火墙或限制 SSH、RustDesk
diff --git a/minigate/luasrc/controller/minigate.lua b/minigate/luasrc/controller/minigate.lua
index 834078eba..8f281744e 100644
--- a/minigate/luasrc/controller/minigate.lua
+++ b/minigate/luasrc/controller/minigate.lua
@@ -373,24 +373,38 @@ function action_proxy_access()
end
-- 读取最近 500 条日志,按 IP 聚合
local raw = sys.exec("cat /var/log/minigate-access.log.1 /var/log/minigate-access.log 2>/dev/null | tail -n 500") or ""
- local visitors = {} -- ip -> {last_time, domain, count, first_time}
+ local visitors = {}
local order = {} -- 保持顺序
for line in raw:gmatch("[^\n]+") do
local time = line:match('"time":"([^"]*)"')
local domain = line:match('"domain":"([^"]*)"')
local client = line:match('"client":"([^"]*)"')
+ local status = tonumber(line:match('"status"%s*:%s*(%d+)'))
+ local upstream = line:match('"upstream_status":"([^"]*)"')
if time and client and client ~= "" then
- if not visitors[client] then
- visitors[client] = { last_time = time, first_time = time, domain = domain or "", count = 1 }
- order[#order + 1] = client
- else
- visitors[client].last_time = time
- visitors[client].count = visitors[client].count + 1
- if domain and domain ~= "" then
- visitors[client].domain = domain
- end
+ local outcome = "unknown"
+ if status == 444 or (status == 400 and domain == "_") then
+ outcome = "blocked"
+ elseif status and (status >= 500 or status == 499) then
+ outcome = "error"
+ elseif status and upstream and upstream ~= "" and upstream ~= "-" then
+ outcome = "forwarded"
+ elseif status and status >= 100 and status < 400 then
+ outcome = "responded"
+ elseif status and status >= 400 then
+ outcome = "error"
end
+ if not visitors[client] then
+ visitors[client] = { count = 0, forwarded_count = 0, blocked_count = 0, other_count = 0 }
+ order[#order + 1] = client
+ end
+ local v = visitors[client]
+ v.last_time, v.domain, v.status, v.outcome = time, domain or "", status, outcome
+ v.count = v.count + 1
+ local counter = outcome == "forwarded" and "forwarded_count" or
+ (outcome == "blocked" and "blocked_count" or "other_count")
+ v[counter] = v[counter] + 1
end
end
@@ -405,19 +419,24 @@ function action_proxy_access()
for i = 1, math.min(#order, limit) do
local ip = order[i]
local v = visitors[ip]
- -- 解析 ISO 时间判断是否在线(5分钟内)
- local online = false
+ -- 仅表示最近有请求,不能据此推断访客在线或已通过准入。
+ local recent = false
local y,mo,da,h,mi,se = v.last_time:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)")
if y then
local ts = os.time({year=tonumber(y),month=tonumber(mo),day=tonumber(da),hour=tonumber(h),min=tonumber(mi),sec=tonumber(se)})
- online = (now - ts) < 300
+ recent = now >= ts and (now - ts) < 300
end
result[#result + 1] = {
ip = ip,
last_time = v.last_time,
domain = v.domain,
count = v.count,
- online = online
+ recent = recent,
+ outcome = v.outcome,
+ status = v.status,
+ forwarded_count = v.forwarded_count,
+ blocked_count = v.blocked_count,
+ other_count = v.other_count
}
end
diff --git a/minigate/luasrc/model/cbi/minigate/general.lua b/minigate/luasrc/model/cbi/minigate/general.lua
index a87c2b46a..c7fce427e 100644
--- a/minigate/luasrc/model/cbi/minigate/general.lua
+++ b/minigate/luasrc/model/cbi/minigate/general.lua
@@ -57,8 +57,8 @@ o.cfgvalue = function()
.mg-table tr:nth-child(even) td{background:var(--mg-surface-alt)}
.mg-table tr:hover td{background:var(--mg-surface-hover)}
.mg-status{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
-.mg-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:#777}
-.mg-dot.on{background:var(--mg-success);box-shadow:0 0 0 3px rgba(47,158,68,.14)}
+.mg-request-result{display:flex;flex-direction:column;gap:4px}
+.mg-request-detail{font-size:11px;color:var(--mg-text-muted);line-height:1.5;white-space:normal}
.mg-ip{display:inline-block;border-radius:6px;background:var(--mg-code-bg);color:var(--mg-text);padding:2px 7px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px}
.mg-empty{padding:18px 16px;color:var(--mg-text-muted);font-size:12px}
.mg-update-body{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:15px 16px;flex-wrap:wrap}
@@ -105,9 +105,9 @@ o.cfgvalue = function()
.mg-table td:nth-child(1){order:2;justify-content:flex-end}
.mg-table td:nth-child(n+3){order:3;grid-column:1/-1}
.mg-table td:nth-child(3):before{content:"归属地"}
-.mg-table td:nth-child(4):before{content:"最后访问"}
+.mg-table td:nth-child(4):before{content:"最后请求"}
.mg-table td:nth-child(5):before{content:"域名"}
-.mg-table td:nth-child(6):before{content:"次数"}
+.mg-table td:nth-child(6):before{content:"请求次数"}
.mg-table td:before{flex:0 0 auto;color:var(--mg-text-muted)}
}
@@ -173,6 +173,7 @@ o.cfgvalue = function()
50
条
+包含被拦截的请求;结果对应最后一次请求,次数为最近日志内按 IP 汇总。已转发不代表已登录。
@@ -286,20 +287,24 @@ function loadVisitors(force){
}
ct.textContent='('+visitors.length+' 个IP)';
var h='';
- h+='状态 IP 地址 归属地 最后访问 域名 次数 ';
+ h+='最近结果 IP 地址 归属地 最后请求 域名 请求次数 ';
for(var i=0;i'
- :' ';
- var stxt=v.online?'在线 ':'离线 ';
+ var outcomes={blocked:['已拦截','danger'],forwarded:['已转发','success'],responded:['已响应','info'],error:['请求失败','warning']};
+ var outcome=outcomes[v.outcome]||['结果未知','muted'];
+ var status=typeof v.status==='number'?('HTTP '+v.status):'无状态码';
+ var activity=typeof v.recent==='boolean'?(v.recent?'近5分钟有请求':'5分钟内无新请求'):'请求时间未知';
h+='';
- h+=''+dot+stxt+' ';
+ h+=''+outcome[0]+' '+escHtml(status)+'
';
h+=''+escHtml(v.ip)+' ';
h+=''+mgGeoHtml(_geoCache[v.ip])+' ';
- h+=''+escHtml(fmtT(v.last_time))+' ';
+ h+=''+escHtml(fmtT(v.last_time))+' '+activity+'
';
h+=''+escHtml(v.domain)+' ';
- h+=''+escHtml(v.count)+' ';
+ h+='总计 '+escHtml(String(v.count))+' ';
+ if(typeof v.blocked_count==='number'&&typeof v.forwarded_count==='number'&&typeof v.other_count==='number'){
+ h+='拦截 '+escHtml(String(v.blocked_count))+' · 转发 '+escHtml(String(v.forwarded_count))+' · 其他 '+escHtml(String(v.other_count))+' ';
+ }
+ h+='
';
h+=' ';
}
h+='
';
diff --git a/minigate/releases/v2026.10.26.md b/minigate/releases/v2026.10.26.md
new file mode 100644
index 000000000..33f012e66
--- /dev/null
+++ b/minigate/releases/v2026.10.26.md
@@ -0,0 +1,24 @@
+## 访问记录结果区分
+
+修复仅允许四川访问时,其他地区的被拒请求仍显示为“在线”、容易被误认为访问成功的问题。
+
+- 访问记录显示最近一次请求结果:**已拦截、已转发、已响应、请求失败、结果未知**,同时显示 HTTP 状态码。
+- 按 IP 汇总最近 500 条日志中的总次数及拦截、转发、其他次数,保留被拦截的请求供核查;每行结果对应该 IP 最后一次请求。
+- 用“近5分钟有请求”替代“在线/离线”;近期出现请求不代表通过准入,已转发不代表已登录。
+- 新日志记录上游响应信息,后端 401/403 显示为已转发,不误判为地区拦截;5xx、499 显示请求失败并计入其他。
+- 旧日志继续可读,缺少上游信息的成功响应只显示已响应,信息不足显示结果未知;不会猜测已转发。
+
+## 升级说明
+
+现有地区访问范围和局域网访问策略保持不变。安装新版后生效,新日志才具备上游响应信息;已有日志不会补写。被拒请求仍会出现在记录中,并显示拦截结果,不表示地区限制失效。
+
+## 验证
+
+104 项本地测试全部通过,无跳过,覆盖真实 Lua 日志聚合与真实 Nginx 转发/拦截日志、HTTP 跳转和 WebSocket;Shell/Lua 语法、IPK 内容及桌面/手机深浅色测试页面检查通过。本次不自动更新路由器。
+
+## 安装包
+
+- opkg 安装包:`minigate_2026.10.26_all.ipk`。
+- 源码安装包:`minigate-v2026.10.26-src.tar.gz`。
+- 兼容旧更新器:`luci-app-minigate-v2026.10.26-src.tar.gz`。
+- 使用随附 `SHA256SUMS` 校验文件完整性。
diff --git a/minigate/root/usr/lib/minigate/proxy.sh b/minigate/root/usr/lib/minigate/proxy.sh
index 20224b6b6..ca8781cce 100644
--- a/minigate/root/usr/lib/minigate/proxy.sh
+++ b/minigate/root/usr/lib/minigate/proxy.sh
@@ -69,6 +69,7 @@ EOF
'"method":"$request_method",'
'"uri":"$request_uri",'
'"status":$status,'
+ '"upstream_status":"$upstream_status",'
'"size":$body_bytes_sent,'
'"referer":"$http_referer",'
'"ua":"$http_user_agent"}';
diff --git a/minigate/root/usr/lib/minigate/update.sh b/minigate/root/usr/lib/minigate/update.sh
index ad80e5c29..edfddf589 100755
--- a/minigate/root/usr/lib/minigate/update.sh
+++ b/minigate/root/usr/lib/minigate/update.sh
@@ -3,7 +3,7 @@
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH
-CURRENT_VERSION="2026.10.25"
+CURRENT_VERSION="2026.10.26"
REPOSITORY="tpxcer/minigate"
API_URL="https://api.github.com/repos/${REPOSITORY}/releases/latest"
DOWNLOAD_ROOT="https://github.com/${REPOSITORY}/releases/download"
diff --git a/minigate/scripts/build-ipk.sh b/minigate/scripts/build-ipk.sh
index 96b337e62..3a7f1c9bf 100755
--- a/minigate/scripts/build-ipk.sh
+++ b/minigate/scripts/build-ipk.sh
@@ -5,7 +5,7 @@ ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
OUT_DIR="${1:-$ROOT_DIR/dist}"
PKG_NAME="minigate"
-PKG_VERSION="2026.10.25"
+PKG_VERSION="2026.10.26"
PKG_ARCH="all"
PKG_FILE="${PKG_NAME}_${PKG_VERSION}_${PKG_ARCH}.ipk"
DEPS="libc, luci-base, luci-compat, nginx-ssl, nginx-mod-stream, openssl-util, wget, curl, jsonfilter, coreutils-stat, nftables"
diff --git a/minigate/tests/access-log.test.cjs b/minigate/tests/access-log.test.cjs
new file mode 100644
index 000000000..cdbdc367b
--- /dev/null
+++ b/minigate/tests/access-log.test.cjs
@@ -0,0 +1,64 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('node:path');
+const { spawnSync } = require('node:child_process');
+
+const lua = process.env.MINIGATE_TEST_LUA;
+const controller = path.resolve(__dirname, '../luasrc/controller/minigate.lua');
+
+test('visitor aggregation distinguishes blocked, upstream, legacy and failed requests', { skip: !lua }, () => {
+ const input = [
+ "local raw, response, limit = '', nil, '50'",
+ "package.preload['luci.sys'] = function() return {exec=function() return raw end} end",
+ "luci = {http={formvalue=function() return limit end, prepare_content=function() end, write_json=function(v) response=v end}}",
+ 'dofile(' + JSON.stringify(controller) + ')',
+ "local real_time = os.time",
+ "os.time = function(v) return v and real_time(v) or real_time({year=2026,month=10,day=26,hour=12,min=0,sec=0}) end",
+ "local function row(ip, status, upstream, domain, time)",
+ "raw = raw .. '{\"time\":\"' .. (time or '2026-10-26T11:59:00+08:00') .. '\",\"client\":\"' .. ip .. '\",\"domain\":\"' .. (domain or 'app.example.com') .. '\"' ..",
+ "(status and ',\"status\":' .. status or '') .. (upstream and ',\"upstream_status\":\"' .. upstream .. '\"' or '') .. '}\\n'",
+ "end",
+ "row('mixed', 200, '200')",
+ "row('mixed', 444, '-')",
+ "row('mixed', 308, '-')",
+ "row('blocked', 444, '-')",
+ "row('default', 400, '-', '_')",
+ "row('upstream403', 403, '403')",
+ "row('upstream401', 401, '401')",
+ "row('legacy200', 200)",
+ "row('legacy403', 403)",
+ "row('failure', 502, '502')",
+ "row('closed', 499, '-')",
+ "row('missing', 200, '200')",
+ "row('missing', nil, '200', '')",
+ "row('old', 200, '200', nil, '2026-10-26T11:54:59+08:00')",
+ "row('future', 200, '200', nil, '2026-10-26T12:01:00+08:00')",
+ "raw = raw .. 'invalid line\\n'",
+ "luci.controller.minigate.action_proxy_access()",
+ "local by_ip = {}",
+ "for _, v in ipairs(response.visitors) do",
+ "by_ip[v.ip] = v",
+ "assert(v.count == v.blocked_count + v.forwarded_count + v.other_count)",
+ "assert(v.online == nil)",
+ "end",
+ "local v = by_ip.mixed",
+ "assert(v.count == 3 and v.blocked_count == 1 and v.forwarded_count == 1 and v.other_count == 1)",
+ "assert(v.outcome == 'responded' and v.status == 308)",
+ "assert(by_ip.blocked.outcome == 'blocked' and by_ip.blocked.recent)",
+ "assert(by_ip.default.outcome == 'blocked')",
+ "assert(by_ip.upstream403.outcome == 'forwarded' and by_ip.upstream403.status == 403)",
+ "assert(by_ip.upstream401.outcome == 'forwarded')",
+ "assert(by_ip.legacy200.outcome == 'responded')",
+ "assert(by_ip.legacy403.outcome == 'error')",
+ "assert(by_ip.failure.outcome == 'error' and by_ip.closed.outcome == 'error')",
+ "assert(by_ip.missing.outcome == 'unknown' and by_ip.missing.status == nil and by_ip.missing.domain == '')",
+ "assert(not by_ip.old.recent and not by_ip.future.recent)",
+ "limit = 'invalid'",
+ "luci.controller.minigate.action_proxy_access()",
+ "assert(#response.visitors == 5)",
+ "print('visitor aggregation passed')"
+ ].join('\n');
+ const result = spawnSync(lua, ['-'], { encoding: 'utf8', input });
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ assert.match(result.stdout, /aggregation passed/);
+});
diff --git a/minigate/tests/access-nginx.test.cjs b/minigate/tests/access-nginx.test.cjs
index b0a93a444..2582360d9 100644
--- a/minigate/tests/access-nginx.test.cjs
+++ b/minigate/tests/access-nginx.test.cjs
@@ -78,6 +78,13 @@ test('real nginx enforces source policy before upstream, redirect and websocket'
assert.equal(fixture.reached(), reached, '拒绝的请求不得到达后端');
assert.match(await request(fixture.redirectPath, '14.196.0.1'), /308 Permanent Redirect/);
assert.match(await request(fixture.socketPath, '14.196.0.1', { websocket: true }), /101 Switching Protocols/);
+ const rows = fs.readFileSync(path.join(fixture.dir, 'access.log'), 'utf8').trim().split('\n').map(JSON.parse);
+ assert.ok(rows.some(row => row.status === 200 && row.upstream_status === '200'));
+ assert.ok(rows.some(row => row.status === 101 && row.upstream_status === '101'));
+ assert.ok(rows.some(row => row.status === 308 && !row.upstream_status));
+ const blocked = rows.filter(row => row.status === 444);
+ assert.equal(blocked.length, 18);
+ assert.ok(blocked.every(row => !row.upstream_status), '被拒绝请求没有上游响应');
});
test('real nginx permits all sources when disabled and fails closed if selected data is missing', { skip: !nginx }, async t => {
diff --git a/minigate/tests/ui.test.cjs b/minigate/tests/ui.test.cjs
index be6098720..aaba9e7b5 100644
--- a/minigate/tests/ui.test.cjs
+++ b/minigate/tests/ui.test.cjs
@@ -102,6 +102,35 @@ test('overview polling pauses in a hidden tab and refreshes when visible again',
assert.deepEqual(page.requests.map(item => item.url).sort(), ['au', 'su']);
});
+test('overview distinguishes request results and never labels blocked requests as online', () => {
+ const page = fakePage(overviewScript);
+ const visitors = ['blocked', 'forwarded', 'responded', 'error', 'unknown'].map((outcome, i) => ({
+ ip: '198.51.100.' + (i + 1), last_time: '2026-10-26T11:59:00+08:00', domain: 'app.example.com',
+ outcome, status: [444, 403, 308, 502, null][i], recent: i === 0,
+ count: 6, blocked_count: i === 0 ? 0 : 1, forwarded_count: 2, other_count: 3
+ }));
+ take(page, 'au').callback(null, { visitors });
+ const html = page.element('mg-v-list').innerHTML;
+ for (const label of ['已拦截', '已转发', '已响应', '请求失败', '结果未知', 'HTTP 444', 'HTTP 403',
+ '无状态码', '近5分钟有请求', '5分钟内无新请求', '总计 6', '拦截 1 · 转发 2 · 其他 3', '拦截 0 · 转发 2 · 其他 3']) {
+ assert.ok(html.includes(label), label);
+ }
+ assert.doesNotMatch(html, /在线|离线|mg-dot/);
+});
+
+test('overview renders legacy visitor responses as unknown and escapes log content', () => {
+ const page = fakePage(overviewScript);
+ take(page, 'au').callback(null, { visitors: [{
+ ip: '198.51.100.1', domain: ' ', count: 1,
+ last_time: '2026-10-26T11:59:00+08:00', online: true
+ }] });
+ const html = page.element('mg-v-list').innerHTML;
+ assert.match(html, /结果未知/);
+ assert.match(html, /请求时间未知/);
+ assert.match(html, /<img/);
+ assert.doesNotMatch(html, / {
const page = fakePage(loginGuardScript);
take(page, 'lu').callback(null, { enabled: '1', running: true, banned: {}, banned_total: 0, watching: {}, watching_total: 0, threshold: 3 });
diff --git a/nexttrace/Makefile b/nexttrace/Makefile
index b2fee762a..caff631af 100644
--- a/nexttrace/Makefile
+++ b/nexttrace/Makefile
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=nexttrace
-PKG_VERSION:=1.7.4
-PKG_RELEASE:=17
+PKG_VERSION:=1.7.5
+PKG_RELEASE:=18
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/nxtrace/NTrace-core/tar.gz/v$(PKG_VERSION)?