mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-03 06:13:56 +08:00
🛸 Sync 2026-10-27 02:27:08
This commit is contained in:
1 parent
b3798acf79
commit
8b63d5d4a9
78 files changed
+2288
-518
No files matched your search
+10
-3
@@ -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
|
||||
|
||||
@@ -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.
Binary file not shown.
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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 "このページに一致する接続はありません"
|
||||
@@ -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"
|
||||
@@ -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 "На этой странице нет подходящих соединений"
|
||||
@@ -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 "本页没有匹配的连接"
|
||||
@@ -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",
|
||||
|
||||
@@ -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') || '未安装';
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>';
|
||||
|
||||
@@ -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` 校验文件完整性。
|
||||
@@ -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"}';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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, /<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
@@ -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)?
|
||||
|
||||
Reference in new issue
Block a user