🛸 Sync 2026-10-27 02:27:08

This commit is contained in:
github-actions[bot] committed 2026-10-27 02:27:08 +08:00
1 parent b3798acf79
commit 8b63d5d4a9
78 files changed
+2288 -518

No files matched your search

+10 -3
View File
@@ -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
+5 -1
View File
@@ -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
@@ -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,
@@ -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,
Binary file not shown.
Binary file not shown.
@@ -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
@@ -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
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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)?
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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
+3
View File
@@ -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
+15 -1
View File
@@ -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"
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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)
@@ -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;
}
};
}
});
@@ -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; });
}
});
@@ -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;
@@ -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') + ':'),
@@ -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 = '<div class="error">' + _('Unable to fetch data') + '</div>';
}
@@ -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' }, [
@@ -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';
+68
View File
@@ -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"
+68
View File
@@ -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"
+68
View File
@@ -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 "このページに一致する接続はありません"
+68
View File
@@ -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"
+68
View File
@@ -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 "На этой странице нет подходящих соединений"
+68
View File
@@ -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 "本页没有匹配的连接"
+68
View File
@@ -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 "本頁沒有符合的連線"
@@ -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
;;
@@ -47,6 +47,7 @@
"getConnectionFlows",
"setHostname",
"deleteDevice",
"deleteDevices",
"getDnsQueries",
"getDnsStats",
"getScheduleLimits",
+1 -1
View File
@@ -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)
@@ -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') || '未安装';
-28
View File
@@ -1,28 +0,0 @@
#
# Copyright (C) 2008-2014 The LuCI Team <luci@lists.subsignal.org>
#
# This is free software, licensed under the Apache License, Version 2.0 .
#
include $(TOPDIR)/rules.mk
LUCI_TITLE:=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
-29
View File
@@ -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
-32
View File
@@ -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:<port>.")
return m
-54
View File
@@ -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
@@ -1,23 +0,0 @@
<script type="text/javascript">//<![CDATA[
var kaiOpenUrl = '<%=url("admin/services/linkease_apps/open")%>?id=kai';
XHR.poll(5, '<%=url("admin/services/kai_status")%>', null,function(x, st){
var el = document.getElementById('kai_status');
if (st && el){
if(!st.running){
el.innerHTML = '<br/><em style=\"color:red\"><%:The KAI service is not running.%></em>';
}
if (st.running){
el.innerHTML = '<br/><em style=\"color:green\"><%:The KAI service is running.%></em>'
+ "<br/><br/><input class=\"btn cbi-button cbi-button-apply\" type=\"button\" value=\" <%:Click to open KAI%> \" onclick=\"window.open(kaiOpenUrl, '_blank', 'noopener')\"/>";
}
}
});
//]]></script>
<fieldset class="cbi-section">
<legend><%:Status%></legend>
<p id="kai_status">
<em><%:Collecting data...%></em>
</p>
</fieldset>
+2 -2
View File
@@ -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
+32 -18
View File
@@ -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
@@ -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
+5 -4
View File
@@ -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
@@ -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
@@ -0,0 +1,99 @@
<%+tasks/embed%>
<% local installed = require("luci.model.kaiplus.classic").installed() %>
<style type="text/css">
.classic-kai-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin: 16px;
}
.classic-kai-actions #classic-kai-update {
font-size: 0.875em;
padding: 6px 12px;
min-height: 32px;
height: auto;
line-height: 1.4;
}
</style>
<script type="text/javascript">//<![CDATA[
var classicPageInstalled = <%= installed and "true" or "false" %>;
var classicInstallUrl = '<%=url("admin/services/kaiplus/install_kai")%>';
var classicUpdateUrl = '<%=url("admin/services/kaiplus/update_kai")%>';
var classicOpenUrl = '<%=url("admin/services/linkease_apps/open")%>?id=kai';
function installClassicKai() {
classicPackageTask(false);
}
function updateClassicKai() {
classicPackageTask(true);
}
function classicPackageTask(update) {
var prompt = update
? '<%:Update CLASSIC KAI and its runtime engine from the iStore package feed? A running CLASSIC KAI service will restart.%>'
: '<%:Classic KAI is not installed. Install it now?%>';
if (!window.confirm(prompt)) return;
if (!window.taskd || !window.taskd.show_log) {
window.alert('<%:Task log window is not available.%>');
return;
}
var button = document.getElementById(update ? 'classic-kai-update' : 'classic-kai-install');
button.disabled = true;
button.value = '<%:Starting task...%>';
(new XHR()).post(update ? classicUpdateUrl : classicInstallUrl, { token: window.taskd.csrfToken || '' }, function(x) {
var data = {};
button.disabled = false;
button.value = update ? '<%:Update%>' : '<%:Install CLASSIC KAI%>';
if (x && x.responseText) {
try { data = JSON.parse(x.responseText || '{}'); }
catch (e) { data = {}; }
}
if (!data.ok) {
window.alert(data.error || '<%:Failed to start the CLASSIC KAI package task.%>');
return;
}
if (data.installed) {
window.location.reload();
return;
}
window.taskd.show_log(data.task_id || 'kaiplus-kai-install');
});
}
XHR.poll(5, '<%=url("admin/services/kaiplus/classic_status")%>', null, function(x, st) {
if (!st) return;
if (!classicPageInstalled && st.installed) {
window.location.reload();
return;
}
var status = document.getElementById('classic-kai-status');
var installButton = document.getElementById('classic-kai-install');
var updateButton = document.getElementById('classic-kai-update');
var updateHint = document.getElementById('classic-kai-update-hint');
var openButton = document.getElementById('classic-kai-open');
status.textContent = !st.installed
? '<%:CLASSIC KAI is not installed. Install it to configure and start the service.%>'
: st.running ? '<%:The CLASSIC KAI service is running.%>' : '<%:The CLASSIC KAI service is not running.%>';
status.style.color = st.installed && st.running ? 'green' : '';
installButton.style.display = st.installed ? 'none' : '';
updateButton.style.display = st.installed ? '' : 'none';
updateHint.style.display = st.installed ? '' : 'none';
openButton.style.display = st.installed && st.running ? '' : 'none';
});
//]]></script>
<fieldset class="cbi-section">
<legend><%:Status%></legend>
<p id="classic-kai-status"><em><%:Collecting data...%></em></p>
<p id="classic-kai-update-hint" style="display:none"><%:Updates come from the iStore package feed and include CLASSIC KAI and its runtime engine.%></p>
<div class="classic-kai-actions">
<input id="classic-kai-install" class="btn cbi-button cbi-button-apply" type="button" value="<%:Install CLASSIC KAI%>" onclick="installClassicKai()" style="display:none"/>
<input id="classic-kai-open" class="btn cbi-button cbi-button-apply" type="button" value="<%:Open CLASSIC KAI%>" onclick="window.open(classicOpenUrl, '_blank', 'noopener')" style="display:none"/>
<input id="classic-kai-update" class="btn cbi-button" type="button" value="<%:Update%>" title="<%:Update CLASSIC KAI%>" aria-label="<%:Update CLASSIC KAI%>" onclick="updateClassicKai()" style="display:none"/>
</div>
</fieldset>
@@ -1,5 +1,3 @@
<%+tasks/embed%>
<style type="text/css">
.kaiplus-actions {
display: flex;
@@ -18,51 +16,10 @@
</style>
<script type="text/javascript">//<![CDATA[
var kaiInstalled = false;
var kaiLuciUrl = '<%=url("admin/services/kai")%>';
var kaiInstallUrl = '<%=url("admin/services/kaiplus/install_kai")%>';
var kaiCsrfToken = (window.taskd && window.taskd.csrfToken) || '';
function openClassicKai() {
var button = document.getElementById('kaiplus-open-kai');
if (kaiInstalled) {
window.location.href = kaiLuciUrl;
return;
}
if (!window.confirm('<%:Classic KAI is not installed. Install it now?%>')) return;
if (!window.taskd || !window.taskd.show_log) {
window.alert('<%:Task log window is not available.%>');
return;
}
button.disabled = true;
button.value = '<%:Starting installation...%>';
(new XHR()).post(kaiInstallUrl, { token: kaiCsrfToken }, function(x) {
var data = {};
button.disabled = false;
button.value = '<%:Classic KAI%>';
if (x && x.responseText) {
try { data = JSON.parse(x.responseText || '{}'); }
catch (e) { data = {}; }
}
if (!data.ok) {
window.alert(data.error || '<%:Failed to start KAI installation.%>');
return;
}
if (data.installed) {
kaiInstalled = true;
window.location.href = kaiLuciUrl;
return;
}
window.taskd.show_log(data.task_id || 'kaiplus-kai-install');
});
}
XHR.poll(5, '<%=url("admin/services/kaiplus_status")%>', null, function(x, st) {
var el = document.getElementById('kaiplus_status');
var openButton = document.getElementById('kaiplus-open');
if (st && el) {
kaiInstalled = st.kai_installed === true;
var status = st.running
? '<br/><em style=\"color:green\"><%:The KaiPlus service is running.%></em>'
: '<br/><em style=\"color:red\"><%:The KaiPlus service is not running.%></em>';
@@ -88,6 +45,6 @@ XHR.poll(5, '<%=url("admin/services/kaiplus_status")%>', null, function(x, st) {
</p>
<div class="kaiplus-actions">
<input id="kaiplus-open" class="btn cbi-button cbi-button-apply" type="button" value=" <%:Click to open KaiPlus%> " onclick="window.open('<%=url("admin/services/kaiplus/open")%>')" style="display:none"/>
<input id="kaiplus-open-kai" class="btn cbi-button" type="button" value=" <%:Classic KAI%> " onclick="openClassicKai()"/>
<input id="kaiplus-open-kai" class="btn cbi-button" type="button" value=" <%:CLASSIC KAI%> " onclick="window.location.href='<%=url("admin/services/kaiplus/classic")%>'"/>
</div>
</fieldset>
+51
View File
@@ -63,3 +63,54 @@ msgstr "状态"
msgid "Collecting data..."
msgstr "正在获取数据……"
msgid "CLASSIC KAI"
msgstr "经典版 KAI"
msgid "CLASSIC KAI can run alongside KaiPlus. Its settings and data are independent."
msgstr "经典版 KAI 可与酷友社AI 同时运行,配置和数据相互独立。"
msgid "Install CLASSIC KAI"
msgstr "安装经典版 KAI"
msgid "Open CLASSIC KAI"
msgstr "打开经典版 KAI"
msgid "CLASSIC KAI is not installed. Install it to configure and start the service."
msgstr "尚未安装经典版 KAI,安装后可在此配置和启动服务。"
msgid "The CLASSIC KAI service is running."
msgstr "经典版 KAI 服务正在运行。"
msgid "The CLASSIC KAI service is not running."
msgstr "经典版 KAI 服务未运行。"
msgid "Save & Apply starts or stops CLASSIC KAI without restarting KaiPlus."
msgstr "保存并应用后启动或停止经典版 KAI,酷友社AI 的运行不受影响。"
msgid "Required. CLASSIC KAI stores its workspace, cache, config, and state here. Use a different directory from KaiPlus."
msgstr "必填。经典版 KAI 在此保存工作区、缓存、配置和状态数据,请与酷友社AI 使用不同目录。"
msgid "CLASSIC KAI is available at http://router-address:port/apps/kai/web/. Use a different port from KaiPlus."
msgstr "可通过 http://路由器地址:端口/apps/kai/web/ 访问经典版 KAI,请与酷友社AI 使用不同端口。"
msgid "CLASSIC KAI could not apply the service settings. Check its configuration and system log."
msgstr "经典版 KAI 无法应用服务设置,请检查配置和系统日志。"
msgid "Update CLASSIC KAI"
msgstr "更新经典版 KAI"
msgid "Update"
msgstr "更新"
msgid "Update CLASSIC KAI and its runtime engine from the iStore package feed? A running CLASSIC KAI service will restart."
msgstr "是否从 iStore 软件源更新经典版 KAI 及运行引擎?正在运行的经典版服务将重启。"
msgid "Starting task..."
msgstr "正在启动任务……"
msgid "Failed to start the CLASSIC KAI package task."
msgstr "无法启动经典版 KAI 软件包任务。"
msgid "Updates come from the iStore package feed and include CLASSIC KAI and its runtime engine."
msgstr "从 iStore 软件源更新经典版 KAI 及运行引擎。"
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
set -eu
case "${1:-}" in
install)
echo "[KaiPlus] Installing classic KAI..."
/bin/is-opkg install kai
echo "[KaiPlus] Classic KAI installed successfully."
;;
update)
was_running=0
if pidof kai_bin >/dev/null 2>&1; then
was_running=1
fi
echo "[KaiPlus] Refreshing the iStore package feed..."
/bin/is-opkg update
echo "[KaiPlus] Updating classic KAI and its runtime engine..."
/bin/is-opkg upgrade kai kai_session
if [ "$was_running" = 1 ] && [ "$(uci -q get kai.@kai[0].enabled || true)" = 1 ]; then
/etc/init.d/kai restart
else
/etc/init.d/kai stop
fi
echo "[KaiPlus] Classic KAI package update completed."
;;
*)
echo "Usage: kaiplus-classic {install|update}" >&2
exit 2
;;
esac
@@ -0,0 +1,74 @@
const assert = require('node:assert/strict');
const vm = require('node:vm');
function page(installed) {
const elements = Object.fromEntries(['classic-kai-status', 'classic-kai-install', 'classic-kai-update', 'classic-kai-update-hint', 'classic-kai-open']
.map(id => [id, { style: {}, disabled: false }]));
const state = { elements, alerts: [], logs: [], reloads: 0 };
function XHR() {}
XHR.poll = (_, url, data, callback) => { state.poll = callback; };
XHR.prototype.post = (url, data, callback) => { state.post = { url, data, callback }; };
const context = {
XHR, document: { getElementById: id => elements[id] },
window: {
confirm: () => true, alert: message => state.alerts.push(message),
location: { reload: () => state.reloads++ },
taskd: { csrfToken: 'test-token', show_log: id => state.logs.push(id) }
}
};
vm.createContext(context);
vm.runInContext(source.replace('PAGE_INSTALLED', String(installed)), context);
state.context = context;
return state;
}
let state = page(false);
state.poll(null, { installed: false, running: false });
assert.equal(state.elements['classic-kai-install'].style.display, '');
assert.equal(state.elements['classic-kai-open'].style.display, 'none');
assert.equal(state.elements['classic-kai-update'].style.display, 'none');
state.context.installClassicKai();
assert.equal(state.post.data.token, 'test-token');
assert.equal(state.post.url, '/cgi-bin/luci/admin/services/kaiplus/install_kai');
assert.equal(state.elements['classic-kai-install'].disabled, true);
state.post.callback({ responseText: '{"ok":true,"task_id":"kaiplus-kai-install"}' });
assert.deepEqual(state.logs, ['kaiplus-kai-install']);
assert.equal(state.elements['classic-kai-install'].disabled, false);
state.poll(null, { installed: true, running: false });
assert.equal(state.reloads, 1);
state = page(false);
state.context.installClassicKai();
state.post.callback({ responseText: 'invalid response' });
assert.equal(state.alerts.length, 1);
assert.equal(state.elements['classic-kai-install'].disabled, false);
state.context.installClassicKai();
state.post.callback({ responseText: '{"ok":false,"error":"installer unavailable"}' });
assert.equal(state.alerts.at(-1), 'installer unavailable');
state.context.installClassicKai();
state.post.callback({ responseText: '{"ok":true,"installed":true}' });
assert.equal(state.reloads, 1);
state = page(true);
state.poll(null, { installed: true, running: true });
assert.equal(state.elements['classic-kai-install'].style.display, 'none');
assert.equal(state.elements['classic-kai-update'].style.display, '');
assert.equal(state.elements['classic-kai-open'].style.display, '');
assert.equal(state.elements['classic-kai-status'].style.color, 'green');
assert.equal(state.reloads, 0);
assert.equal(state.context.classicOpenUrl, '/cgi-bin/luci/admin/services/linkease_apps/open?id=kai');
state.poll(null, { installed: true, running: false });
assert.equal(state.elements['classic-kai-open'].style.display, 'none');
assert.equal(state.elements['classic-kai-update'].style.display, '');
state.context.updateClassicKai();
assert.equal(state.post.url, '/cgi-bin/luci/admin/services/kaiplus/update_kai');
assert.equal(state.post.data.token, 'test-token');
assert.equal(state.elements['classic-kai-update'].disabled, true);
state.post.callback({ responseText: '{"ok":true,"busy":true,"task_id":"kaiplus-kai-install"}' });
assert.deepEqual(state.logs, ['kaiplus-kai-install']);
assert.equal(state.elements['classic-kai-update'].disabled, false);
assert.equal(state.reloads, 0);
state.context.updateClassicKai();
state.post.callback({ responseText: 'invalid response' });
assert.equal(state.alerts.length, 1);
assert.equal(state.elements['classic-kai-update'].disabled, false);
state.context.window.confirm = () => false;
state.post = undefined;
state.context.updateClassicKai();
assert.equal(state.post, undefined);
@@ -0,0 +1,151 @@
-- Real sources are embedded by the Python runner. All I/O is stubbed, so this
-- harness can also run over SSH without changing the device's files or services.
local checks = 0
local function check(value, message)
assert(value, message)
checks = checks + 1
end
local runtime_files = { "/usr/sbin/kai_bin", "/usr/sbin/kai_session", "/etc/init.d/kai", "/etc/config/kai", "/usr/share/kai/kai-plugin.json" }
local function fixture(o)
o = o or {}
local f = { calls = {}, routes = {} }
local files = { ["/etc/config/kaiplus"] = true, ["/etc/init.d/tasks"] = true, ["/bin/is-opkg"] = true, ["/usr/libexec/kaiplus-classic"] = true }
if o.installed then for _, path in ipairs(runtime_files) do files[path] = true end end
if o.missing then files[o.missing] = nil end
local fs = { access = function(path) return files[path] end }
local sys = {
call = function(command)
f.calls[#f.calls + 1] = command
if command:match("^pidof kai_bin") then return o.running and 0 or 1 end
return o.rc or 0
end,
exec = function(command) f.task_query = command; return "task status fixture" end
}
package.loaded["nixio.fs"], package.loaded["luci.sys"] = fs, sys
package.loaded["luci.jsonc"] = { parse = function() return o.task_status or {} end }
package.loaded["luci.util"] = { shellquote = function(v) return "'" .. v:gsub("'", "'\\''") .. "'" end }
package.loaded["luci.http"] = {
getenv = function() return o.method or "POST" end,
formvalue = function() return o.token or "valid-token" end,
prepare_content = function() end,
write_json = function(v) f.reply = v end,
status = function(v) f.code = v end
}
package.loaded["luci.dispatcher"] = { context = { authsession = "session", token = "valid-token" } }
if o.no_session then package.loaded["luci.dispatcher"].context.authsession = nil end
f.classic = assert(loadstring(sources.classic))()
package.loaded["luci.model.kaiplus.classic"] = f.classic
f.paths = assert(loadstring(sources.paths))()
f.paths.blocks = function() return {} end
f.paths.home = function() return { Configs = "/mnt/test/Configs" } end
package.loaded["luci.model.kaiplus"] = f.paths
local cursor = { get_first = function(_, config, section, key)
check(config == "kai" and section == "kai" and key == "enabled", "form must read only KAI configuration")
return o.enabled or "0"
end }
local env = setmetatable({
module = function() end, nixio = { fs = fs },
_ = function(v) return v end, translate = function(v) return v end,
entry = function(path, target)
local r = { target = target }; f.routes[table.concat(path, "/")] = r; return r
end,
alias = function(...) return { kind = "alias", path = table.concat({...}, "/") } end,
cbi = function(name) return { name = name } end,
call = function(name) return { name = name } end,
SimpleSection = {}, TypedSection = {}, Flag = {}, Value = {}
}, { __index = _G })
local function form(kind, name)
local m = { kind = kind, name = name, sections = {}, uci = cursor }
function m:section()
local s = { fields = {} }
function s:option(_, key)
local field = { values = {} }
function field:value(v) self.values[#self.values + 1] = v end
self.fields[key] = field; return field
end
self.sections[#self.sections + 1] = s; return s
end
return m
end
env.Map = function(name) return form("map", name) end
env.SimpleForm = function(name) return form("simple", name) end
local function load(source)
local chunk = assert(loadstring(source)); setfenv(chunk, env); return chunk()
end
load(sources.controller)
f.controller, f.form = env, function() return load(sources.form) end
return f
end
local f = fixture()
f.controller.index()
check(f.routes["admin/services/kaiplus"].target.kind == "alias", "root entry must keep working")
check(f.routes["admin/services/kaiplus/settings"].target.name == "kaiplus", "KaiPlus settings missing")
check(f.routes["admin/services/kaiplus/classic"].target.name == "kaiplus/classic", "classic tab must exist without KAI")
check(f.routes["admin/services/kaiplus/update_kai"].target.name == "update_kai", "update route missing")
f.controller.classic_status()
check(f.reply.installed == false and f.reply.running == false, "missing KAI status")
f = fixture({ installed = true, running = true })
f.controller.classic_status()
check(f.reply.installed and f.reply.running, "runtime must work without luci-app-kai")
check(f.calls[1] == "pidof kai_bin >/dev/null", "status must use classic process")
for _, path in ipairs(runtime_files) do
f = fixture({ installed = true, missing = path })
check(not f.classic.installed(), "incomplete runtime must not be installed: " .. path)
end
for _, o in ipairs({ { method = "GET" }, { token = "wrong" }, { no_session = true } }) do
f = fixture(o); f.controller.install_kai()
check(f.code == (o.method == "GET" and 405 or 403), "install requires POST, session and CSRF")
check(#f.calls == 0 and not f.task_query, "rejected install must not execute commands")
o.installed = true
f = fixture(o); f.controller.update_kai()
check(f.code == (o.method == "GET" and 405 or 403), "update requires POST, session and CSRF")
check(#f.calls == 0 and not f.task_query, "rejected update must not execute commands")
end
f = fixture(); f.controller.install_kai()
check(f.reply.ok and f.reply.task_id == "kaiplus-kai-install", "install task missing")
check(#f.calls == 1 and f.calls[1]:find("/usr/libexec/kaiplus%-classic install"), "install optional runtime")
check(not f.calls[1]:find("luci%-app%-kai") and not f.calls[1]:find("/etc/init.d/kaiplus"), "install must not depend on old LuCI or restart KaiPlus")
f = fixture({ installed = true }); f.controller.install_kai()
check(f.reply.ok and f.reply.installed and #f.calls == 0, "already installed must be a no-op")
f = fixture(); f.controller.update_kai()
check(f.code == 409 and not f.reply.ok and #f.calls == 0, "update must not install an absent runtime")
f = fixture({ installed = true }); f.controller.update_kai()
check(f.reply.ok and f.reply.task_id == "kaiplus-kai-install", "update must share the installation task lock")
check(#f.calls == 1 and f.calls[1]:find("/usr/libexec/kaiplus%-classic update"), "queue classic update")
for _, running in ipairs({ true, 1, "1", "true" }) do
f = fixture({ task_status = { running = running } }); f.controller.install_kai()
check(f.reply.ok and f.reply.busy and #f.calls == 0, "do not duplicate busy installation")
f = fixture({ installed = true, task_status = { running = running } }); f.controller.update_kai()
check(f.reply.ok and f.reply.busy and #f.calls == 0, "install/update tasks must not overlap")
end
for _, path in ipairs({ "/etc/init.d/tasks", "/bin/is-opkg", "/usr/libexec/kaiplus-classic" }) do
f = fixture({ missing = path }); f.controller.install_kai()
check(not f.reply.ok and #f.calls == 0, "report unavailable installation support")
f = fixture({ installed = true, missing = path }); f.controller.update_kai()
check(not f.reply.ok and #f.calls == 0, "report unavailable update support")
end
f = fixture({ rc = 1 }); f.controller.install_kai()
check(not f.reply.ok and f.reply.task_id, "report task submission failure")
f = fixture({ installed = true, rc = 1 }); f.controller.update_kai()
check(not f.reply.ok and f.reply.task_id, "report update submission failure")
f = fixture()
local m = f.form()
check(m.kind == "simple" and m.submit == false and m.reset == false, "uninstalled tab must not create UCI form")
check(#f.calls == 0, "viewing tab must not start services")
f = fixture({ installed = true, enabled = "1" }); m = f.form()
check(m.kind == "map" and m.name == "kai", "edit original KAI configuration")
check(m.sections[2].fields.data_dir.default == "/mnt/test/Configs/KAI", "separate data directory")
check(m.sections[2].fields.port.default == "8197" and m.sections[2].fields.port.datatype == "port", "validated classic port")
m:on_after_commit()
check(#f.calls == 1 and f.calls[1] == "/etc/init.d/kai restart >/dev/null 2>&1", "apply must restart only KAI")
f = fixture({ installed = true, enabled = "0" }); m = f.form(); m:on_after_commit()
check(#f.calls == 1 and f.calls[1] == "/etc/init.d/kai stop >/dev/null 2>&1", "disable must stop only KAI")
f = fixture({ installed = true, enabled = "1", rc = 1 }); m = f.form(); m:on_after_commit()
check(m.message ~= nil, "report service operation failure")
local _, plus = f.paths.find_paths({}, { Configs = "/mnt/test/Configs" }, "Configs")
local _, classic = f.paths.find_paths({}, { Configs = "/mnt/test/Configs" }, "Configs", "KAI")
check(plus == "/mnt/test/Configs/KAIPlus" and classic == "/mnt/test/Configs/KAI", "preserve independent path defaults")
local _, fallback = f.paths.find_paths({ "/mnt/data" }, { Configs = "/root/Configs" }, "Configs", "KAI")
check(fallback == "/mnt/data/Configs/KAI", "preserve KAI disk selection")
print(checks .. " classic KAI runtime checks passed")
@@ -1,4 +1,11 @@
from pathlib import Path
import json
import os
import re
import shlex
import shutil
import subprocess
import tempfile
import unittest
@@ -6,51 +13,102 @@ APP_DIR = Path(__file__).resolve().parents[1]
class ClassicKaiEntryContractTest(unittest.TestCase):
def read(self, relative):
return (APP_DIR / relative).read_text(encoding="utf-8")
def test_classic_runtime_remains_an_optional_install(self):
makefile = (APP_DIR / "Makefile").read_text()
deps = re.search(r"^LUCI_DEPENDS:=(.*)$", makefile, re.M).group(1).split()
self.assertIn("+kaiplus", deps)
self.assertIn("+luci-lib-taskd", deps)
for package in ("kai", "kai_session", "kai-agent", "luci-app-kai"):
self.assertNotIn("+" + package, deps)
def test_status_reports_classic_kai_manifest(self):
controller = self.read("luasrc/controller/kaiplus.lua")
def test_lua_routes_installation_and_configuration(self):
command = os.environ.get("LUA_COMMAND")
if not command:
lua = shutil.which("lua5.1") or shutil.which("lua") or shutil.which("luajit")
if not lua:
self.skipTest("Lua 5.1 required; LUA_COMMAND can use an SSH interpreter")
command = shlex.quote(lua) + " -"
paths = {
"controller": "luasrc/controller/kaiplus.lua",
"classic": "luasrc/model/kaiplus/classic.lua",
"paths": "luasrc/model/kaiplus.lua",
"form": "luasrc/model/cbi/kaiplus/classic.lua",
}
sources = "local sources = {}\n"
for name, relative in paths.items():
body = (APP_DIR / relative).read_text()
self.assertNotIn("]====]", body)
sources += f"sources.{name} = [====[{body}]====]\n"
harness = (APP_DIR / "tests/classic_kai_runtime.lua").read_text()
result = subprocess.run(shlex.split(command), input=sources + harness,
text=True, capture_output=True, timeout=45)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("classic KAI runtime checks passed", result.stdout)
self.assertIn('fs.access("/usr/share/kai/kai-plugin.json")', controller)
self.assertIn('fs.access("/usr/lib/lua/luci/controller/kai.lua")', controller)
def test_installation_ui_retries_and_reloads_the_embedded_form(self):
node = shutil.which("node")
if not node:
self.skipTest("Node.js required for browser behavior checks")
template = (APP_DIR / "luasrc/view/kaiplus/classic_status.htm").read_text()
script = re.search(r"<script[^>]*>(.*?)</script>", 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('<div class="kaiplus-actions">')
kaiplus = status.index('id="kaiplus-open"', actions)
classic = status.index('id="kaiplus-open-kai"', actions)
actions_end = status.index("</div>", 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__":
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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 <info@masmbit.com>
@@ -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 "$@" ;;
@@ -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';
@@ -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
}
+1 -1
View File
@@ -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
@@ -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%;
@@ -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();
}
});
},
/**
@@ -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;
+6 -1
View File
@@ -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;
}
}
}
+37 -3
View File
@@ -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);
}
}
}
+7
View File
@@ -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%;
@@ -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);
});
+2 -2
View File
@@ -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
+22 -17
View File
@@ -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
+33 -14
View File
@@ -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
+17 -12
View File
@@ -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)}
}
</style>
@@ -173,6 +173,7 @@ o.cfgvalue = function()
<option value="50">50</option>
</select>条</label>
</div>
<div class="mg-request-detail" style="padding:10px 16px">包含被拦截的请求;结果对应最后一次请求,次数为最近日志内按 IP 汇总。已转发不代表已登录。</div>
<div id="mg-v-list" class="mg-table-wrap"><div class="mg-empty">加载中...</div></div>
</div>
@@ -286,20 +287,24 @@ function loadVisitors(force){
}
ct.textContent='('+visitors.length+' 个IP)';
var h='<table class="mg-table">';
h+='<thead><tr><th>状态</th><th>IP 地址</th><th>归属地</th><th>最后访问</th><th>域名</th><th>次数</th></tr></thead><tbody>';
h+='<thead><tr><th>最近结果</th><th>IP 地址</th><th>归属地</th><th>最后请求</th><th>域名</th><th>请求次数</th></tr></thead><tbody>';
for(var i=0;i<visitors.length;i++){
var v=visitors[i];
var dot=v.online
?'<span class="mg-dot on" title="在线"></span>'
:'<span class="mg-dot" title="离线"></span>';
var stxt=v.online?'<span style="color:'+mgColor('success')+'">在线</span>':'<span style="color:'+mgColor('muted')+'">离线</span>';
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+='<tr>';
h+='<td><span class="mg-status">'+dot+stxt+'</span></td>';
h+='<td><div class="mg-request-result"><span class="mg-status" style="color:'+mgColor(outcome[1])+'">'+outcome[0]+'</span><span class="mg-request-detail">'+escHtml(status)+'</span></div></td>';
h+='<td><code class="mg-ip">'+escHtml(v.ip)+'</code></td>';
h+='<td id="geo-'+i+'">'+mgGeoHtml(_geoCache[v.ip])+'</td>';
h+='<td style="white-space:nowrap">'+escHtml(fmtT(v.last_time))+'</td>';
h+='<td style="white-space:nowrap"><div class="mg-request-result"><span>'+escHtml(fmtT(v.last_time))+'</span><span class="mg-request-detail">'+activity+'</span></div></td>';
h+='<td>'+escHtml(v.domain)+'</td>';
h+='<td>'+escHtml(v.count)+'</td>';
h+='<td><div class="mg-request-result"><span>总计 '+escHtml(String(v.count))+'</span>';
if(typeof v.blocked_count==='number'&&typeof v.forwarded_count==='number'&&typeof v.other_count==='number'){
h+='<span class="mg-request-detail">拦截 '+escHtml(String(v.blocked_count))+' · 转发 '+escHtml(String(v.forwarded_count))+' · 其他 '+escHtml(String(v.other_count))+'</span>';
}
h+='</div></td>';
h+='</tr>';
}
h+='</tbody></table>';
+24
View File
@@ -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` 校验文件完整性。
+1
View File
@@ -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"}';
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+64
View File
@@ -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/);
});
+7
View File
@@ -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 => {
+29
View File
@@ -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: '<img src=x onerror=alert(1)>', 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, /&lt;img/);
assert.doesNotMatch(html, /<img|已转发|在线|离线|拦截 undefined/);
});
test('login guard renders empty JSON objects as empty lists and uses POST for changes', () => {
const page = fakePage(loginGuardScript);
take(page, 'lu').callback(null, { enabled: '1', running: true, banned: {}, banned_total: 0, watching: {}, watching_total: 0, threshold: 3 });
+2 -2
View File
@@ -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)?