mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-06 03:14:12 +08:00
🌴 Sync 2026-10-28 19:42:05
This commit is contained in:
1 parent
2d9a92ead9
commit
c3e527335a
22 files changed
+7105
No files matched your search
@@ -0,0 +1,130 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=isp-split
|
||||
PKG_VERSION:=0.2.1
|
||||
PKG_RELEASE:=1
|
||||
PKG_LICENSE:=MIT
|
||||
PKG_LICENSE_FILES:=LICENSE
|
||||
PKG_MAINTAINER:=jihebube <jihebube@users.noreply.github.com>
|
||||
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
|
||||
define Package/isp-split
|
||||
SECTION:=net
|
||||
CATEGORY:=Network
|
||||
SUBMENU:=Routing and Redirection
|
||||
TITLE:=ISP-aware policy routing for mwan3 (nftables)
|
||||
# 注意:这里故意不写 +mwan3 依赖。
|
||||
# 官方 feed 的 mwan3 是 iptables 版(2.12.5),写 +mwan3 会错误地拉进它;
|
||||
# Kwrt 源里 nft 版的包名是 mwan3-nft,与其他源命名不一致。
|
||||
# nftables 版 mwan3 是硬性前提,由 install.sh 与 router-preflight 检查,
|
||||
# 并在 description 里说明。
|
||||
DEPENDS:=+nftables +curl +mwan3-nft
|
||||
PKGARCH:=all
|
||||
endef
|
||||
|
||||
define Package/isp-split/description
|
||||
Route traffic by the ISP that owns the destination address.
|
||||
Builds mutually exclusive nftables sets from daily-updated
|
||||
Chinese ISP CIDR data and wires them into mwan3-nft rules.
|
||||
|
||||
REQUIRES the nftables port of mwan3 (>= 3.6.0):
|
||||
- Kwrt: apk add mwan3-nft (软件源里自带)
|
||||
- 上游: 用 https://github.com/dl12345/mwan3 替换官方 feed 的
|
||||
iptables 版 mwan3 (2.12.5),或从 Release 下载
|
||||
endef
|
||||
|
||||
define Build/Compile
|
||||
endef
|
||||
|
||||
define Package/isp-split/conffiles
|
||||
/etc/isp-split.conf
|
||||
endef
|
||||
|
||||
# opkg 升级会以 prerm upgrade 调用;apk 的 prerm 只映射到 pre-deinstall。
|
||||
# 升级时绝不能 uninstall:数据目录、mwan3 规则都必须保留。
|
||||
define Package/isp-split/prerm
|
||||
#!/bin/sh
|
||||
[ -n "$${IPKG_INSTROOT}" ] && exit 0
|
||||
case "$${1:-}" in
|
||||
upgrade|pre-upgrade|*upgrade*) exit 0 ;;
|
||||
esac
|
||||
if [ -x /usr/sbin/isp-split ]; then
|
||||
/usr/sbin/isp-split uninstall >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit 0
|
||||
endef
|
||||
|
||||
# 卸载后的兜底清理:cron、命名计数器、dnsmasq confdir、数据目录。
|
||||
# 正常路径由 prerm 的 isp-split uninstall 完成,这里防止二进制先被删掉。
|
||||
define Package/isp-split/postrm
|
||||
#!/bin/sh
|
||||
[ -n "$${IPKG_INSTROOT}" ] && exit 0
|
||||
case "$${1:-}" in
|
||||
upgrade|pre-upgrade|*upgrade*) exit 0 ;;
|
||||
esac
|
||||
if [ -f /etc/crontabs/root ]; then
|
||||
# mktemp 而不是 /tmp/.ispcron.$$:PID 可预测,符号链接能抢占(SEC-1)
|
||||
_cron_tmp=$$(mktemp /etc/.isp-split-cron.XXXXXX) || _cron_tmp=""
|
||||
if [ -n "$$_cron_tmp" ]; then
|
||||
grep -v 'isp-split' /etc/crontabs/root > "$$_cron_tmp" 2>/dev/null || true
|
||||
mv -f "$$_cron_tmp" /etc/crontabs/root 2>/dev/null || rm -f "$$_cron_tmp"
|
||||
fi
|
||||
/etc/init.d/cron restart >/dev/null 2>&1 || true
|
||||
fi
|
||||
nft delete counter inet mwan3 isp_fallback >/dev/null 2>&1 || true
|
||||
_state=/etc/isp-split/state/dnsmasq.confdir.orig
|
||||
if [ -f "$$_state" ]; then
|
||||
_orig=$$(cat "$$_state" 2>/dev/null)
|
||||
if [ -n "$$_orig" ]; then
|
||||
uci -q set "dhcp.@dnsmasq[0].confdir=$$_orig" 2>/dev/null || true
|
||||
else
|
||||
uci -q delete dhcp.@dnsmasq[0].confdir 2>/dev/null || true
|
||||
fi
|
||||
uci -q commit dhcp 2>/dev/null || true
|
||||
rm -f "$$_state"
|
||||
fi
|
||||
# BUG-6:OpenClash 接管时 dnsmasq 实际加载的是 /var/etc/dnsmasq.conf.*
|
||||
# 里 conf-dir= 指向的目录,必须按同一来源清理,不能只删 /tmp/dnsmasq.d。
|
||||
for _f in /var/etc/dnsmasq.conf.*; do
|
||||
[ -f "$$_f" ] || continue
|
||||
sed -n 's/^[[:space:]]*conf-dir=//p' "$$_f" 2>/dev/null |
|
||||
while IFS= read -r _line; do
|
||||
for _d in $$_line; do
|
||||
_d=$${_d%%,*}
|
||||
while [ "$$_d" != "/" ] && [ "$${_d%/}" != "$$_d" ]; do
|
||||
_d=$${_d%/}
|
||||
done
|
||||
[ -n "$$_d" ] && rm -f "$$_d/isp-split.conf" 2>/dev/null || true
|
||||
done
|
||||
done
|
||||
done
|
||||
rm -f /tmp/dnsmasq.d/isp-split.conf /etc/isp-split/dnsmasq.d/isp-split.conf 2>/dev/null || true
|
||||
rm -rf /etc/isp-split 2>/dev/null || true
|
||||
exit 0
|
||||
endef
|
||||
|
||||
# 显式指定权限,不依赖源文件的权限位——固件里脚本不可执行会很难查
|
||||
define Package/isp-split/install
|
||||
$(INSTALL_DIR) $(1)/usr/sbin
|
||||
$(INSTALL_DIR) $(1)/usr/lib/isp-split
|
||||
$(INSTALL_DIR) $(1)/etc/init.d
|
||||
$(INSTALL_DIR) $(1)/etc/uci-defaults
|
||||
$(INSTALL_DIR) $(1)/etc/isp-split/data
|
||||
|
||||
$(INSTALL_BIN) ./files/usr/sbin/isp-split $(1)/usr/sbin/isp-split
|
||||
$(INSTALL_BIN) ./files/etc/init.d/isp-split $(1)/etc/init.d/isp-split
|
||||
$(INSTALL_BIN) ./files/usr/lib/isp-split/build.sh $(1)/usr/lib/isp-split/build.sh
|
||||
$(INSTALL_BIN) ./files/etc/uci-defaults/99-isp-split $(1)/etc/uci-defaults/99-isp-split
|
||||
|
||||
$(INSTALL_DATA) ./files/usr/lib/isp-split/cidr2range.awk $(1)/usr/lib/isp-split/cidr2range.awk
|
||||
$(INSTALL_DATA) ./files/usr/lib/isp-split/merge.awk $(1)/usr/lib/isp-split/merge.awk
|
||||
$(INSTALL_DATA) ./files/usr/lib/isp-split/exclude.awk $(1)/usr/lib/isp-split/exclude.awk
|
||||
$(INSTALL_DATA) ./files/usr/lib/isp-split/range2cidr.awk $(1)/usr/lib/isp-split/range2cidr.awk
|
||||
$(INSTALL_DATA) ./files/usr/lib/isp-split/rules-splice.awk $(1)/usr/lib/isp-split/rules-splice.awk
|
||||
$(INSTALL_DATA) ./files/usr/lib/isp-split/badcidr.awk $(1)/usr/lib/isp-split/badcidr.awk
|
||||
$(INSTALL_DATA) ./files/usr/lib/isp-split/ispdiff.awk $(1)/usr/lib/isp-split/ispdiff.awk
|
||||
$(INSTALL_CONF) ./files/etc/isp-split.conf $(1)/etc/isp-split.conf
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,isp-split))
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
# 开机时把已构建好的集合灌进 nftables。
|
||||
# 注意:开机不下载数据 —— 网络可能还没通,交给 cron 每日更新。
|
||||
|
||||
START=95
|
||||
STOP=10
|
||||
|
||||
start() {
|
||||
(
|
||||
i=0
|
||||
while [ "$i" -lt 60 ]; do
|
||||
if nft list table inet mwan3 >/dev/null 2>&1; then
|
||||
/usr/sbin/isp-split apply
|
||||
|
||||
# mwan3_output 是 mwan3 重建的,我们插进去的 DNS 旁路规则
|
||||
# 会随 mwan3 重启丢失。dns 子命令是幂等的,直接重放。
|
||||
/usr/sbin/isp-split dns >/dev/null 2>&1
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
logger -t isp-split "开机等待 mwan3 超时,未应用运营商集合"
|
||||
) &
|
||||
}
|
||||
|
||||
stop() {
|
||||
:
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
# /etc/isp-split.conf
|
||||
# openwrt-isp-split 配置文件
|
||||
|
||||
# ---------------------------------------------------------------- 多线路 DNS
|
||||
#
|
||||
# 为什么需要:只做"按目标 IP 纠线路"治标不治本。如果 DNS 查询本身走的是
|
||||
# 另一条线,CDN 会返回那条线的节点,你拿着电信节点走联通线,速度差 100 倍。
|
||||
# 让"从哪条线出去,就用哪条线的 DNS",CDN 才会主动把本运营商节点给你。
|
||||
# 这是商业方案(如爱快)分流效果更好的关键,原理见 docs/DESIGN.md 的
|
||||
# 「与商业方案(iKuai)的对比」一节。
|
||||
#
|
||||
# 每条线路对应的 DNS:<mwan3策略>:<DNS服务器>
|
||||
DNS_TABLE="
|
||||
wan_only:223.5.5.5
|
||||
wanb_only:119.29.29.29
|
||||
"
|
||||
|
||||
# 多线路 DNS 的「出线方式」
|
||||
# bind = server=/域名/IP@接口,用 dnsmasq 的 SO_BINDTODEVICE 绑定出接口
|
||||
# mark = server=/域名/IP + nft 给发往该 DNS 的 53 端口打上对应策略的标
|
||||
#
|
||||
# 怎么选:
|
||||
# · 纯 dnsmasq 环境、没装 OpenClash → bind(更直接)
|
||||
# · OpenClash 接管了 DNS,或解析器不是 dnsmasq → mark
|
||||
# (mark 只看目标 IP,不关心是谁发的查询,抗干扰;也不依赖 @接口特性)
|
||||
DNS_MODE="bind"
|
||||
|
||||
# 统计未命中任何运营商集合的兜底流量(status 里显示"兜底"行)
|
||||
# 规则插在本工具规则块之后,命中集合的流量不会经过它
|
||||
# 口径:ct state new —— 只计每条连接的**首包**。不加这一条时,若某个流
|
||||
# 最终没拿到 conntrack 标记(例如 mwan3 的 default_rule_v4 被 enabled=0
|
||||
# 关闭),同一连接的每个包都会重复计数,数字会按包放大。
|
||||
COUNT_FALLBACK="1"
|
||||
|
||||
# 兜底计数是否排除 DNS(udp/tcp 任一侧端口 53)
|
||||
# 1 = 排除(默认;出向查询 dport=53 与入向响应 sport=53 都不计)
|
||||
# 0 = 不排除,保持旧行为
|
||||
#
|
||||
# 为什么默认排除:DNS 查询/响应本身不是业务流量——到网关(如 192.168.10.1:53)
|
||||
# 的查询在本地就被应答,到运营商 DNS 的也只是解析开销。它们占兜底包数
|
||||
# 比例很高,计进去只会稀释"未命中运营商集合的真实业务流量"这个口径。
|
||||
# 排除按 UDP/TCP 源或目的端口 53 判断,不写死网关地址,所以无论查的是
|
||||
# 本地网关还是运营商 DNS,查询和响应都不会被当成业务流量。
|
||||
COUNT_FALLBACK_SKIP_DNS="1"
|
||||
|
||||
# 域名分流:<域名后缀>:<策略>
|
||||
# 这些域名的解析会被强制从对应线路发出。
|
||||
# 例如 B 站:联通自有 CDN(cn-*-cu-*)需要联通 DNS 才会解析出来。
|
||||
DOMAIN_SPLIT="
|
||||
bilivideo.com:wanb_only
|
||||
bilibili.com:wanb_only
|
||||
"
|
||||
|
||||
# 可选:外部域名清单目录,$DOMAINDIR/<策略>.txt,每行一个域名。
|
||||
# 也兼容 felixonmars/dnsmasq-china-list 的 server=/domain/1.2.3.4 格式。
|
||||
DOMAIN_LIST_DIR=""
|
||||
|
||||
# 运营商 IP 段数据源(单源兼容模式;ISP_SOURCES 为空时才用它)
|
||||
#
|
||||
# 这里保留 clang 的基址和 <源名>.txt 命名,是为了让 0.1.x 老配置在
|
||||
# ISP_SOURCES 留空时逐字节保持原行为。默认多源配置见下面的 ISP_SOURCES。
|
||||
ISP_MIRROR="https://ispip.clang.cn"
|
||||
|
||||
# ---------------------------------------------------------------- 多数据源(0.2.1 默认双源)
|
||||
#
|
||||
# 格式:每行一条 <名字>:<角色>:<镜像基址>
|
||||
# 角色 primary 出数据(必须且只能有一个)
|
||||
# verify 只用来跑日 diff,不参与合成
|
||||
# audit 低频审计源(同样只跑 diff)
|
||||
#
|
||||
# 0.2.1 默认:"主源 + 校验源"(方案 c)
|
||||
# · 主源换成 gaoyifan/china-operator-ip(MIT,BGP/AS_PATH 口径)
|
||||
# · clang 只做运行时校验源:默认每天下载、跑 Jaccard diff,不参与合成
|
||||
# · 校验源连续失败 1-2 天只降级;达到 ISP_SOURCE_MAX_FAIL_DAYS 天则拒绝更新
|
||||
# · 主源失败立即冻结(保留旧数据),绝不自动换源——换源=换口径
|
||||
#
|
||||
# 留空 = 单源兼容模式:仍按上面的 ISP_MIRROR/<源名>.txt 拉取,
|
||||
# 行为与 0.1.x 完全一致。老配置不需要改任何东西。
|
||||
#
|
||||
# 注意:升级时 opkg/apk 会保留用户已有的 /etc/isp-split.conf。这里改的只是
|
||||
# 新安装的默认模板;已有配置里显式声明的 ISP_SOURCES 不会被覆盖。
|
||||
ISP_SOURCES="
|
||||
yfgao:primary:https://china-operator-ip.yfgao.com
|
||||
clang:verify:https://ispip.clang.cn
|
||||
"
|
||||
|
||||
# 每个源的"文件名映射":<源名>:<ISP_TABLE 里的源名>=<该源上的文件名>,...
|
||||
#
|
||||
# 必须显式声明:clang 的文件名是 chinatelecom.txt,yfgao 的同一个运营
|
||||
# 商叫 chinanet.txt。按名字猜一定会错,所以这里只认声明。
|
||||
#
|
||||
# 规则:某个源只要声明了任意一条映射,映射里没列出的运营商默认不下载;
|
||||
# 若该运营商已启用,主源会按历史命名 <源名>.txt 再试一次(兼容 0.2.0 老配置),
|
||||
# 文件不存在时 fetch 直接报错。校验源/审计源只认映射,绝不去猜。
|
||||
#
|
||||
# yfgao 只有 chinanet/unicom/cmcc/cernet 四类运营商数据,没有
|
||||
# chinabtn/gwbn/othernet/hk/mo/tw。所以 ISP_TABLE 里后六类默认保持 0(关闭);
|
||||
# 若要启用它们,需要把主源换成提供这些文件的源,或自行补映射。
|
||||
ISP_SOURCE_FILES="
|
||||
yfgao:chinatelecom=chinanet,unicom_cnc=unicom,cmcc=cmcc,cernet=cernet
|
||||
clang:chinatelecom=chinatelecom,unicom_cnc=unicom_cnc,cmcc=cmcc,cernet=cernet
|
||||
"
|
||||
|
||||
# 是否启用校验源(0 = 不下载不校验;0.2.1 起默认 1)
|
||||
ISP_VERIFY_ENABLE=1
|
||||
|
||||
# 分歧阈值:某个运营商的 Jaccard 低于这个百分比就拒绝本次更新(退出码 3)
|
||||
# 实测正常值 95.7%-96.9%,口径切错时 64.6%-87.9%,90 是中间带
|
||||
ISP_DIFF_MAX_JACCARD_DROP=90
|
||||
|
||||
# 单运营商地址数日变化上限(%),只在校验源启用时生效
|
||||
ISP_DIFF_MAX_DELTA_PCT=3
|
||||
|
||||
# 校验源连续失败多少天后,从"降级继续"升级为"拒绝更新"
|
||||
ISP_SOURCE_MAX_FAIL_DAYS=3
|
||||
|
||||
# 运营商表,格式:<源文件名>:<nft集合名/规则名>:<mwan3策略>:<是否启用>
|
||||
#
|
||||
# 顺序即优先级:排在前面的运营商优先占有重叠地址。
|
||||
# 同一个 IP 只会出现在一个集合里,所以调整这里的顺序会改变归属,
|
||||
# 但不会出现"规则先后决定归属"的隐性行为。
|
||||
#
|
||||
# 集合名同时也是 mwan3 规则名,mwan3 限制规则名 <=15 字符,
|
||||
# 且不能以 mwan3_ 开头(该前缀为 mwan3 内部保留)。
|
||||
#
|
||||
# 策略名必须已在 mwan3 中定义,可用 `mwan3 status` 或
|
||||
# `uci show mwan3 | grep policy` 查看。
|
||||
ISP_TABLE="
|
||||
chinatelecom:isp_tel:wan_only:1
|
||||
unicom_cnc:isp_uni:wanb_only:1
|
||||
cmcc:isp_cmcc:wanb_only:1
|
||||
cernet:isp_cernet:wan_only:1
|
||||
chinabtn:isp_tietong:wanb_only:0
|
||||
gwbn:isp_gwbn:balanced:0
|
||||
othernet:isp_other:wanb_only:0
|
||||
hk:isp_hk:wanb_only:0
|
||||
mo:isp_mo:wanb_only:0
|
||||
tw:isp_tw:wanb_only:0
|
||||
"
|
||||
|
||||
# 关于 othernet 默认关闭(实测教训):
|
||||
# othernet 是"三大运营商以外"的集合(华为云、腾讯云、各类 IDC、
|
||||
# 以及**公共 DNS** 如 223.5.5.5 / 119.29.29.29 都可能落在这里)。
|
||||
# 启用它之后,路由器自己的 DNS 查询会被分到这条线上——如果这条线
|
||||
# 恰好不可用,整个域名解析就挂了(实测:启用后所有域名解析失败)。
|
||||
# 要启用它,先确认你的 DNS 服务器不属于该集合,或者给策略配好备份成员。
|
||||
|
||||
# 关于 cmcc(移动)默认走联通的实测依据:
|
||||
# Steam 移动 CDN(39.134.x)实测 联通 22ms / 电信 72ms,联通明显更好,
|
||||
# 而 Steam 下载是典型大流量场景,故默认给联通。
|
||||
#
|
||||
# 已知例外:B 站 PCDN(*mcdn.bilivideo.cn)虽然是移动段,
|
||||
# 实测 电信 12-22 MB/s 而联通只有 0.04-1 MB/s。若你常用 B 站且命中 mcdn,
|
||||
# 应改用域名/网段级覆盖,而不是把 cmcc 整体改回电信——
|
||||
# B 站自有联通 CDN(cn-*-cu-*)恰恰需要走联通才快。
|
||||
|
||||
# 规则插入位置:在第一个匹配该正则的 mwan3 rule 之前插入运营商规则。
|
||||
# 默认插在按端口/兜底规则之前、按设备/IP 的规则之后。
|
||||
ISP_ANCHOR='dest_port|src_port|default_rule|https|internet'
|
||||
|
||||
# 每日自动更新(crontab 时间)
|
||||
ISP_CRON="17 4 * * *"
|
||||
|
||||
# 分线测速用的接口与探测目标
|
||||
PROBE_IFACES="pppoe-wan1 pppoe-wan2"
|
||||
PROBE_BYTES=33554432
|
||||
PROBE_TIME=25
|
||||
PROBE_URLS="
|
||||
https://mirrors.huaweicloud.com/ubuntu-releases/24.04/ubuntu-24.04.3-live-server-amd64.iso
|
||||
https://mirrors.aliyun.com/ubuntu-releases/24.04/ubuntu-24.04.3-live-server-amd64.iso
|
||||
"
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# 首次启动初始化 —— 用于把本包直接编译进固件的情况
|
||||
#
|
||||
# 为什么需要:打进固件时,opkg/apk 的 postinst 在镜像构建阶段就被跳过
|
||||
# (IPKG_INSTROOT 非空),所以 /etc/rc.d 的自启软链不会自动建立。
|
||||
# OpenWrt 会在首次启动时执行 /etc/uci-defaults/ 下的脚本,就在这里补上。
|
||||
|
||||
[ -x /usr/sbin/isp-split ] || exit 0
|
||||
|
||||
/etc/init.d/isp-split enable >/dev/null 2>&1
|
||||
|
||||
# 启用每日更新(只改数据,不重写 mwan3 配置)
|
||||
if [ -x /etc/init.d/cron ]; then
|
||||
mkdir -p /etc/crontabs
|
||||
touch /etc/crontabs/root
|
||||
if ! grep -q 'isp-split' /etc/crontabs/root 2>/dev/null; then
|
||||
echo "17 4 * * * /usr/sbin/isp-split refresh >/dev/null 2>&1 # isp-split" \
|
||||
>> /etc/crontabs/root
|
||||
fi
|
||||
/etc/init.d/cron enable >/dev/null 2>&1
|
||||
/etc/init.d/cron restart >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
logger -t isp-split "固件内置初始化完成:已开自启与每日更新;运行 isp-split plan 进行预演"
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,42 @@
|
||||
# 找出不合法的 CIDR 行(校验规则与 cidr2range.awk 保持一致)
|
||||
# 输出格式:<行号>: <原始内容>
|
||||
{
|
||||
line = $0
|
||||
sub(/#.*/, "", line)
|
||||
gsub(/[ \t\r]/, "", line)
|
||||
if (line == "")
|
||||
next
|
||||
|
||||
n = split(line, p, "/")
|
||||
if (n != 2) {
|
||||
print NR ": " $0
|
||||
next
|
||||
}
|
||||
|
||||
alen = split(p[1], q, ".")
|
||||
if (alen != 4) {
|
||||
print NR ": " $0
|
||||
next
|
||||
}
|
||||
|
||||
# 与 cidr2range.awk 保持一致:前缀必须是纯数字,不能靠 +0 转换
|
||||
if (p[2] !~ /^[0-9]+$/) {
|
||||
print NR ": " $0
|
||||
next
|
||||
}
|
||||
len = p[2] + 0
|
||||
if (len < 0 || len > 32) {
|
||||
print NR ": " $0
|
||||
next
|
||||
}
|
||||
|
||||
bad = 0
|
||||
for (i = 1; i <= 4; i++) {
|
||||
if (q[i] !~ /^[0-9]+$/)
|
||||
bad = 1
|
||||
else if (q[i] + 0 > 255)
|
||||
bad = 1
|
||||
}
|
||||
if (bad)
|
||||
print NR ": " $0
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
# 数据管线:原始 CIDR -> 归一化 -> 按优先级互斥化 -> 每运营商一个 CIDR 文件
|
||||
#
|
||||
# 用法: build.sh <rawdir> <outdir> <优先级列表>
|
||||
# rawdir : 含 <isp>.raw 的目录,每行一个 CIDR
|
||||
# 优先级 : 空格分隔的运营商名,排在前面的优先占有重叠地址
|
||||
#
|
||||
# 设计要点:
|
||||
# 1. 每个集合内部先合并去重(RFC1918 无关,只做区间合并)
|
||||
# 2. 逐级做差集:后一个集合要扣掉所有前面已占用的地址
|
||||
# -> 保证同一 IP 只出现在一个集合里,规则顺序不再影响归属
|
||||
# 3. 全程只依赖 awk + sort,无 Python、无 ipcalc
|
||||
set -eu
|
||||
|
||||
RAW=$1
|
||||
OUT=$2
|
||||
PRIORITY=$3
|
||||
|
||||
LIB=${ISP_SPLIT_LIB:-$(dirname "$0")}
|
||||
WORK=$OUT/.work
|
||||
|
||||
mkdir -p "$OUT" "$WORK"
|
||||
: > "$WORK/claimed"
|
||||
|
||||
printf '%-16s %8s %8s %14s\n' "运营商" "原始" "互斥后" "地址数" > "$WORK/report"
|
||||
|
||||
for isp in $PRIORITY; do
|
||||
src=$RAW/$isp.raw
|
||||
[ -f "$src" ] || {
|
||||
printf '%-16s %8s\n' "$isp" "缺失" >> "$WORK/report"
|
||||
continue
|
||||
}
|
||||
|
||||
# 1. CIDR -> 区间,排序,合并
|
||||
awk -f "$LIB/cidr2range.awk" "$src" |
|
||||
sort |
|
||||
awk -f "$LIB/merge.awk" > "$WORK/$isp.ranges"
|
||||
|
||||
# 2. 扣掉高优先级运营商已占用的地址
|
||||
awk -v claimed="$WORK/claimed" -f "$LIB/exclude.awk" "$WORK/$isp.ranges" > "$WORK/$isp.excl"
|
||||
|
||||
# 3. 区间 -> 最小 CIDR 集合
|
||||
awk -f "$LIB/range2cidr.awk" "$WORK/$isp.excl" > "$OUT/$isp.txt"
|
||||
|
||||
# 4. 已占用集合累加(需重新合并以保持有序且互不重叠)
|
||||
cat "$WORK/claimed" "$WORK/$isp.excl" |
|
||||
sort |
|
||||
awk -f "$LIB/merge.awk" > "$WORK/claimed.new"
|
||||
mv "$WORK/claimed.new" "$WORK/claimed"
|
||||
|
||||
# 统计
|
||||
n_raw=$(awk -f "$LIB/cidr2range.awk" "$src" | wc -l | tr -d ' ')
|
||||
n_out=$(wc -l < "$OUT/$isp.txt" | tr -d ' ')
|
||||
n_addr=$(awk '{ split($0, p, "/"); t += 2 ^ (32 - (p[2] + 0)) } END { printf "%.0f", t }' "$OUT/$isp.txt")
|
||||
printf '%-16s %8s %8s %14s\n' "$isp" "$n_raw" "$n_out" "$n_addr" >> "$WORK/report"
|
||||
done
|
||||
|
||||
cat "$WORK/report" >&2
|
||||
@@ -0,0 +1,59 @@
|
||||
# CIDR -> 数值区间
|
||||
# 输入:每行一个 CIDR(允许 # 注释、行尾空白)
|
||||
# 输出:每行 "<起始> <结束>",均为零填充的 10 位定宽数字
|
||||
#
|
||||
# 为什么零填充:
|
||||
# OpenWrt 的 busybox sort 对大数执行 sort -n 时并不按数值排序
|
||||
# (实测 3232235520 会排在 134744064 之前,等于没排序)。
|
||||
# 把数值零填充到 10 位后,普通字典序 == 数值序,任何平台都成立。
|
||||
#
|
||||
# 纯算术实现,不使用位运算,兼容 busybox awk / mawk / gawk
|
||||
|
||||
function pad(n, s) {
|
||||
s = sprintf("%.0f", n)
|
||||
while (length(s) < 10)
|
||||
s = "0" s
|
||||
return s
|
||||
}
|
||||
|
||||
{
|
||||
line = $0
|
||||
sub(/#.*/, "", line)
|
||||
gsub(/[ \t\r]/, "", line)
|
||||
if (line == "")
|
||||
next
|
||||
|
||||
n = split(line, p, "/")
|
||||
if (n != 2)
|
||||
next
|
||||
|
||||
alen = split(p[1], q, ".")
|
||||
if (alen != 4)
|
||||
next
|
||||
|
||||
# 前缀必须是纯数字;否则 "abc"/"" 会被 +0 当成 0,单行就吞掉整个 IPv4
|
||||
if (p[2] !~ /^[0-9]+$/)
|
||||
next
|
||||
len = p[2] + 0
|
||||
if (len < 0 || len > 32)
|
||||
next
|
||||
|
||||
bad = 0
|
||||
for (i = 1; i <= 4; i++) {
|
||||
if (q[i] !~ /^[0-9]+$/)
|
||||
bad = 1
|
||||
else if (q[i] + 0 > 255)
|
||||
bad = 1
|
||||
}
|
||||
if (bad)
|
||||
next
|
||||
|
||||
ip = q[1] * 16777216 + q[2] * 65536 + q[3] * 256 + q[4]
|
||||
if (ip < 0 || ip > 4294967295)
|
||||
next
|
||||
|
||||
blk = 2 ^ (32 - len)
|
||||
s = int(ip / blk) * blk
|
||||
e = s + blk - 1
|
||||
print pad(s), pad(e)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# 区间差集:target 减去 claimed
|
||||
# 用法:awk -v claimed=<已占用区间文件> -f exclude.awk < target
|
||||
# claimed:已按字典序排好的互不重叠区间 "<起始> <结束>"(零填充定宽)
|
||||
# target :同样已排序去重叠的区间(stdin)
|
||||
# 输出:target 中未被 claimed 覆盖的部分(零填充、升序、互不重叠)
|
||||
# 目的:让同一个 IP 只归属一个运营商集合,消除归属歧义
|
||||
|
||||
function pad(n, s) {
|
||||
s = sprintf("%.0f", n)
|
||||
while (length(s) < 10)
|
||||
s = "0" s
|
||||
return s
|
||||
}
|
||||
|
||||
BEGIN {
|
||||
if (claimed == "") {
|
||||
print "exclude.awk: 缺少参数 -v claimed=<已占用区间文件>" > "/dev/stderr"
|
||||
exit 2
|
||||
}
|
||||
nc = 0
|
||||
while ((getline l < claimed) > 0) {
|
||||
n = split(l, f, " ")
|
||||
if (n < 2)
|
||||
continue
|
||||
nc++
|
||||
cs[nc] = f[1] + 0
|
||||
ce[nc] = f[2] + 0
|
||||
}
|
||||
close(claimed)
|
||||
ci = 1
|
||||
}
|
||||
{
|
||||
s = $1 + 0
|
||||
e = $2 + 0
|
||||
if (s > e)
|
||||
next
|
||||
|
||||
# 跳过完全位于 s 之前的已占用区间
|
||||
while (ci <= nc && ce[ci] < s)
|
||||
ci++
|
||||
|
||||
cur = ci
|
||||
while (cur <= nc && cs[cur] <= e) {
|
||||
if (cs[cur] > s)
|
||||
print pad(s), pad(cs[cur] - 1)
|
||||
if (ce[cur] + 1 > s)
|
||||
s = ce[cur] + 1
|
||||
if (s > e)
|
||||
break
|
||||
cur++
|
||||
}
|
||||
|
||||
if (s <= e)
|
||||
print pad(s), pad(e)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# 两个运营商集合的三段差异:仅主源 / 仅校验源 / 共同,并计算 Jaccard
|
||||
#
|
||||
# 用法:
|
||||
# awk -v vf=<校验源区间文件> [-v outp=<仅主源区间输出>] [-v outv=<仅校验源区间输出>] \
|
||||
# -f ispdiff.awk <主源区间文件>
|
||||
#
|
||||
# 输入格式:两边的区间文件都必须是「每行 <起始> <结束>,升序、互不重叠」,
|
||||
# 也就是 cidr2range.awk | sort | merge.awk 的输出(零填充十位定宽)。
|
||||
# 输出(stdout 一行六列):
|
||||
# <主源地址数> <校验源地址数> <共同> <仅主> <仅校验> <Jaccard 百分比>
|
||||
#
|
||||
# 为什么用扫描线而不是逐条 CIDR 比对:
|
||||
# 运营商集合动辄上千段,"每段对每段"是 O(n*m);排序后双指针是 O(n+m),
|
||||
# 在 4 千行量级一次 diff 只要几十毫秒,路由器上完全无感。
|
||||
#
|
||||
# 注意:awk 函数里除第一个参数外多写的形参才是局部变量,这里用显式形参
|
||||
# (i/j/k/s/e 都是全局的,只用一次,不影响正确性)。
|
||||
|
||||
BEGIN {
|
||||
nv = 0
|
||||
while ((getline line < vf) > 0) {
|
||||
if (line == "") continue
|
||||
split(line, a, " ")
|
||||
if (a[1] == "" || a[2] == "") continue
|
||||
nv++
|
||||
vs[nv] = a[1] + 0
|
||||
ve[nv] = a[2] + 0
|
||||
}
|
||||
close(vf)
|
||||
np = 0
|
||||
}
|
||||
|
||||
{
|
||||
if ($1 == "" || $2 == "") next
|
||||
np++
|
||||
ps[np] = $1 + 0
|
||||
pe[np] = $2 + 0
|
||||
}
|
||||
|
||||
END {
|
||||
ptot = 0
|
||||
for (i = 1; i <= np; i++) ptot += pe[i] - ps[i] + 1
|
||||
vtot = 0
|
||||
for (i = 1; i <= nv; i++) vtot += ve[i] - vs[i] + 1
|
||||
|
||||
# 交集长度(双指针)
|
||||
common = 0
|
||||
i = 1
|
||||
j = 1
|
||||
while (i <= np && j <= nv) {
|
||||
s = (ps[i] > vs[j]) ? ps[i] : vs[j]
|
||||
e = (pe[i] < ve[j]) ? pe[i] : ve[j]
|
||||
if (s <= e) common += e - s + 1
|
||||
if (pe[i] < ve[j]) i++
|
||||
else if (ve[j] < pe[i]) j++
|
||||
else { i++; j++ }
|
||||
}
|
||||
|
||||
# 仅主源区间(主源减去校验源)
|
||||
if (outp != "") {
|
||||
j = 1
|
||||
for (i = 1; i <= np; i++) {
|
||||
s = ps[i]
|
||||
e = pe[i]
|
||||
while (j <= nv && ve[j] < s) j++
|
||||
k = j
|
||||
while (k <= nv && vs[k] <= e) {
|
||||
if (vs[k] > s) print s " " (vs[k] - 1) > outp
|
||||
if (ve[k] + 1 > s) s = ve[k] + 1
|
||||
if (s > e) break
|
||||
k++
|
||||
}
|
||||
if (s <= e) print s " " e > outp
|
||||
}
|
||||
close(outp)
|
||||
}
|
||||
|
||||
# 仅校验源区间(校验源减去主源)
|
||||
if (outv != "") {
|
||||
i = 1
|
||||
for (j = 1; j <= nv; j++) {
|
||||
s = vs[j]
|
||||
e = ve[j]
|
||||
while (i <= np && pe[i] < s) i++
|
||||
k = i
|
||||
while (k <= np && ps[k] <= e) {
|
||||
if (ps[k] > s) print s " " (ps[k] - 1) > outv
|
||||
if (pe[k] + 1 > s) s = pe[k] + 1
|
||||
if (s > e) break
|
||||
k++
|
||||
}
|
||||
if (s <= e) print s " " e > outv
|
||||
}
|
||||
close(outv)
|
||||
}
|
||||
|
||||
if (ptot + vtot - common > 0)
|
||||
jac = common * 100 / (ptot + vtot - common)
|
||||
else
|
||||
jac = 100
|
||||
printf "%.0f %.0f %.0f %.0f %.0f %.2f\n", ptot, vtot, common, ptot - common, vtot - common, jac
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# 合并重叠/相邻区间
|
||||
# 输入:已按字典序排好的 "<起始> <结束>"(零填充定宽,字典序 == 数值序)
|
||||
# 输出:互不重叠、已合并的区间,同样零填充
|
||||
#
|
||||
# 注意:不能依赖 sort -n —— busybox 的 sort -n 对大数不生效。
|
||||
# 输入已经是零填充格式,普通 sort 即可得到正确数值序。
|
||||
|
||||
function pad(n, s) {
|
||||
s = sprintf("%.0f", n)
|
||||
while (length(s) < 10)
|
||||
s = "0" s
|
||||
return s
|
||||
}
|
||||
|
||||
BEGIN {
|
||||
have = 0
|
||||
}
|
||||
{
|
||||
s = $1 + 0
|
||||
e = $2 + 0
|
||||
if (s > e)
|
||||
next
|
||||
|
||||
if (!have) {
|
||||
cs = s
|
||||
ce = e
|
||||
have = 1
|
||||
next
|
||||
}
|
||||
|
||||
# 与当前合并段重叠或正好相邻
|
||||
if (s <= ce + 1) {
|
||||
if (e > ce)
|
||||
ce = e
|
||||
next
|
||||
}
|
||||
|
||||
print pad(cs), pad(ce)
|
||||
cs = s
|
||||
ce = e
|
||||
}
|
||||
END {
|
||||
if (have)
|
||||
print pad(cs), pad(ce)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# 数值区间 -> 最小 CIDR 集合
|
||||
# 输入:每行 "<起始> <结束>"(升序、互不重叠)
|
||||
# 输出:每行一个 CIDR
|
||||
#
|
||||
# 注意:awk 函数里除第一个参数外,多写的形参才是局部变量。
|
||||
# int2ip(n, a,b,c,d) 中的 a,b,c,d 若不加在形参列表里就会变成全局变量,
|
||||
# 直接污染外层的块大小 b,导致死循环。这是本项目踩过的一个坑。
|
||||
function int2ip(n, a, b, c, d) {
|
||||
a = int(n / 16777216) % 256
|
||||
b = int(n / 65536) % 256
|
||||
c = int(n / 256) % 256
|
||||
d = n % 256
|
||||
return a "." b "." c "." d
|
||||
}
|
||||
|
||||
function log2(x, r) {
|
||||
r = 0
|
||||
while (x > 1) {
|
||||
x = x / 2
|
||||
r++
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
{
|
||||
s = $1 + 0
|
||||
e = $2 + 0
|
||||
if (s > e)
|
||||
next
|
||||
|
||||
while (s <= e) {
|
||||
# 找出以 s 为起点、长度不超过 e 的最大 2 的幂块
|
||||
blk = 1
|
||||
while (s % (blk * 2) == 0 && s + blk * 2 - 1 <= e)
|
||||
blk = blk * 2
|
||||
if (blk < 1)
|
||||
blk = 1
|
||||
|
||||
printf "%s/%d\n", int2ip(s), 32 - log2(blk)
|
||||
s = s + blk
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
# 把运营商分流规则块插入 /etc/config/mwan3 的正确位置
|
||||
#
|
||||
# 为什么不直接 uci add:
|
||||
# mwan3 按配置文件中的顺序渲染 nft 规则链(first-match),而 uci add 只能追加到末尾。
|
||||
# 追加的规则必然排在 default_rule 之后,永远匹配不到 —— 等于白装。
|
||||
#
|
||||
# 用法:
|
||||
# awk -v insert=<规则片段文件> -v anchor=<锚点正则> -f rules-splice.awk /etc/config/mwan3
|
||||
#
|
||||
# 行为:
|
||||
# 1. 只删掉带精确标记 option isp_split '1' 的本工具规则块
|
||||
# (不再按 isp_ 前缀或注释里的 isp_split 字符串判定,避免误删用户规则)
|
||||
# 2. 在第一个匹配 anchor 的 rule 块之前插入 insert 文件内容
|
||||
# 3. 找不到锚点且 insert 非空时,输出警告到 stderr 并以 3 退出,
|
||||
# 绝不静默追加到链尾(追加在 catch-all 之后永远不命中)
|
||||
#
|
||||
# 注意:awk 函数除首参外多写的形参才是局部变量,务必写全,否则污染全局。
|
||||
|
||||
function blocktext(i, e, j, t) {
|
||||
e = (i < nb) ? bs[i + 1] - 1 : NR
|
||||
t = ""
|
||||
# 只拼接块内的 option/list 行。块间注释、空行不能算进前一个块,
|
||||
# 否则一段含 isp_split 字样的注释就会把上面的用户规则误判成我们的。
|
||||
for (j = bs[i]; j <= e; j++)
|
||||
if (line[j] ~ /^[[:space:]]*(option|list)[[:space:]]/)
|
||||
t = t " " line[j]
|
||||
return t
|
||||
}
|
||||
|
||||
function is_ours(i, t) {
|
||||
if (bt[i] != "rule")
|
||||
return 0
|
||||
t = blocktext(i)
|
||||
if (t ~ /option[[:space:]]+isp_split[[:space:]]+['"]1['"]/)
|
||||
return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
function warn_no_anchor() {
|
||||
print "rules-splice.awk: 找不到锚点 '" anchor "',拒绝把规则追加到链尾(inserted=0)" > "/dev/stderr"
|
||||
}
|
||||
|
||||
BEGIN {
|
||||
if (insert == "") {
|
||||
print "rules-splice.awk: 缺少参数 -v insert=<规则片段文件>" > "/dev/stderr"
|
||||
exit 2
|
||||
}
|
||||
# 先读入待插入片段,顺便判断是否为空(卸载时 insert 为空是合法的)
|
||||
ni = 0
|
||||
while ((getline l < insert) > 0)
|
||||
ins[++ni] = l
|
||||
close(insert)
|
||||
}
|
||||
|
||||
{
|
||||
line[NR] = $0
|
||||
if ($1 == "config") {
|
||||
nb++
|
||||
bs[nb] = NR
|
||||
bt[nb] = $2
|
||||
bn[nb] = $3
|
||||
}
|
||||
}
|
||||
|
||||
END {
|
||||
for (i = 1; i <= nb; i++) {
|
||||
gsub(/['\''"]/, "", bt[i])
|
||||
gsub(/['\''"]/, "", bn[i])
|
||||
}
|
||||
|
||||
# 找锚点:第一个不是我们的、且匹配 anchor 的 rule 块
|
||||
aidx = 0
|
||||
for (i = 1; i <= nb && aidx == 0; i++) {
|
||||
if (bt[i] != "rule")
|
||||
continue
|
||||
if (is_ours(i))
|
||||
continue
|
||||
# blocktext 只含 option/list 行;section 名(如 default_rule_v4)
|
||||
# 也要参与锚点匹配,否则默认锚点里的 default_rule 会失效。
|
||||
if (blocktext(i) ~ anchor || bn[i] ~ anchor)
|
||||
aidx = i
|
||||
}
|
||||
|
||||
if (nb == 0) {
|
||||
if (ni > 0) {
|
||||
warn_no_anchor()
|
||||
exit 3
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 文件头(第一个 config 之前的内容)
|
||||
for (j = 1; j < bs[1]; j++)
|
||||
print line[j]
|
||||
|
||||
inserted = 0
|
||||
for (i = 1; i <= nb; i++) {
|
||||
if (is_ours(i))
|
||||
continue
|
||||
if (!inserted && i == aidx) {
|
||||
for (k = 1; k <= ni; k++)
|
||||
print ins[k]
|
||||
inserted = 1
|
||||
}
|
||||
e = (i < nb) ? bs[i + 1] - 1 : NR
|
||||
for (j = bs[i]; j <= e; j++)
|
||||
print line[j]
|
||||
}
|
||||
|
||||
# 没有锚点:insert 为空(卸载)时原样输出;非空时拒绝追加
|
||||
if (!inserted && ni > 0) {
|
||||
warn_no_anchor()
|
||||
exit 3
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,58 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-isp-split
|
||||
PKG_VERSION:=0.2.1
|
||||
PKG_RELEASE:=1
|
||||
PKG_LICENSE:=MIT
|
||||
PKG_LICENSE_FILES:=LICENSE
|
||||
PKG_MAINTAINER:=jihebube <jihebube@users.noreply.github.com>
|
||||
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
|
||||
define Package/luci-app-isp-split
|
||||
SECTION:=luci
|
||||
CATEGORY:=LuCI
|
||||
SUBMENU:=3. Applications
|
||||
TITLE:=LuCI support for ISP-aware policy routing
|
||||
DEPENDS:=+isp-split +luci-base
|
||||
PKGARCH:=all
|
||||
endef
|
||||
|
||||
define Package/luci-app-isp-split/description
|
||||
LuCI page for isp-split: shows per-ISP element counts and hit
|
||||
counters, and offers one-click sync, data refresh, and config
|
||||
editing (wire/apply/dns).
|
||||
endef
|
||||
|
||||
define Build/Compile
|
||||
endef
|
||||
|
||||
define Package/luci-app-isp-split/install
|
||||
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/isp-split
|
||||
$(INSTALL_DIR) $(1)/www/luci-static/resources/isp-split
|
||||
$(INSTALL_DATA) ./htdocs/luci-static/resources/isp-split/isp-split.css \
|
||||
$(1)/www/luci-static/resources/isp-split/isp-split.css
|
||||
$(INSTALL_DIR) $(1)/www/luci-static/resources/isp-split/logos
|
||||
$(INSTALL_DATA) ./htdocs/luci-static/resources/isp-split/logos/*.svg \
|
||||
$(1)/www/luci-static/resources/isp-split/logos/
|
||||
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/isp-split/overview.js \
|
||||
$(1)/www/luci-static/resources/view/isp-split/overview.js
|
||||
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/isp-split/config.js \
|
||||
$(1)/www/luci-static/resources/view/isp-split/config.js
|
||||
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
|
||||
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-isp-split.json \
|
||||
$(1)/usr/share/luci/menu.d/luci-app-isp-split.json
|
||||
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
|
||||
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-isp-split.json \
|
||||
$(1)/usr/share/rpcd/acl.d/luci-app-isp-split.json
|
||||
endef
|
||||
|
||||
define Package/luci-app-isp-split/postinst
|
||||
#!/bin/sh
|
||||
[ -n "$${IPKG_INSTROOT}" ] && exit 0
|
||||
rm -f /tmp/luci-indexcache* 2>/dev/null
|
||||
/etc/init.d/rpcd restart >/dev/null 2>&1
|
||||
exit 0
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,luci-app-isp-split))
|
||||
@@ -0,0 +1,723 @@
|
||||
/*
|
||||
* openwrt-isp-split — LuCI 页面样式(原生 CSS,无框架、无外链)
|
||||
*
|
||||
* 配色原则:
|
||||
* · 背景/边框/文字全部走 LuCI 主题变量(--background-color-*、--border-color-*、
|
||||
* --text-color-*、--primary-color-*),深浅色主题自动跟随;
|
||||
* · 只有"运营商身份色"(电信蓝/联通红/移动绿/教育网紫)是固定色相,
|
||||
* 统一收敛成 --isp-accent-* 变量,主题作者可以在 theme 里覆盖;
|
||||
* · 阴影只用中性黑(透明度很低),两种主题下都不会突兀。
|
||||
*/
|
||||
|
||||
.isp-app {
|
||||
--isp-card-bg: var(--background-color-high, rgba(128, 128, 128, 0.07));
|
||||
--isp-card-bg-2: var(--background-color-medium, rgba(128, 128, 128, 0.12));
|
||||
--isp-border: var(--border-color-low, rgba(128, 128, 128, 0.28));
|
||||
--isp-border-strong: var(--border-color-medium, rgba(128, 128, 128, 0.45));
|
||||
/* 次要文字用 --text-color-medium:原来的 --text-color-low 是主题里的边框/禁用色
|
||||
(浅色 #bfbfbf、深色 #404040),深色主题下正文对比度只有 1.5:1,几乎看不见。 */
|
||||
--isp-muted: var(--text-color-medium, rgba(128, 128, 128, 0.95));
|
||||
--isp-text: var(--text-color-high, inherit);
|
||||
--isp-primary: var(--primary-color-high, currentColor);
|
||||
--isp-ok: var(--success-color-high, currentColor);
|
||||
--isp-warn: var(--warn-color-high, currentColor);
|
||||
--isp-bad: var(--error-color-high, currentColor);
|
||||
|
||||
--isp-accent-tel: #2f6fd0;
|
||||
--isp-accent-uni: #d0362f;
|
||||
--isp-accent-cmcc: #2e9e5b;
|
||||
--isp-accent-cernet: #8a5cd6;
|
||||
--isp-accent-other: #8a8a8a;
|
||||
|
||||
--isp-radius: 14px;
|
||||
--isp-shadow: 0 1px 2px rgba(0, 0, 0, 0.10), 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.isp-app * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 头部 */
|
||||
|
||||
.isp-hero {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 14px 0;
|
||||
}
|
||||
|
||||
.isp-hero h2 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
color: var(--isp-text);
|
||||
}
|
||||
|
||||
.isp-hero .isp-sub {
|
||||
flex: 1 1 260px;
|
||||
min-width: 200px;
|
||||
color: var(--isp-muted);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.isp-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.isp-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--isp-card-bg-2);
|
||||
border: 1px solid var(--isp-border);
|
||||
font-size: 0.78rem;
|
||||
color: var(--isp-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.isp-pill .isp-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--isp-ok);
|
||||
}
|
||||
|
||||
.isp-pill.is-paused .isp-dot {
|
||||
background: var(--isp-warn);
|
||||
}
|
||||
|
||||
/* 刷新中:徽标转中文「刷新中…」并让图标转圈,替代主题自带的英文 Refreshing 提示 */
|
||||
.isp-pill.is-busy {
|
||||
color: var(--isp-text);
|
||||
border-color: var(--isp-border-strong);
|
||||
}
|
||||
|
||||
.isp-pill .isp-ico {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.isp-spin {
|
||||
display: inline-flex;
|
||||
animation: isp-rotate 0.9s linear infinite;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
@keyframes isp-rotate {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.isp-spin {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 卡片网格 */
|
||||
|
||||
.isp-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.isp-grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.isp-grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.isp-card {
|
||||
position: relative;
|
||||
background: var(--isp-card-bg);
|
||||
border: 1px solid var(--isp-border);
|
||||
border-radius: var(--isp-radius);
|
||||
box-shadow: var(--isp-shadow);
|
||||
padding: 14px 16px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.isp-card > h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--isp-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 运营商卡片:顶部身份色条 */
|
||||
.isp-card-op {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.isp-card-op::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 4px;
|
||||
background: var(--isp-accent, var(--isp-accent-other));
|
||||
}
|
||||
|
||||
.isp-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.isp-op-name {
|
||||
font-size: 0.98rem;
|
||||
font-weight: 600;
|
||||
color: var(--isp-text);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.isp-op-ico {
|
||||
display: inline-flex;
|
||||
color: var(--isp-accent, var(--isp-accent-other));
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.isp-big {
|
||||
display: inline-block;
|
||||
font-size: 1.9rem;
|
||||
line-height: 1.15;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--isp-text);
|
||||
}
|
||||
|
||||
.isp-unit {
|
||||
margin-left: 6px;
|
||||
font-size: 0.76rem;
|
||||
color: var(--isp-muted);
|
||||
}
|
||||
|
||||
.isp-op-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--isp-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.isp-route {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--isp-muted);
|
||||
}
|
||||
|
||||
.isp-route .isp-arrows {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
color: var(--isp-accent, var(--isp-accent-other));
|
||||
}
|
||||
|
||||
.isp-meter {
|
||||
margin-top: 10px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--isp-card-bg-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.isp-meter > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--isp-accent, var(--isp-accent-other));
|
||||
opacity: 0.85;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* 零流量卡片:不画进度条(否则 0% 只剩一个孤点),改成一行占位「—」 */
|
||||
.isp-meter-empty {
|
||||
margin-top: 10px;
|
||||
height: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
color: var(--isp-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 列表 / 表格 */
|
||||
|
||||
.isp-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.isp-line {
|
||||
display: grid;
|
||||
grid-template-columns: 10px minmax(64px, auto) 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: var(--isp-card-bg-2);
|
||||
border: 1px solid transparent;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.isp-line .isp-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--isp-muted);
|
||||
}
|
||||
|
||||
.isp-line.is-online .isp-dot {
|
||||
background: var(--isp-ok);
|
||||
box-shadow: 0 0 0 3px rgba(46, 158, 91, 0.18);
|
||||
}
|
||||
|
||||
.isp-line.is-offline .isp-dot {
|
||||
background: var(--isp-bad);
|
||||
}
|
||||
|
||||
.isp-line .isp-line-name {
|
||||
font-weight: 600;
|
||||
color: var(--isp-text);
|
||||
font-size: 0.88rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.isp-line .isp-line-track {
|
||||
font-size: 0.78rem;
|
||||
color: var(--isp-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.isp-line .isp-line-pct {
|
||||
font-size: 0.78rem;
|
||||
color: var(--isp-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.isp-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.isp-table th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: var(--isp-muted);
|
||||
padding: 4px 8px 6px 0;
|
||||
border-bottom: 1px solid var(--isp-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.isp-table td {
|
||||
padding: 6px 8px 6px 0;
|
||||
border-bottom: 1px solid var(--isp-border);
|
||||
color: var(--isp-text);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.isp-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.isp-table .num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.isp-tag {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
background: var(--isp-card-bg-2);
|
||||
color: var(--isp-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 长的说明性标签(如出线方式解释)允许换行,避免窄屏把页面撑宽。
|
||||
圆角从 999px 收敛成 8px:胶囊形的半圆端在换行后会把首尾字符压在弧线上,
|
||||
看起来像被截断;直角一点的说明条才容得下两行完整文字。 */
|
||||
.isp-btn-row .isp-tag {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
max-width: 100%;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.isp-tag.is-ok {
|
||||
color: var(--isp-ok);
|
||||
}
|
||||
|
||||
.isp-tag.is-warn {
|
||||
color: var(--isp-warn);
|
||||
}
|
||||
|
||||
.isp-tag.is-bad {
|
||||
color: var(--isp-bad);
|
||||
}
|
||||
|
||||
.isp-empty {
|
||||
color: var(--isp-muted);
|
||||
font-size: 0.84rem;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.isp-time {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 18px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--isp-muted);
|
||||
}
|
||||
|
||||
.isp-time .isp-tag {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.isp-time b {
|
||||
color: var(--isp-text);
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 操作区 */
|
||||
|
||||
.isp-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 4px 0 10px 0;
|
||||
}
|
||||
|
||||
.isp-actions .cbi-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.isp-ico {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.isp-out {
|
||||
max-height: 22em;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--isp-border);
|
||||
background: var(--isp-card-bg);
|
||||
color: var(--isp-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 没有输出时不留空盒子:hidden 属性必须真的生效(主题里有 pre{display:block},
|
||||
单靠 hidden 会被作者样式盖掉,只剩一个 22px 高的空边框)。 */
|
||||
.isp-out[hidden],
|
||||
.isp-out.is-empty {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 表单(配置页) */
|
||||
|
||||
.isp-section {
|
||||
background: var(--isp-card-bg);
|
||||
border: 1px solid var(--isp-border);
|
||||
border-radius: var(--isp-radius);
|
||||
box-shadow: var(--isp-shadow);
|
||||
padding: 14px 16px 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.isp-section > h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--isp-text);
|
||||
}
|
||||
|
||||
.isp-section > .isp-desc {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: var(--isp-muted);
|
||||
}
|
||||
|
||||
.isp-form-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.isp-form-table th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: var(--isp-muted);
|
||||
padding: 0 8px 6px 0;
|
||||
border-bottom: 1px solid var(--isp-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.isp-form-table td {
|
||||
padding: 6px 8px 6px 0;
|
||||
border-bottom: 1px solid var(--isp-border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.isp-form-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.isp-form-table input[type="text"],
|
||||
.isp-form-table select {
|
||||
width: 100%;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.isp-err {
|
||||
display: none;
|
||||
margin-top: 3px;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.35;
|
||||
color: var(--isp-bad);
|
||||
}
|
||||
|
||||
.isp-err.is-on {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.isp-row-actions {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.isp-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--isp-border);
|
||||
background: var(--isp-card-bg-2);
|
||||
color: var(--isp-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.isp-icon-btn:hover:not(:disabled) {
|
||||
border-color: var(--isp-border-strong);
|
||||
}
|
||||
|
||||
.isp-icon-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.isp-icon-btn.is-danger {
|
||||
color: var(--isp-bad);
|
||||
}
|
||||
|
||||
.isp-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.isp-switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.isp-switch span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--isp-card-bg-2);
|
||||
border: 1px solid var(--isp-border);
|
||||
transition: background 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.isp-switch span::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--isp-muted);
|
||||
transition: transform 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.isp-switch input:checked + span {
|
||||
background: var(--isp-primary);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.isp-switch input:checked + span::after {
|
||||
transform: translateX(18px);
|
||||
background: var(--background-color-high, #fff);
|
||||
}
|
||||
|
||||
.isp-btn-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* 表单下方的整段说明(如「出线方式」的解释):允许换行,不加胶囊边框 */
|
||||
.isp-note {
|
||||
margin-top: 8px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
color: var(--isp-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.isp-test-result {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
color: var(--isp-muted);
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.isp-test-result.is-ok {
|
||||
color: var(--isp-ok);
|
||||
}
|
||||
|
||||
.isp-test-result.is-bad {
|
||||
color: var(--isp-bad);
|
||||
}
|
||||
|
||||
.isp-footer-actions {
|
||||
/* 曾经是 position:sticky; bottom:0 —— 会浮在视口底部盖住 DNS 表最后两行。
|
||||
保存按钮放在正常文档流里即可,页脚用一条上边框跟内容分开。 */
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 4px 0 8px 0;
|
||||
padding: 12px 0 0 0;
|
||||
border-top: 1px solid var(--isp-border);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 响应式 */
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.isp-cards {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.isp-grid-3 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.isp-grid-2,
|
||||
.isp-grid-3 {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.isp-cards {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.isp-big {
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
|
||||
.isp-line {
|
||||
grid-template-columns: 10px 1fr;
|
||||
row-gap: 2px;
|
||||
}
|
||||
|
||||
.isp-line .isp-line-pct {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
/* 表单表格在窄屏改成两块:上半行是标签,下半行是输入 */
|
||||
.isp-form-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.isp-form-table tr {
|
||||
display: block;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--isp-border);
|
||||
}
|
||||
|
||||
.isp-form-table td {
|
||||
display: block;
|
||||
border: none;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.isp-form-table td::before {
|
||||
content: attr(data-th);
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--isp-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="715" viewBox="-29.771 0 5703 1554" width="2500"><g fill="#02489d"><path d="m1014.229 261s-28-31-65-31c0 0-81 0-136 175 0 0-47 153-47 380 0 0 0 214 44 365 0 0 44 153 108 153 0 0 45 0 83-84 0 0 38-84 54-221l120-30s-31 255-127 420c0 0-99 166-224 166 0 0-150 0-256-225 0 0-106-225-106-544 0 0 0-319 106-546 0 0 106-226 256-226 0 0 150 0 257 230z"/><g><path d="M1436.229 568s-207 171 -537 254c0 0 -331 82 -594 28c0 0 -263 -56 -302 -217c0 0 -33 -134 112 -282c0 0 143 -147 400 -245l17 66s-150 64 -233 145c0 0 -85 85 -66 158c0 0 25 93 201 121c0 0 175 27 402 -29c0 0 225 -56 369 -163c0 0 142 -108 118 -202c0 0 -20 -81 -164 -116l-22 -86s187 7 311 69c0 0 127 62 153 167c0 0 41 160 -165 332"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 743 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" viewBox="0 0 192.756 192.756"><g fill-rule="evenodd" clip-rule="evenodd"><g><path d="M59.293 54.077c0 -20.53 16.605 -37.136 37.135 -37.136c20.429 0 37.136 16.605 37.136 37.136c0 20.429 -16.707 37.035 -37.136 37.035c-20.53 -0.001 -37.135 -16.606 -37.135 -37.035zM75.596 47.434l3.724 3.02l15.397 -12.982c1.409 -1.107 3.22 -1.711 5.132 -1.711s3.725 0.705 5.234 1.812l19.422 15.8v3.623l-17.209 -13.888c-1.309 -1.007 -2.816 -1.51 -4.529 -1.51c-1.709 0 -3.32 0.604 -4.629 1.61l-0.201 0.101l-0.1 0.101c-1.611 1.409 -9.561 8.051 -13.587 11.372h0.101c0.805 1.308 2.918 1.51 4.83 0.402h0.101c4.328 -3.724 8.252 -7.044 8.655 -7.447c1.209 -1.006 2.717 -1.711 4.328 -1.711c1.609 0 3.02 0.604 4.125 1.51L118.769 57.6l3.623 3.019l2.113 1.711l-0.402 0.101l0.402 0.402s-16.404 13.989 -20.529 17.511c-2.014 2.114 -4.932 3.422 -8.152 3.422c-2.919 0 -5.736 -1.107 -7.75 -3.019L67.646 64.14v-5.133l20.329 16.505c1.409 1.006 3.019 1.61 4.931 1.61s3.724 -0.705 5.133 -1.812c0.805 -0.705 13.082 -11.171 18.617 -15.8l-3.623 -3.019l-15.498 13.084c-1.509 1.006 -3.22 1.61 -5.132 1.61c-2.013 0 -3.824 -0.604 -5.334 -1.812l-19.423 -15.8V49.95l17.31 13.989c1.208 0.906 2.717 1.51 4.428 1.51c1.812 0 3.422 -0.604 4.63 -1.61l0.201 -0.201l0.101 -0.101c1.711 -1.409 9.56 -7.951 13.585 -11.373h-0.1v-0.101l-1.006 -0.705c-1.008 -0.503 -2.416 -0.302 -3.725 0.403c-0.1 0 -0.1 0.101 -0.1 0c-4.328 3.724 -8.354 7.044 -8.756 7.447c-1.107 1.107 -2.717 1.711 -4.327 1.711c-1.51 0 -2.919 -0.503 -4.126 -1.409L73.483 49.246l-0.101 0.101l-3.522 -2.918l-1.007 -1.107l-1.207 -1.006h0.201l-0.201 -0.201s16.404 -13.989 20.53 -17.511c2.113 -2.113 4.931 -3.422 8.151 -3.422c3.02 0 5.737 1.208 7.749 3.02l20.43 16.706v5.132l-20.328 -16.605c-1.41 -1.006 -3.02 -1.61 -4.83 -1.61a8.446 8.446 0 0 0 -5.234 1.811c-0.805 0.702 -12.983 11.169 -18.518 15.798z" fill="#2767b0"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="1367" viewBox="0.122 228.756 611.755 334.503"><path fill="#D91A21" d="M380.786 389.207l20.073-19.89-15.851-15.729 15.851-15.974-20.073-19.706-15.79 15.667-10.465-10.465c-16.646-16.708-44.003-16.708-60.71 0-8.446 8.384-12.607 19.461-12.607 30.478 0 10.894 4.101 21.909 12.607 30.355 16.707 16.769 44.063 16.769 60.71 0a1481.013 1481.013 0 0 0 10.465-10.466l15.79 15.73zm-46.022-25.337c-5.875 5.875-15.239 5.875-21.053 0-2.815-2.815-4.223-6.671-4.223-10.343a15.23 15.23 0 0 1 4.223-10.526c5.813-5.813 15.178-5.813 21.053 0 4.59 4.651 10.404 10.526 10.404 10.526s-5.814 5.631-10.404 10.343zm264.567-40.82c-16.708-16.708-44.003-16.708-60.771 0-4.713 4.835-10.465 10.465-10.465 10.465l-15.852-15.728-19.89 19.706 15.789 15.973-15.789 15.729 19.89 19.891 15.79-15.79s5.753 5.691 10.465 10.466c16.77 16.769 44.064 16.769 60.771 0 8.385-8.446 12.607-19.462 12.607-30.355s-4.16-21.973-12.545-30.357zm-19.951 40.82c-5.753 5.875-15.116 5.875-20.931 0-4.712-4.712-10.465-10.343-10.465-10.343s5.753-5.875 10.465-10.526c5.814-5.813 15.178-5.813 20.931 0 2.876 2.877 4.284 6.671 4.284 10.526 0 3.672-1.346 7.527-4.284 10.343zm-112.73-10.343s46.695-46.757 51.286-51.408c16.829-16.769 16.829-43.941 0-60.832-16.708-16.708-43.881-16.708-60.772 0-6.548 6.731-10.403 10.526-10.403 10.526s-3.795-3.795-10.404-10.526c-16.83-16.708-44.003-16.708-60.833 0-16.769 16.891-16.769 44.063 0 60.832l51.408 51.408s-46.757 46.573-51.408 51.286c-16.769 16.769-16.769 44.003 0 60.833 16.83 16.707 44.003 16.707 60.833 0 6.609-6.732 10.404-10.649 10.404-10.649s3.855 3.917 10.403 10.649c16.892 16.707 44.064 16.707 60.772 0 16.829-16.83 16.829-44.064 0-60.833-4.591-4.713-51.286-51.286-51.286-51.286zm-71.237-71.298c-5.692-5.875-5.692-15.116 0-20.931 5.813-5.691 15.238-5.691 20.93 0l10.526 10.526-15.851 15.729 19.829 19.951 15.851-15.851 15.851 15.851 19.829-19.951-15.79-15.729s4.896-4.957 10.527-10.526c5.752-5.691 15.116-5.691 20.869 0 5.813 5.814 5.813 15.056 0 20.931-6.06 6.059-51.47 51.592-51.47 51.592s-45.165-45.533-51.101-51.592zm102.694 163.466c-5.753 5.691-15.116 5.691-20.869 0-5.631-5.569-10.526-10.466-10.526-10.466l15.789-15.851-19.828-19.891-15.912 15.79-15.852-15.851-19.828 19.89 15.851 15.852s-4.957 4.896-10.526 10.465c-5.691 5.691-15.116 5.691-20.931 0-5.691-5.753-5.691-15.239 0-20.931 5.937-6.059 51.225-51.592 51.225-51.592s45.41 45.533 51.469 51.592c5.751 5.692 5.751 15.24-.062 20.993z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,862 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require fs';
|
||||
'require ui';
|
||||
|
||||
/*
|
||||
* LuCI 配置页:策略映射 / 多线路 DNS / 域名分流
|
||||
*
|
||||
* 约定:
|
||||
* · 所有文件解析与改写仍由 `isp-split conf` 在 shell 侧完成(带白名单校验、
|
||||
* 回读校验、失败回滚),前端只负责收集值;
|
||||
* · 前端做一遍同样的校验,错误直接标在出错的输入框下面(红字),
|
||||
* 不必等后端返回;
|
||||
* · `conf preview` 只把改动写进临时副本再跑 plan,"保存前预检"不会碰
|
||||
* /etc/isp-split.conf。
|
||||
*/
|
||||
|
||||
var CMD = '/usr/sbin/isp-split';
|
||||
|
||||
/* ---------------------------------------------------------------- 图标(内联 SVG) */
|
||||
|
||||
var ICONS = {
|
||||
refresh: '<path d="M20 11a8 8 0 1 0-2.3 6.3"/><path d="M20 4v7h-7"/>',
|
||||
check: '<path d="M4 12.5 9 17.5 20 6.5"/>',
|
||||
plus: '<path d="M12 5v14"/><path d="M5 12h14"/>',
|
||||
up: '<path d="M12 19V5"/><path d="M6 11l6-6 6 6"/>',
|
||||
down: '<path d="M12 5v14"/><path d="M6 13l6 6 6-6"/>',
|
||||
trash: '<path d="M4 7h16"/><path d="M9 7V4h6v3"/><path d="M6 7l1 13h10l1-13"/>',
|
||||
plug: '<path d="M9 3v6"/><path d="M15 3v6"/><path d="M6 9h12v3a6 6 0 0 1-12 0z"/><path d="M12 18v3"/>',
|
||||
save: '<path d="M5 4h11l3 3v13H5z"/><path d="M8 4v6h8V4"/><path d="M8 20v-6h8v6"/>'
|
||||
};
|
||||
|
||||
function svgIcon(name, size) {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'isp-ico';
|
||||
span.innerHTML = '<svg viewBox="0 0 24 24" width="' + (size || 16) + '" height="' + (size || 16) +
|
||||
'" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" ' +
|
||||
'stroke-linejoin="round" aria-hidden="true">' + (ICONS[name] || ICONS.check) + '</svg>';
|
||||
return span;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 工具 */
|
||||
|
||||
function run(args) {
|
||||
return fs.exec(CMD, args).then(function(res) {
|
||||
// fs.exec 不管退出码都会 resolve;非 0 必须变成 reject,
|
||||
// 否则校验失败也会被 UI 当成"已保存"。
|
||||
if (res && res.code) {
|
||||
var out = ((res.stdout || '') + (res.stderr || '')).trim();
|
||||
throw new Error('isp-split ' + args.join(' ') +
|
||||
' 失败(退出码 ' + res.code + ')' + (out ? ':' + out : ''));
|
||||
}
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
/* 行内错误提示:同一个 td 里紧跟输入框的一行红字 */
|
||||
function errBox() {
|
||||
return E('div', { 'class': 'isp-err' });
|
||||
}
|
||||
|
||||
function setErr(box, msg) {
|
||||
if (!box)
|
||||
return;
|
||||
if (msg) {
|
||||
box.textContent = msg;
|
||||
box.classList.add('is-on');
|
||||
} else {
|
||||
box.textContent = '';
|
||||
box.classList.remove('is-on');
|
||||
}
|
||||
}
|
||||
|
||||
function isIPv4(s) {
|
||||
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(s))
|
||||
return false;
|
||||
var p = s.split('.');
|
||||
for (var i = 0; i < p.length; i++) {
|
||||
if (+p[i] > 255)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isDomain(s) {
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(s))
|
||||
return false;
|
||||
if (s.charAt(0) === '.' || s.charAt(s.length - 1) === '.')
|
||||
return false;
|
||||
return s.indexOf('..') < 0;
|
||||
}
|
||||
|
||||
function isSrc(s) {
|
||||
return /^[A-Za-z0-9._-]+$/.test(s);
|
||||
}
|
||||
|
||||
function isSetName(s) {
|
||||
return /^[A-Za-z0-9_]{1,15}$/.test(s);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 视图 */
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
/* 配置字段走 conf get;数据源健康度走 health --json。
|
||||
health 失败不能拖垮整个配置页:捕获后渲染成“读取失败”。 */
|
||||
return Promise.all([
|
||||
run(['conf', 'get']),
|
||||
fs.exec(CMD, ['health', '--json']).catch(function() { return null; })
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(res) {
|
||||
var self = this;
|
||||
var out = (res && res[0] && res[0].stdout) || '';
|
||||
var health = null;
|
||||
try {
|
||||
health = JSON.parse(((res[1] && res[1].stdout) || '').trim() || 'null');
|
||||
} catch (e) {
|
||||
health = null;
|
||||
}
|
||||
|
||||
var model = {
|
||||
mode: 'bind',
|
||||
policies: [],
|
||||
isp: [],
|
||||
dns: [],
|
||||
domains: []
|
||||
};
|
||||
|
||||
out.split('\n').forEach(function(line) {
|
||||
if (!line)
|
||||
return;
|
||||
var f = line.split('\t');
|
||||
if (f[0] === 'dns_mode')
|
||||
model.mode = (f[1] || 'bind').trim();
|
||||
else if (f[0] === 'policies')
|
||||
model.policies = (f[1] || '').trim().split(/\s+/).filter(Boolean);
|
||||
else if (f[0] === 'isp' && f.length >= 5)
|
||||
model.isp.push({ src: f[1], set: f[2], policy: f[3], enabled: f[4] === '1' });
|
||||
else if (f[0] === 'dns' && f.length >= 3)
|
||||
model.dns.push({ policy: f[1], dns: f[2], test: null });
|
||||
else if (f[0] === 'domain' && f.length >= 3)
|
||||
model.domains.push({ domain: f[1], policy: f[2] });
|
||||
});
|
||||
|
||||
if (!model.isp.length) {
|
||||
return E('div', { 'class': 'isp-app cbi-map' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('isp-split/isp-split.css') }),
|
||||
E('h2', {}, [ _('运营商分流 · 配置') ]),
|
||||
E('div', { 'class': 'cbi-map-descr' }, [
|
||||
_('读不到 /etc/isp-split.conf 的 ISP_TABLE,请先确认已安装并执行过 isp-split fetch。')
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
var result = E('pre', { 'id': 'cfg-out', 'class': 'isp-out is-empty', 'hidden': '' }, ['']);
|
||||
|
||||
/* 没有输出时保持 hidden:主题的 pre{display:block} 会盖掉 hidden 属性,
|
||||
CSS 里配了 .isp-out[hidden]{display:none!important},这里只管切换属性。 */
|
||||
function showResult(text) {
|
||||
if (text) {
|
||||
result.textContent = text;
|
||||
result.removeAttribute('hidden');
|
||||
result.classList.remove('is-empty');
|
||||
}
|
||||
else {
|
||||
result.textContent = '';
|
||||
result.setAttribute('hidden', '');
|
||||
result.classList.add('is-empty');
|
||||
}
|
||||
result.scrollTop = 0;
|
||||
}
|
||||
|
||||
/* ============================================================ 一、策略映射 */
|
||||
|
||||
var ispTbody = E('tbody', {});
|
||||
|
||||
function renderIsp() {
|
||||
while (ispTbody.firstChild)
|
||||
ispTbody.removeChild(ispTbody.firstChild);
|
||||
|
||||
model.isp.forEach(function(row, idx) {
|
||||
row._err = row._err || {};
|
||||
|
||||
var srcInp = E('input', {
|
||||
'type': 'text', 'class': 'cbi-input-text', 'value': row.src,
|
||||
'placeholder': '源名,如 chinatelecom'
|
||||
});
|
||||
srcInp.addEventListener('input', function() {
|
||||
row.src = srcInp.value.trim();
|
||||
validateIspRow(row);
|
||||
});
|
||||
|
||||
var setInp = E('input', {
|
||||
'type': 'text', 'class': 'cbi-input-text', 'value': row.set,
|
||||
'placeholder': '集合名,<=15 字符'
|
||||
});
|
||||
setInp.addEventListener('input', function() {
|
||||
row.set = setInp.value.trim();
|
||||
validateIspRow(row);
|
||||
});
|
||||
|
||||
var polSel = E('select', { 'class': 'cbi-input-select' },
|
||||
model.policies.map(function(p) {
|
||||
return E('option', { 'value': p, 'selected': p === row.policy ? '' : null }, [ p ]);
|
||||
}));
|
||||
if (!model.policies.length)
|
||||
polSel.appendChild(E('option', { 'value': '' }, [ _('(mwan3 里没有策略)') ]));
|
||||
polSel.addEventListener('change', function() {
|
||||
row.policy = polSel.value;
|
||||
validateIspRow(row);
|
||||
});
|
||||
|
||||
var chk = E('input', { 'type': 'checkbox', 'checked': row.enabled ? '' : null });
|
||||
chk.addEventListener('change', function() { row.enabled = chk.checked; });
|
||||
|
||||
var up = E('button', {
|
||||
'class': 'isp-icon-btn', 'title': _('上移(提高优先级)'),
|
||||
'disabled': idx === 0 ? '' : null,
|
||||
'click': function() { moveIsp(idx, -1); }
|
||||
}, [ svgIcon('up', 15) ]);
|
||||
|
||||
var down = E('button', {
|
||||
'class': 'isp-icon-btn', 'title': _('下移(降低优先级)'),
|
||||
'disabled': idx === model.isp.length - 1 ? '' : null,
|
||||
'click': function() { moveIsp(idx, 1); }
|
||||
}, [ svgIcon('down', 15) ]);
|
||||
|
||||
var tr = E('tr', {}, [
|
||||
E('td', { 'data-th': _('运营商') }, [ srcInp, row._err.src = errBox() ]),
|
||||
E('td', { 'data-th': _('集合') }, [ setInp, row._err.set = errBox() ]),
|
||||
E('td', { 'data-th': _('策略') }, [ polSel, row._err.policy = errBox() ]),
|
||||
E('td', { 'data-th': _('启用') }, [
|
||||
E('label', { 'class': 'isp-switch' }, [
|
||||
chk,
|
||||
E('span')
|
||||
])
|
||||
]),
|
||||
E('td', { 'data-th': _('顺序') }, [
|
||||
E('div', { 'class': 'isp-row-actions' }, [ up, down ])
|
||||
])
|
||||
]);
|
||||
ispTbody.appendChild(tr);
|
||||
validateIspRow(row);
|
||||
});
|
||||
}
|
||||
|
||||
function moveIsp(idx, delta) {
|
||||
var to = idx + delta;
|
||||
if (to < 0 || to >= model.isp.length)
|
||||
return;
|
||||
var row = model.isp.splice(idx, 1)[0];
|
||||
model.isp.splice(to, 0, row);
|
||||
renderIsp();
|
||||
}
|
||||
|
||||
function validateIspRow(row) {
|
||||
var err = row._err || {};
|
||||
var ok = true;
|
||||
|
||||
if (!isSrc(row.src || '')) {
|
||||
setErr(err.src, _('只能包含字母、数字、点、下划线、连字符'));
|
||||
ok = false;
|
||||
} else {
|
||||
setErr(err.src, '');
|
||||
}
|
||||
|
||||
if (!isSetName(row.set || '')) {
|
||||
setErr(err.set, _('1-15 个字符,只能是字母/数字/下划线'));
|
||||
ok = false;
|
||||
} else {
|
||||
var dup = model.isp.filter(function(r) { return r !== row && r.set === row.set; }).length;
|
||||
if (dup) {
|
||||
setErr(err.set, _('集合名重复'));
|
||||
ok = false;
|
||||
} else {
|
||||
setErr(err.set, '');
|
||||
}
|
||||
}
|
||||
|
||||
if (!row.policy || (model.policies.length && model.policies.indexOf(row.policy) < 0)) {
|
||||
setErr(err.policy, _('mwan3 里没有这个策略'));
|
||||
ok = false;
|
||||
} else {
|
||||
setErr(err.policy, '');
|
||||
}
|
||||
|
||||
row._valid = ok;
|
||||
return ok;
|
||||
}
|
||||
|
||||
renderIsp();
|
||||
|
||||
var addIspBtn = E('button', {
|
||||
'class': 'cbi-button cbi-button-neutral',
|
||||
'click': function() {
|
||||
model.isp.push({
|
||||
src: '', set: '', policy: model.policies[0] || '', enabled: true
|
||||
});
|
||||
renderIsp();
|
||||
}
|
||||
}, [ svgIcon('plus'), E('span', {}, [_('添加运营商')]) ]);
|
||||
|
||||
/* ============================================================ 二、多线路 DNS */
|
||||
|
||||
var dnsTbody = E('tbody', {});
|
||||
|
||||
/* DNS 行 = mwan3 策略 ∪ DNS_TABLE 里已有但 mwan3 没列出的策略 */
|
||||
var dnsPolicies = model.policies.slice();
|
||||
model.dns.forEach(function(r) {
|
||||
if (dnsPolicies.indexOf(r.policy) < 0)
|
||||
dnsPolicies.push(r.policy);
|
||||
});
|
||||
dnsPolicies.forEach(function(p) {
|
||||
var exists = null;
|
||||
model.dns.forEach(function(r) { if (r.policy === p) exists = r; });
|
||||
if (!exists)
|
||||
model.dns.push({ policy: p, dns: '', test: null });
|
||||
});
|
||||
|
||||
function renderDns() {
|
||||
while (dnsTbody.firstChild)
|
||||
dnsTbody.removeChild(dnsTbody.firstChild);
|
||||
|
||||
model.dns.forEach(function(row) {
|
||||
row._err = errBox();
|
||||
|
||||
var inp = E('input', {
|
||||
'type': 'text', 'class': 'cbi-input-text',
|
||||
'placeholder': _('该线路使用的 DNS,如 223.5.5.5'),
|
||||
'value': row.dns
|
||||
});
|
||||
inp.addEventListener('input', function() {
|
||||
row.dns = inp.value.trim();
|
||||
validateDnsRow(row);
|
||||
});
|
||||
|
||||
var resSpan = E('div', { 'class': 'isp-test-result' }, [
|
||||
row.test ? row.test.text : ''
|
||||
]);
|
||||
if (row.test)
|
||||
resSpan.className = 'isp-test-result ' + (row.test.ok ? 'is-ok' : 'is-bad');
|
||||
|
||||
var testBtn = E('button', {
|
||||
'class': 'cbi-button cbi-button-neutral',
|
||||
'title': _('向该线路的 DNS 发一次查询'),
|
||||
'click': function() {
|
||||
resSpan.className = 'isp-test-result';
|
||||
resSpan.textContent = _('测试中…');
|
||||
/* 这里不能用 run():DNS 不可用时命令返回 1,但 JSON 仍然有效 */
|
||||
return fs.exec(CMD, ['dnstest', row.policy, '--json']).then(function(r) {
|
||||
var d = null;
|
||||
try {
|
||||
d = JSON.parse((r.stdout || '').trim() || 'null');
|
||||
} catch (e) {
|
||||
d = null;
|
||||
}
|
||||
if (d && d.ok) {
|
||||
resSpan.className = 'isp-test-result is-ok';
|
||||
resSpan.textContent = _('可用') + '(' + (d.device || '?') + ' → ' +
|
||||
(d.answer || '?') + ')';
|
||||
row.test = { ok: true, text: resSpan.textContent };
|
||||
} else {
|
||||
resSpan.className = 'isp-test-result is-bad';
|
||||
resSpan.textContent = _('不可用(DNS 无响应)');
|
||||
row.test = { ok: false, text: resSpan.textContent };
|
||||
}
|
||||
}).catch(function(e) {
|
||||
resSpan.className = 'isp-test-result is-bad';
|
||||
resSpan.textContent = _('测线失败:') + String(e).replace(/^.*失败([^)]*):/, '');
|
||||
});
|
||||
}
|
||||
}, [ svgIcon('plug'), E('span', {}, [_('测线')]) ]);
|
||||
|
||||
dnsTbody.appendChild(E('tr', {}, [
|
||||
E('td', { 'data-th': _('线路(策略)') }, [
|
||||
E('span', { 'class': 'isp-line-name' }, [ row.policy ]),
|
||||
row._err
|
||||
]),
|
||||
E('td', { 'data-th': _('DNS') }, [ inp ]),
|
||||
E('td', { 'data-th': _('测线') }, [
|
||||
E('div', { 'class': 'isp-row-actions' }, [ testBtn ]),
|
||||
resSpan
|
||||
])
|
||||
]));
|
||||
validateDnsRow(row);
|
||||
});
|
||||
}
|
||||
|
||||
function validateDnsRow(row) {
|
||||
if (!row.dns) {
|
||||
setErr(row._err, '');
|
||||
row._valid = true;
|
||||
return true;
|
||||
}
|
||||
if (!isIPv4(row.dns)) {
|
||||
setErr(row._err, _('必须是 IPv4 地址,如 223.5.5.5'));
|
||||
row._valid = false;
|
||||
return false;
|
||||
}
|
||||
setErr(row._err, '');
|
||||
row._valid = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
renderDns();
|
||||
|
||||
var modeSel = E('select', { 'class': 'cbi-input-select' }, [
|
||||
E('option', { 'value': 'bind', 'selected': model.mode === 'bind' ? '' : null },
|
||||
[_('bind · dnsmasq 绑定出接口')]),
|
||||
E('option', { 'value': 'mark', 'selected': model.mode === 'mark' ? '' : null },
|
||||
[_('mark · nft 按目标 DNS 打标')])
|
||||
]);
|
||||
modeSel.addEventListener('change', function() { model.mode = modeSel.value; });
|
||||
|
||||
/* ============================================================ 三、域名分流 */
|
||||
|
||||
var domTbody = E('tbody', {});
|
||||
|
||||
function renderDomains() {
|
||||
while (domTbody.firstChild)
|
||||
domTbody.removeChild(domTbody.firstChild);
|
||||
|
||||
if (!model.domains.length) {
|
||||
domTbody.appendChild(E('tr', {}, [
|
||||
E('td', { 'colspan': 3, 'data-th': '' }, [
|
||||
E('div', { 'class': 'isp-empty' }, [
|
||||
_('尚未配置。大多数域名不需要在这里出现——只有 CDN 调度不准的站点才需要钉线。')
|
||||
])
|
||||
])
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
model.domains.forEach(function(row, idx) {
|
||||
var inp = E('input', {
|
||||
'type': 'text', 'class': 'cbi-input-text',
|
||||
'placeholder': _('域名后缀,如 bilivideo.com'),
|
||||
'value': row.domain
|
||||
});
|
||||
inp.addEventListener('input', function() {
|
||||
row.domain = inp.value.trim();
|
||||
validateDomainRow(row);
|
||||
});
|
||||
|
||||
var sel = E('select', { 'class': 'cbi-input-select' },
|
||||
model.policies.map(function(p) {
|
||||
return E('option', { 'value': p, 'selected': p === row.policy ? '' : null }, [ p ]);
|
||||
}));
|
||||
sel.addEventListener('change', function() {
|
||||
row.policy = sel.value;
|
||||
validateDomainRow(row);
|
||||
});
|
||||
|
||||
var del = E('button', {
|
||||
'class': 'isp-icon-btn is-danger', 'title': _('删除'),
|
||||
'click': function() {
|
||||
ui.showModal(_('确认删除'), [
|
||||
E('p', {}, [
|
||||
_('把 ') + (row.domain || _('这一行')) + _(' 从域名分流里删掉?')
|
||||
]),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', { 'class': 'btn', 'click': ui.hideModal }, [_('取消')]),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative',
|
||||
'click': function() {
|
||||
ui.hideModal();
|
||||
model.domains.splice(idx, 1);
|
||||
renderDomains();
|
||||
}
|
||||
}, [_('删除')])
|
||||
])
|
||||
]);
|
||||
}
|
||||
}, [ svgIcon('trash', 15) ]);
|
||||
|
||||
var err = errBox();
|
||||
row._err = err;
|
||||
|
||||
domTbody.appendChild(E('tr', {}, [
|
||||
E('td', { 'data-th': _('域名后缀') }, [ inp, err ]),
|
||||
E('td', { 'data-th': _('钉到线路') }, [ sel ]),
|
||||
E('td', { 'data-th': _('操作') }, [
|
||||
E('div', { 'class': 'isp-row-actions' }, [ del ])
|
||||
])
|
||||
]));
|
||||
validateDomainRow(row);
|
||||
});
|
||||
}
|
||||
|
||||
function validateDomainRow(row) {
|
||||
if (!row.domain) {
|
||||
setErr(row._err, '');
|
||||
row._valid = true;
|
||||
return true;
|
||||
}
|
||||
if (!isDomain(row.domain)) {
|
||||
setErr(row._err, _('只能包含字母/数字/点/下划线/连字符,且不能以点开头或结尾'));
|
||||
row._valid = false;
|
||||
return false;
|
||||
}
|
||||
var dup = model.domains.filter(function(r) {
|
||||
return r !== row && r.domain === row.domain;
|
||||
}).length;
|
||||
if (dup) {
|
||||
setErr(row._err, _('域名重复'));
|
||||
row._valid = false;
|
||||
return false;
|
||||
}
|
||||
setErr(row._err, '');
|
||||
row._valid = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
renderDomains();
|
||||
|
||||
var addDomBtn = E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'click': function() {
|
||||
model.domains.push({ domain: '', policy: model.policies[0] || '' });
|
||||
renderDomains();
|
||||
}
|
||||
}, [ svgIcon('plus'), E('span', {}, [_('添加域名')]) ]);
|
||||
|
||||
/* ============================================================ 四、数据源(只读) */
|
||||
|
||||
/* 数据源地址是 root 路径的下载入口,配置页不开放编辑:
|
||||
一个非法 URL 就能让 root 去拉任意内容。这里只展示现状,
|
||||
修改方式指向 CONFIGURATION.md 的「多数据源」一节。 */
|
||||
var srcTbody = E('tbody', {});
|
||||
var srcRows = (health && health.sources) || [];
|
||||
var verifyEnabled = !!(health && health.verify_enabled);
|
||||
|
||||
function srcRoleText(role) {
|
||||
if (role === 'primary')
|
||||
return _('主源');
|
||||
if (role === 'verify')
|
||||
return _('校验源');
|
||||
if (role === 'audit')
|
||||
return _('审计源');
|
||||
return role || '—';
|
||||
}
|
||||
|
||||
function renderSources() {
|
||||
while (srcTbody.firstChild)
|
||||
srcTbody.removeChild(srcTbody.firstChild);
|
||||
|
||||
if (!srcRows.length) {
|
||||
srcTbody.appendChild(E('tr', {}, [
|
||||
E('td', { 'colspan': 6, 'data-th': '' }, [
|
||||
E('div', { 'class': 'isp-empty' }, [
|
||||
health
|
||||
? _('没有数据源信息。')
|
||||
: _('读取数据源状态失败,请检查 isp-split health --json。')
|
||||
])
|
||||
])
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
srcRows.forEach(function(s) {
|
||||
var tag = 'isp-tag is-ok';
|
||||
var text = _('正常');
|
||||
if (s.status === 'disabled') {
|
||||
tag = 'isp-tag';
|
||||
text = _('未启用校验');
|
||||
} else if (s.max_fail && s.fail_days >= s.max_fail) {
|
||||
tag = 'isp-tag is-bad';
|
||||
text = _('拒绝更新') + ' ' + s.fail_days + '/' + s.max_fail;
|
||||
} else if (s.fail_days > 0) {
|
||||
tag = 'isp-tag is-warn';
|
||||
text = _('降级') + ' ' + s.fail_days + '/' + s.max_fail;
|
||||
}
|
||||
|
||||
srcTbody.appendChild(E('tr', {}, [
|
||||
E('td', { 'data-th': _('源') }, [ s.label || s.name ]),
|
||||
E('td', { 'data-th': _('角色') }, [
|
||||
E('span', { 'class': 'isp-tag' }, [ srcRoleText(s.role) ])
|
||||
]),
|
||||
E('td', { 'data-th': _('状态') }, [
|
||||
E('span', { 'class': tag }, [ text ])
|
||||
]),
|
||||
E('td', { 'data-th': _('最近 Jaccard') }, [
|
||||
s.jaccard != null ? (s.jaccard + '%') : '—'
|
||||
]),
|
||||
E('td', { 'data-th': _('最后成功') }, [ s.last_ok_text || '—' ]),
|
||||
E('td', { 'data-th': _('连续失败') }, [
|
||||
String(s.fail_days || 0)
|
||||
])
|
||||
]));
|
||||
});
|
||||
}
|
||||
|
||||
renderSources();
|
||||
|
||||
/* ============================================================ 保存 / 预检 */
|
||||
|
||||
function values() {
|
||||
var ispVal = model.isp.map(function(r) {
|
||||
return r.src + ':' + r.set + ':' + r.policy + ':' + (r.enabled ? '1' : '0');
|
||||
}).join('|');
|
||||
|
||||
var dnsVal = model.dns.filter(function(r) { return r.dns; })
|
||||
.map(function(r) { return r.policy + ':' + r.dns; }).join('|');
|
||||
|
||||
var domVal = model.domains.filter(function(r) { return r.domain; })
|
||||
.map(function(r) { return r.domain + ':' + r.policy; }).join('|');
|
||||
|
||||
return {
|
||||
isp: ispVal,
|
||||
dns: dnsVal,
|
||||
dom: domVal,
|
||||
mode: modeSel.value
|
||||
};
|
||||
}
|
||||
|
||||
/* 返回 true = 全部合法;出错的行已经标红 */
|
||||
function validateAll() {
|
||||
var ok = true;
|
||||
model.isp.forEach(function(r) { ok = validateIspRow(r) && ok; });
|
||||
model.dns.forEach(function(r) { ok = validateDnsRow(r) && ok; });
|
||||
model.domains.forEach(function(r) { ok = validateDomainRow(r) && ok; });
|
||||
|
||||
if (!model.isp.length) {
|
||||
ui.addNotification(null, E('p', {}, [
|
||||
_('至少保留一行运营商映射')
|
||||
]), 'danger');
|
||||
ok = false;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
function listErrors() {
|
||||
var msgs = [];
|
||||
model.isp.forEach(function(r) {
|
||||
if (r._valid === false)
|
||||
msgs.push(_('运营商行 ') + (r.set || r.src || '?') + _(' 有错误'));
|
||||
});
|
||||
model.dns.forEach(function(r) {
|
||||
if (r._valid === false)
|
||||
msgs.push(_('DNS 行 ') + r.policy + _(' 有错误'));
|
||||
});
|
||||
model.domains.forEach(function(r) {
|
||||
if (r._valid === false)
|
||||
msgs.push(_('域名行 ') + (r.domain || '?') + _(' 有错误'));
|
||||
});
|
||||
return msgs;
|
||||
}
|
||||
|
||||
function step(args, log) {
|
||||
return run(args).then(function(r) {
|
||||
var t = ((r.stdout || '') + (r.stderr || '')).trim();
|
||||
log.push('$ isp-split ' + args.join(' ') + '\n' + (t || ' (无输出)'));
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
function preflight() {
|
||||
if (!validateAll()) {
|
||||
showResult(_('预检未通过:') + '\n - ' + listErrors().join('\n - '));
|
||||
ui.addNotification(null, E('p', {}, [_('表格里还有非法输入,先修正标红的行')]), 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
var v = values();
|
||||
showResult(_('正在预检(只写临时副本,不会改动配置)…'));
|
||||
run(['conf', 'preview',
|
||||
'isp_table', v.isp,
|
||||
'dns_table', v.dns,
|
||||
'domain_split', v.dom,
|
||||
'dns_mode', v.mode
|
||||
]).then(function(r) {
|
||||
showResult(((r.stdout || '') + (r.stderr || '')) || '(无输出)');
|
||||
ui.addNotification(null, E('p', {}, [_('预检完成,配置未被修改')]), 'info');
|
||||
}).catch(function(e) {
|
||||
showResult(_('预检失败:') + String(e));
|
||||
ui.addNotification(null, E('p', {}, [_('预检失败:') + String(e)]), 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function save(apply) {
|
||||
if (!validateAll()) {
|
||||
showResult(_('保存已取消:') + '\n - ' + listErrors().join('\n - '));
|
||||
ui.addNotification(null, E('p', {}, [_('表格里还有非法输入,先修正标红的行')]), 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
var v = values();
|
||||
var log = [];
|
||||
|
||||
step(['conf', 'set', 'isp_table', v.isp], log)
|
||||
.then(function() { return step(['conf', 'set', 'dns_table', v.dns], log); })
|
||||
.then(function() { return step(['conf', 'set', 'domain_split', v.dom], log); })
|
||||
.then(function() { return step(['conf', 'set', 'dns_mode', v.mode], log); })
|
||||
.then(function() {
|
||||
if (!apply)
|
||||
return null;
|
||||
return step(['wire'], log)
|
||||
.then(function() { return step(['apply'], log); })
|
||||
.then(function() { return step(['dns'], log); });
|
||||
})
|
||||
.then(function() {
|
||||
showResult(log.join('\n\n'));
|
||||
ui.addNotification(null, E('p', {}, [
|
||||
apply
|
||||
? _('已保存并生效')
|
||||
: _('已保存。改动需执行「保存并生效」或手动 wire/apply 后才生效')
|
||||
]), 'info');
|
||||
})
|
||||
.catch(function(e) {
|
||||
showResult(log.join('\n\n') + '\n\n错误: ' + String(e));
|
||||
ui.addNotification(null, E('p', {}, [ _('操作失败:') + String(e) ]), 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================================================ 页面 */
|
||||
|
||||
return E('div', { 'class': 'isp-app cbi-map' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('isp-split/isp-split.css') }),
|
||||
|
||||
E('div', { 'class': 'isp-hero' }, [
|
||||
E('h2', {}, [ _('运营商分流 · 配置') ]),
|
||||
E('div', { 'class': 'isp-sub' }, [
|
||||
_('改动写入 /etc/isp-split.conf:写入前校验、写入后回读,语法不合法自动回滚。')
|
||||
])
|
||||
]),
|
||||
|
||||
/* ---- 策略映射 ---- */
|
||||
E('section', { 'class': 'isp-section' }, [
|
||||
E('h3', {}, [ _('策略映射') ]),
|
||||
E('p', { 'class': 'isp-desc' }, [
|
||||
_('顺序即优先级:排在前面的运营商优先占有重叠地址。集合名同时是 mwan3 规则名(<=15 字符)。')
|
||||
]),
|
||||
E('table', { 'class': 'isp-form-table' }, [
|
||||
E('thead', {}, [
|
||||
E('tr', {}, [
|
||||
E('th', {}, [_('运营商(源名)')]),
|
||||
E('th', {}, [_('集合')]),
|
||||
E('th', {}, [_('策略')]),
|
||||
E('th', {}, [_('启用')]),
|
||||
E('th', {}, [_('顺序')])
|
||||
])
|
||||
]),
|
||||
ispTbody
|
||||
]),
|
||||
E('div', { 'class': 'isp-btn-row' }, [ addIspBtn ])
|
||||
]),
|
||||
|
||||
/* ---- 多线路 DNS ---- */
|
||||
E('section', { 'class': 'isp-section' }, [
|
||||
E('h3', {}, [ _('多线路 DNS') ]),
|
||||
E('p', { 'class': 'isp-desc' }, [
|
||||
_('让每条线用自己运营商的 DNS,CDN 才会把本运营商节点返回给你。留空表示不配置。')
|
||||
]),
|
||||
E('table', { 'class': 'isp-form-table' }, [
|
||||
E('thead', {}, [
|
||||
E('tr', {}, [
|
||||
E('th', {}, [_('线路(策略)')]),
|
||||
E('th', {}, [_('DNS 服务器')]),
|
||||
E('th', {}, [_('测线')])
|
||||
])
|
||||
]),
|
||||
dnsTbody
|
||||
]),
|
||||
E('div', { 'class': 'isp-btn-row' }, [
|
||||
E('span', { 'class': 'isp-line-name' }, [_('出线方式')]),
|
||||
modeSel
|
||||
]),
|
||||
/* 这行解释原来挤在 .isp-tag 胶囊里,窄屏换成两行后首尾字符被圆角压掉,
|
||||
看着像被截断。改成普通说明段落:整段换行显示,title 再兜一次全文。 */
|
||||
E('div', {
|
||||
'class': 'isp-note',
|
||||
'title': _('bind 依赖 dnsmasq @接口;mark 用 nft 打标,OpenClash 接管 DNS 时更稳')
|
||||
}, [
|
||||
_('bind 依赖 dnsmasq @接口;mark 用 nft 打标,OpenClash 接管 DNS 时更稳')
|
||||
])
|
||||
]),
|
||||
|
||||
/* ---- 域名分流 ---- */
|
||||
E('section', { 'class': 'isp-section' }, [
|
||||
E('h3', {}, [ _('域名分流') ]),
|
||||
E('p', { 'class': 'isp-desc' }, [
|
||||
_('未列出的域名走系统默认上游(通常就是 PPPoE 下发的运营商 DNS,无需配置)。'),
|
||||
_('只有个别站点 CDN 调度不准时,才需要把它的域名钉到指定线路。')
|
||||
]),
|
||||
E('table', { 'class': 'isp-form-table' }, [
|
||||
E('thead', {}, [
|
||||
E('tr', {}, [
|
||||
E('th', {}, [_('域名后缀')]),
|
||||
E('th', {}, [_('钉到线路')]),
|
||||
E('th', {}, [_('操作')])
|
||||
])
|
||||
]),
|
||||
domTbody
|
||||
]),
|
||||
E('div', { 'class': 'isp-btn-row' }, [ addDomBtn ])
|
||||
]),
|
||||
|
||||
/* ---- 数据源(只读) ---- */
|
||||
E('section', { 'class': 'isp-section' }, [
|
||||
E('h3', {}, [ _('数据源'), E('span', { 'class': 'isp-tag' }, [ _('只读') ]) ]),
|
||||
E('p', { 'class': 'isp-desc' }, [
|
||||
_('主源出数据,校验源只跑日 diff;分歧超阈值会拒绝更新并保留旧数据。'),
|
||||
_('为避免把非法 URL 写进 root 路径,本页只展示、不开放编辑。')
|
||||
]),
|
||||
E('div', { 'class': 'isp-btn-row' }, [
|
||||
E('span', {
|
||||
'class': 'isp-tag' + (verifyEnabled ? ' is-ok' : '')
|
||||
}, [ verifyEnabled ? _('校验已启用') : _('校验未启用') ]),
|
||||
E('span', { 'class': 'isp-tag' }, [
|
||||
_('源声明来自 ISP_SOURCES')
|
||||
])
|
||||
]),
|
||||
E('table', { 'class': 'isp-form-table' }, [
|
||||
E('thead', {}, [
|
||||
E('tr', {}, [
|
||||
E('th', {}, [_('源')]),
|
||||
E('th', {}, [_('角色')]),
|
||||
E('th', {}, [_('状态')]),
|
||||
E('th', {}, [_('最近 Jaccard')]),
|
||||
E('th', {}, [_('最后成功')]),
|
||||
E('th', {}, [_('连续失败')])
|
||||
])
|
||||
]),
|
||||
srcTbody
|
||||
]),
|
||||
E('div', { 'class': 'isp-note' }, [
|
||||
_('修改数据源请编辑 /etc/isp-split.conf 的 ISP_SOURCES / ISP_SOURCE_FILES,'),
|
||||
E('a', {
|
||||
'href': 'https://github.com/jihebube/openwrt-isp-split/blob/main/docs/CONFIGURATION.md',
|
||||
'target': '_blank',
|
||||
'rel': 'noopener'
|
||||
}, [ _('配置文档') ]),
|
||||
_(' 的「多数据源」一节有完整字段说明与降级行为表。')
|
||||
])
|
||||
]),
|
||||
|
||||
E('div', { 'class': 'isp-footer-actions' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-neutral',
|
||||
'click': preflight
|
||||
}, [ svgIcon('refresh'), E('span', {}, [_('保存前预检')]) ]),
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': function() { save(false); }
|
||||
}, [ svgIcon('save'), E('span', {}, [_('仅保存')]) ]),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'click': function() { save(true); }
|
||||
}, [ svgIcon('check'), E('span', {}, [_('保存并生效')]) ])
|
||||
]),
|
||||
|
||||
result
|
||||
]);
|
||||
},
|
||||
|
||||
handleSave: null,
|
||||
handleSaveApply: null,
|
||||
handleReset: null
|
||||
});
|
||||
@@ -0,0 +1,734 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require fs';
|
||||
'require ui';
|
||||
|
||||
/*
|
||||
* LuCI 概览页:运营商分流状态
|
||||
*
|
||||
* 数据来源(两次只读调用,均由 rpcd file.exec 执行):
|
||||
* isp-split status --json → 每个集合的条目数/命中包/流量 + 兜底计数
|
||||
* isp-split health --json → 源健康度、双线状态、策略占比、数据时间、最近 diff
|
||||
*
|
||||
* 页面只用原生 DOM(LuCI 的 E())+ 一份静态 CSS,没有构建工具、没有外链资源:
|
||||
* 路由器离线时图标与样式照常显示。
|
||||
*
|
||||
* 为什么不用 LuCI 的 poll 模块:`poll.add()` 会触发 document 级的 poll-start 事件,
|
||||
* 主题随之在顶栏弹出英文 "Refreshing" 徽标(luci.js: showIndicator(_('Refreshing'))),
|
||||
* 中文界面里很突兀,而且位置离数据很远。这里改成自己的定时器 + 页面内中文徽标
|
||||
* 「刷新中…」,用户看到的是同一处状态点。
|
||||
*/
|
||||
|
||||
var CMD = '/usr/sbin/isp-split';
|
||||
var REFRESH_SEC = 10;
|
||||
|
||||
/* ---------------------------------------------------------------- 图标(内联 SVG,无外链) */
|
||||
|
||||
var ICONS = {
|
||||
refresh: '<path d="M20 11a8 8 0 1 0-2.3 6.3"/><path d="M20 4v7h-7"/>',
|
||||
pause: '<rect x="7" y="5" width="3.2" height="14" rx="1"/><rect x="13.8" y="5" width="3.2" height="14" rx="1"/>',
|
||||
play: '<path d="M8 5.5v13l11-6.5z"/>',
|
||||
sync: '<path d="M4 7h11a4 4 0 0 1 0 8H9"/><path d="M7 4 4 7l3 3"/><path d="M20 17H9a4 4 0 0 1 0-8h6"/><path d="M17 20l3-3-3-3"/>',
|
||||
download: '<path d="M12 4v10"/><path d="M8 10l4 4 4-4"/><path d="M5 19h14"/>',
|
||||
check: '<path d="M4 12.5 9 17.5 20 6.5"/>',
|
||||
tel: '<path d="M12 9v12"/><circle cx="12" cy="5" r="2"/><path d="M8.6 8.4a5 5 0 0 1 6.8 0"/><path d="M6 5.8a8.4 8.4 0 0 1 12 0"/>',
|
||||
uni: '<circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="12" cy="18" r="2"/><path d="M8 6h8"/><path d="M7 8l4 8"/><path d="M17 8l-4 8"/>',
|
||||
cmcc: '<rect x="7" y="3" width="10" height="18" rx="2"/><path d="M11 18h2"/>',
|
||||
cernet: '<path d="M3 9.5 12 5l9 4.5-9 4.5z"/><path d="M7 11.5V16c0 1.2 2.2 2.2 5 2.2s5-1 5-2.2v-4.5"/>',
|
||||
other: '<circle cx="12" cy="12" r="7"/><path d="M12 5v14"/>'
|
||||
};
|
||||
|
||||
var GLYPH_FOR = {
|
||||
chinatelecom: 'tel', chinanet: 'tel', telecom: 'tel',
|
||||
unicom_cnc: 'uni', unicom: 'uni',
|
||||
cmcc: 'cmcc', mobile: 'cmcc',
|
||||
cernet: 'cernet'
|
||||
};
|
||||
|
||||
function svgIcon(name, size) {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'isp-ico';
|
||||
span.innerHTML = '<svg viewBox="0 0 24 24" width="' + (size || 18) + '" height="' + (size || 18) +
|
||||
'" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" ' +
|
||||
'stroke-linejoin="round" aria-hidden="true">' + (ICONS[name] || ICONS.other) + '</svg>';
|
||||
return span;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 格式化 */
|
||||
|
||||
function fmtNum(n) {
|
||||
n = +n || 0;
|
||||
return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
n = +n || 0;
|
||||
var u = ['B', 'KiB', 'MiB', 'GiB', 'TiB'], i = 0;
|
||||
while (n >= 1024 && i < u.length - 1) {
|
||||
n /= 1024;
|
||||
i++;
|
||||
}
|
||||
return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + u[i];
|
||||
}
|
||||
|
||||
function accentOf(src) {
|
||||
return 'var(--isp-accent-' + (GLYPH_FOR[src] || 'other') + ')';
|
||||
}
|
||||
|
||||
/*
|
||||
* cron 表达式 → 人话(分 时 日 月 周)。
|
||||
* 只翻译最常见、能一眼看懂的形状;认不出来的原样返回,宁可显示原文也不猜错。
|
||||
* 17 4 * * * → 每天 04:17
|
||||
* 30 3 * * 1 → 每周一 03:30
|
||||
* 5 2 1 * * → 每月 1 日 02:05
|
||||
*/
|
||||
var DOW_NAMES = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
|
||||
function humanCron(expr) {
|
||||
var f = String(expr || '').trim().split(/\s+/);
|
||||
|
||||
if (f.length !== 5)
|
||||
return '';
|
||||
|
||||
function num(s) {
|
||||
return /^\d+$/.test(s) ? +s : null;
|
||||
}
|
||||
|
||||
var min = num(f[0]);
|
||||
var hour = num(f[1]);
|
||||
if (min === null || hour === null || min > 59 || hour > 23)
|
||||
return '';
|
||||
|
||||
var hhmm = (hour < 10 ? '0' : '') + hour + ':' + (min < 10 ? '0' : '') + min;
|
||||
var dom = num(f[2]);
|
||||
var dow = num(f[4]);
|
||||
|
||||
if (f[2] === '*' && f[3] === '*' && f[4] === '*')
|
||||
return _('每天 ') + hhmm;
|
||||
|
||||
if (f[2] === '*' && f[3] === '*' && dow !== null && dow <= 7)
|
||||
return _('每') + DOW_NAMES[dow % 7] + ' ' + hhmm;
|
||||
|
||||
if (f[4] === '*' && f[3] === '*' && f[2] !== '*' && dom !== null)
|
||||
return _('每月 ') + dom + _(' 日 ') + hhmm;
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/* mwan3 的线路状态是英文枚举,界面上统一显示中文;原文留在 title 里备查 */
|
||||
function stateText(state) {
|
||||
if (state === 'online')
|
||||
return _('在线');
|
||||
if (state === 'offline')
|
||||
return _('离线');
|
||||
return _('未知');
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 数据解析 */
|
||||
|
||||
function parseJson(res) {
|
||||
try {
|
||||
return JSON.parse(((res && res.stdout) || '').trim() || 'null');
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function policyMembers(health, policy) {
|
||||
var list = (health && health.policies) || [];
|
||||
for (var i = 0; i < list.length; i++)
|
||||
if (list[i].name === policy)
|
||||
return list[i].members || [];
|
||||
return [];
|
||||
}
|
||||
|
||||
/* 策略里出现过的线路,按出现顺序编号:第一条 = 左,第二条 = 右 */
|
||||
function lineOrder(health) {
|
||||
var order = [];
|
||||
((health && health.policies) || []).forEach(function(p) {
|
||||
(p.members || []).forEach(function(m) {
|
||||
if (order.indexOf(m.iface) < 0)
|
||||
order.push(m.iface);
|
||||
});
|
||||
});
|
||||
return order;
|
||||
}
|
||||
|
||||
function activeLines(health) {
|
||||
var order = lineOrder(health);
|
||||
var used = {};
|
||||
((health && health.policies) || []).forEach(function(p) {
|
||||
(p.members || []).forEach(function(m) {
|
||||
if (+m.pct > 0)
|
||||
used[m.iface] = true;
|
||||
});
|
||||
});
|
||||
var out = order.filter(function(iface) { return used[iface]; });
|
||||
return out.length ? out : order;
|
||||
}
|
||||
|
||||
function lineInfo(health, iface) {
|
||||
var lines = (health && health.lines) || [];
|
||||
for (var i = 0; i < lines.length; i++)
|
||||
if (lines[i].iface === iface)
|
||||
return lines[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
function lineShares(health, iface) {
|
||||
var out = [];
|
||||
((health && health.policies) || []).forEach(function(p) {
|
||||
(p.members || []).forEach(function(m) {
|
||||
if (m.iface === iface && +m.pct > 0)
|
||||
out.push({ policy: p.name, pct: +m.pct });
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 视图 */
|
||||
|
||||
return view.extend({
|
||||
exec: function(args) {
|
||||
return fs.exec(CMD, args).then(function(res) {
|
||||
if (res && res.code) {
|
||||
var out = ((res.stdout || '') + (res.stderr || '')).trim();
|
||||
throw new Error('isp-split ' + args.join(' ') +
|
||||
' 失败(退出码 ' + res.code + ')' + (out ? ':' + out : ''));
|
||||
}
|
||||
return res;
|
||||
});
|
||||
},
|
||||
|
||||
/* 只读 JSON 调用:失败不抛出,交给页面显示一行告警 */
|
||||
readJson: function(args) {
|
||||
return fs.exec(CMD, args).then(function(res) {
|
||||
var data = parseJson(res);
|
||||
if (res && res.code && !data)
|
||||
throw new Error((((res.stdout || '') + (res.stderr || '')).trim()) ||
|
||||
('退出码 ' + res.code));
|
||||
return data;
|
||||
});
|
||||
},
|
||||
|
||||
setOut: function(text) {
|
||||
var out = document.getElementById('isp-out');
|
||||
if (out) {
|
||||
out.textContent = text;
|
||||
out.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
|
||||
run: function(args, label, confirmText) {
|
||||
var self = this;
|
||||
|
||||
var doRun = function() {
|
||||
self.setOut('正在执行:isp-split ' + args.join(' ') + ' …');
|
||||
return self.exec(args).then(function(res) {
|
||||
self.setOut(((res.stdout || '') + (res.stderr || '')) || '(无输出)');
|
||||
ui.addNotification(null, E('p', {}, [label + ' 已完成']), 'info');
|
||||
return self.refresh();
|
||||
}).catch(function(err) {
|
||||
self.setOut('执行失败:' + String(err));
|
||||
ui.addNotification(null, E('p', {}, ['执行失败:' + String(err)]), 'danger');
|
||||
});
|
||||
};
|
||||
|
||||
if (!confirmText)
|
||||
return doRun();
|
||||
|
||||
return ui.showModal(_('确认'), [
|
||||
E('p', {}, [confirmText]),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', { 'class': 'btn', 'click': ui.hideModal }, [_('取消')]),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative',
|
||||
'click': function() { ui.hideModal(); doRun(); }
|
||||
}, [_('确认执行')])
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
refresh: function() {
|
||||
var self = this;
|
||||
|
||||
/* 上一轮还没回来就不再叠一次取数(10s 一跳,网络慢时很容易叠) */
|
||||
if (this._inflight)
|
||||
return this._inflight;
|
||||
|
||||
this.busy = true;
|
||||
this.paintPill();
|
||||
|
||||
var done = function() {
|
||||
self._inflight = null;
|
||||
self.busy = false;
|
||||
self.paintPill();
|
||||
};
|
||||
|
||||
this._inflight = Promise.all([
|
||||
this.readJson(['status', '--json']).catch(function(e) {
|
||||
return { __error: String(e) };
|
||||
}),
|
||||
this.readJson(['health', '--json']).catch(function(e) {
|
||||
return { __error: String(e) };
|
||||
})
|
||||
]).then(function(res) {
|
||||
self.status = Array.isArray(res[0]) ? res[0] : [];
|
||||
self.statusError = res[0] && res[0].__error;
|
||||
self.health = (res[1] && !res[1].__error) ? res[1] : {};
|
||||
self.healthError = res[1] && res[1].__error;
|
||||
self.paint();
|
||||
}).then(done, done);
|
||||
|
||||
return this._inflight;
|
||||
},
|
||||
|
||||
/* 状态徽标:正常「自动刷新 10s」/ 取数中「刷新中…」+ 转圈 / 暂停「已暂停」 */
|
||||
paintPill: function() {
|
||||
var pill = this._pill;
|
||||
if (!pill || !pill.parentNode)
|
||||
return;
|
||||
|
||||
var icon = this._pillIcon;
|
||||
var text = this._pillText;
|
||||
|
||||
while (icon.firstChild)
|
||||
icon.removeChild(icon.firstChild);
|
||||
|
||||
var cls = 'isp-pill';
|
||||
|
||||
if (this.paused) {
|
||||
cls += ' is-paused';
|
||||
icon.appendChild(E('span', { 'class': 'isp-dot' }));
|
||||
text.textContent = _('已暂停');
|
||||
}
|
||||
else if (this.busy) {
|
||||
cls += ' is-busy';
|
||||
var spin = svgIcon('refresh', 13);
|
||||
spin.className = 'isp-ico isp-spin';
|
||||
icon.appendChild(spin);
|
||||
text.textContent = _('刷新中…');
|
||||
}
|
||||
else {
|
||||
icon.appendChild(E('span', { 'class': 'isp-dot' }));
|
||||
text.textContent = _('自动刷新 ' + REFRESH_SEC + 's');
|
||||
}
|
||||
|
||||
pill.className = cls;
|
||||
},
|
||||
|
||||
/* ---------------- 运营商卡片 ---------------- */
|
||||
|
||||
paintOps: function() {
|
||||
var self = this;
|
||||
var box = document.getElementById('isp-cards');
|
||||
if (!box)
|
||||
return;
|
||||
|
||||
while (box.firstChild)
|
||||
box.removeChild(box.firstChild);
|
||||
|
||||
var rows = (this.status || []).filter(function(r) {
|
||||
return r && r.set !== '兜底';
|
||||
});
|
||||
|
||||
if (!rows.length) {
|
||||
box.appendChild(E('div', { 'class': 'isp-empty' }, [
|
||||
this.statusError
|
||||
? _('读取状态失败:') + this.statusError
|
||||
: _('没有启用的运营商。检查 /etc/isp-split.conf 里的 ISP_TABLE。')
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
var maxPackets = 0;
|
||||
rows.forEach(function(r) { maxPackets = Math.max(maxPackets, +r.packets || 0); });
|
||||
|
||||
rows.forEach(function(r) {
|
||||
var accent = accentOf(r.src);
|
||||
var members = policyMembers(self.health, r.policy);
|
||||
var order = lineOrder(self.health);
|
||||
var active = members.filter(function(m) { return +m.pct > 0; });
|
||||
var arrows = '·';
|
||||
var routeText = _('策略无在线成员');
|
||||
|
||||
if (active.length > 1) {
|
||||
arrows = '◀▶';
|
||||
routeText = active.map(function(m) { return m.iface; }).join(' + ');
|
||||
} else if (active.length === 1) {
|
||||
arrows = (order.indexOf(active[0].iface) <= 0) ? '◀' : '▶';
|
||||
routeText = active[0].iface + ' ' + active[0].pct + '%';
|
||||
}
|
||||
|
||||
var packets = +r.packets || 0;
|
||||
var pct = maxPackets > 0
|
||||
? Math.max(2, Math.round(packets * 100 / maxPackets))
|
||||
: 0;
|
||||
|
||||
box.appendChild(E('article', {
|
||||
'class': 'isp-card isp-card-op',
|
||||
'style': '--isp-accent: ' + accent
|
||||
}, [
|
||||
E('div', { 'class': 'isp-card-head' }, [
|
||||
E('span', { 'class': 'isp-op-name' }, [ r.label || r.src ]),
|
||||
E('span', { 'class': 'isp-op-ico' }, [ svgIcon(GLYPH_FOR[r.src] || 'other', 20) ])
|
||||
]),
|
||||
E('div', {}, [
|
||||
E('span', { 'class': 'isp-big' }, [ r.exists ? fmtNum(packets) : '—' ]),
|
||||
E('span', { 'class': 'isp-unit' }, [ _('命中包') ])
|
||||
]),
|
||||
E('div', { 'class': 'isp-op-sub' }, [
|
||||
r.exists
|
||||
? _('流量 ') + fmtBytes(r.bytes) + ' · ' + _('条目 ') + fmtNum(r.elements)
|
||||
: _('集合不存在(等待 wire/apply)')
|
||||
]),
|
||||
E('div', { 'class': 'isp-route' }, [
|
||||
E('span', { 'class': 'isp-arrows' }, [ arrows ]),
|
||||
E('span', {}, [ routeText ]),
|
||||
E('span', { 'class': 'isp-tag' }, [ r.policy ])
|
||||
]),
|
||||
/* 零流量时不画进度条:2% 的最小宽度会缩成一个孤零零的色点,
|
||||
看起来像渲染坏了。改成一个「—」,标题里说明原因。 */
|
||||
packets > 0
|
||||
? E('div', { 'class': 'isp-meter' }, [
|
||||
E('i', { 'style': 'width:' + pct + '%' })
|
||||
])
|
||||
: E('div', {
|
||||
'class': 'isp-meter-empty',
|
||||
'title': r.exists ? _('暂无命中流量') : _('集合尚未生效')
|
||||
}, [ '—' ])
|
||||
]));
|
||||
});
|
||||
},
|
||||
|
||||
/* ---------------- 双线健康 ---------------- */
|
||||
|
||||
paintLines: function() {
|
||||
var box = document.getElementById('isp-lines');
|
||||
if (!box)
|
||||
return;
|
||||
|
||||
while (box.firstChild)
|
||||
box.removeChild(box.firstChild);
|
||||
|
||||
var health = this.health || {};
|
||||
var ifaces = activeLines(health);
|
||||
|
||||
if (!ifaces.length) {
|
||||
box.appendChild(E('div', { 'class': 'isp-empty' }, [
|
||||
_('读不到 mwan3 的线路状态(mwan3 未运行,或策略里没有成员)。')
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
var list = E('div', { 'class': 'isp-list' });
|
||||
ifaces.forEach(function(iface) {
|
||||
var info = lineInfo(health, iface) || {};
|
||||
var state = info.state || 'unknown';
|
||||
var cls = 'isp-line' + (state === 'online' ? ' is-online'
|
||||
: (state === 'offline' ? ' is-offline' : ''));
|
||||
var tracks = (info.tracks || []).map(function(t) {
|
||||
return t.ip + ':' + t.state;
|
||||
}).join(' ');
|
||||
|
||||
var shares = lineShares(health, iface).map(function(s) {
|
||||
return s.policy + ' ' + s.pct + '%';
|
||||
}).join(' · ');
|
||||
|
||||
/* 这一列只剩 1fr 的窄位,塞「online · tracking active · IP:up」必然被
|
||||
ellipsis 截成 "tracking activ…"。正文只留两个字,全文进 title。 */
|
||||
var detail = state
|
||||
+ (info.tracking ? ' · tracking ' + info.tracking : '')
|
||||
+ (tracks ? ' · ' + tracks : '');
|
||||
|
||||
list.appendChild(E('div', { 'class': cls }, [
|
||||
E('span', { 'class': 'isp-dot' }),
|
||||
E('span', { 'class': 'isp-line-name' }, [ iface ]),
|
||||
E('span', {
|
||||
'class': 'isp-line-track',
|
||||
'title': iface + ' · ' + detail
|
||||
}, [ stateText(state) ]),
|
||||
E('span', { 'class': 'isp-line-pct' }, [ shares || _('未参与策略') ])
|
||||
]));
|
||||
});
|
||||
box.appendChild(list);
|
||||
},
|
||||
|
||||
/* ---------------- 兜底流量 ---------------- */
|
||||
|
||||
paintFallback: function() {
|
||||
var box = document.getElementById('isp-fallback');
|
||||
if (!box)
|
||||
return;
|
||||
|
||||
while (box.firstChild)
|
||||
box.removeChild(box.firstChild);
|
||||
|
||||
var fb = null;
|
||||
(this.status || []).forEach(function(r) {
|
||||
if (r && r.set === '兜底')
|
||||
fb = r;
|
||||
});
|
||||
|
||||
if (!fb || !fb.exists) {
|
||||
box.appendChild(E('div', { 'class': 'isp-empty' }, [
|
||||
_('兜底计数器不存在。执行「同步生效」或确认 COUNT_FALLBACK=1。')
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
box.appendChild(E('div', {}, [
|
||||
E('span', { 'class': 'isp-big' }, [ fmtNum(fb.packets) ]),
|
||||
E('span', { 'class': 'isp-unit' }, [ _('连接(首包)') ])
|
||||
]));
|
||||
box.appendChild(E('div', { 'class': 'isp-op-sub' }, [
|
||||
_('流量 ') + fmtBytes(fb.bytes) + ' · ' + _('未命中任何运营商集合')
|
||||
]));
|
||||
},
|
||||
|
||||
/* ---------------- 数据源健康度 ---------------- */
|
||||
|
||||
paintSources: function() {
|
||||
var box = document.getElementById('isp-sources');
|
||||
if (!box)
|
||||
return;
|
||||
|
||||
while (box.firstChild)
|
||||
box.removeChild(box.firstChild);
|
||||
|
||||
var health = this.health || {};
|
||||
var srcs = health.sources || [];
|
||||
|
||||
if (!srcs.length) {
|
||||
box.appendChild(E('div', { 'class': 'isp-empty' }, [
|
||||
this.healthError
|
||||
? _('读取数据源状态失败:') + this.healthError
|
||||
: _('没有数据源信息。')
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
var table = E('table', { 'class': 'isp-table' }, [
|
||||
E('thead', {}, [
|
||||
E('tr', {}, [
|
||||
E('th', {}, [_('源')]),
|
||||
E('th', {}, [_('角色')]),
|
||||
E('th', {}, [_('最后成功')]),
|
||||
E('th', { 'class': 'num' }, [_('连续失败')]),
|
||||
E('th', { 'class': 'num' }, [_('Jaccard')]),
|
||||
E('th', {}, [_('状态')])
|
||||
])
|
||||
])
|
||||
]);
|
||||
var body = E('tbody', {});
|
||||
|
||||
srcs.forEach(function(s) {
|
||||
var tag = 'isp-tag is-ok';
|
||||
var text = _('正常');
|
||||
if (s.status === 'disabled') {
|
||||
tag = 'isp-tag';
|
||||
text = _('未启用校验');
|
||||
} else if (s.max_fail && s.fail_days >= s.max_fail) {
|
||||
tag = 'isp-tag is-bad';
|
||||
text = _('拒绝更新') + ' ' + s.fail_days + '/' + s.max_fail;
|
||||
} else if (s.fail_days > 0) {
|
||||
tag = 'isp-tag is-warn';
|
||||
text = _('降级') + ' ' + s.fail_days + '/' + s.max_fail;
|
||||
}
|
||||
|
||||
body.appendChild(E('tr', {}, [
|
||||
E('td', {}, [ s.label || s.name ]),
|
||||
E('td', {}, [ E('span', { 'class': 'isp-tag' }, [ s.role ]) ]),
|
||||
E('td', {}, [ s.last_ok_text || '—' ]),
|
||||
E('td', { 'class': 'num' }, [ String(s.fail_days || 0) ]),
|
||||
E('td', { 'class': 'num' }, [ s.jaccard != null ? (s.jaccard + '%') : '—' ]),
|
||||
E('td', {}, [ E('span', { 'class': tag }, [ text ]) ])
|
||||
]));
|
||||
});
|
||||
|
||||
table.appendChild(body);
|
||||
box.appendChild(table);
|
||||
},
|
||||
|
||||
/* ---------------- 数据时间 / cron / diff ---------------- */
|
||||
|
||||
paintTime: function() {
|
||||
var box = document.getElementById('isp-time');
|
||||
if (!box)
|
||||
return;
|
||||
|
||||
while (box.firstChild)
|
||||
box.removeChild(box.firstChild);
|
||||
|
||||
var d = (this.health && this.health.data) || {};
|
||||
var diff = (this.health && this.health.diff) || {};
|
||||
|
||||
box.appendChild(E('div', { 'class': 'isp-time' }, [
|
||||
E('span', {}, [
|
||||
_('数据更新:'),
|
||||
E('b', {}, [ d.updated && d.updated !== '-' ? d.updated : _('未知') ])
|
||||
]),
|
||||
E('span', {}, [
|
||||
_('下次更新:'),
|
||||
E('b', {}, [ d.next_cron && d.next_cron !== '-' ? d.next_cron : _('未知') ]),
|
||||
/* 裸 cron 表达式(17 4 * * *)对普通用户等于没写;翻成「每天 04:17」,
|
||||
原始表达式退到 title 里,排障时鼠标一悬停就能看到。 */
|
||||
d.cron
|
||||
? E('span', {
|
||||
'class': 'isp-tag',
|
||||
'title': _('cron 表达式:') + d.cron
|
||||
}, [ humanCron(d.cron) || d.cron ])
|
||||
: ''
|
||||
]),
|
||||
E('span', {}, [
|
||||
_('最近校验:'),
|
||||
E('b', {}, [
|
||||
diff.min_jaccard != null
|
||||
? diff.min_jaccard + '%'
|
||||
: ((this.health && this.health.verify_enabled) ? _('尚未运行') : _('未启用校验'))
|
||||
])
|
||||
])
|
||||
]));
|
||||
},
|
||||
|
||||
paint: function() {
|
||||
this.paintOps();
|
||||
this.paintLines();
|
||||
this.paintFallback();
|
||||
this.paintSources();
|
||||
this.paintTime();
|
||||
|
||||
var err = document.getElementById('isp-load-error');
|
||||
if (err) {
|
||||
var msg = this.statusError
|
||||
? ('status: ' + this.statusError)
|
||||
: (this.healthError ? ('health: ' + this.healthError) : '');
|
||||
err.textContent = msg;
|
||||
err.style.display = msg ? '' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
/* ---------------- 页面骨架 ---------------- */
|
||||
|
||||
render: function() {
|
||||
var self = this;
|
||||
this.status = [];
|
||||
this.health = {};
|
||||
this.paused = false;
|
||||
this.busy = false;
|
||||
this._inflight = null;
|
||||
|
||||
var pauseLabel = E('span', {}, [_('暂停刷新')]);
|
||||
var pauseBtn = E('button', {
|
||||
'class': 'btn',
|
||||
'title': _('暂停后不再每 10 秒取值,可省一点 CPU'),
|
||||
'click': function() {
|
||||
self.paused = !self.paused;
|
||||
while (pauseBtn.firstChild)
|
||||
pauseBtn.removeChild(pauseBtn.firstChild);
|
||||
pauseBtn.appendChild(svgIcon(self.paused ? 'play' : 'pause', 16));
|
||||
pauseBtn.appendChild(E('span', {}, [ self.paused ? _('继续刷新') : _('暂停刷新') ]));
|
||||
|
||||
self.paintPill();
|
||||
|
||||
if (!self.paused)
|
||||
self.refresh();
|
||||
}
|
||||
}, [ svgIcon('pause', 16), pauseLabel ]);
|
||||
|
||||
this._pillIcon = E('span', { 'class': 'isp-pill-ico' });
|
||||
this._pillText = E('span', {});
|
||||
var statePill = this._pill = E('span', { 'class': 'isp-pill' }, [
|
||||
this._pillIcon,
|
||||
this._pillText
|
||||
]);
|
||||
this.paintPill();
|
||||
|
||||
var actions = E('div', { 'class': 'isp-actions' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'click': function() {
|
||||
self.run(['sync'], _('同步'),
|
||||
_('将下载最新运营商数据、重写 mwan3 规则并刷入 nftables。这会改动分流配置,确认继续?'));
|
||||
}
|
||||
}, [ svgIcon('sync', 16), E('span', {}, [_('同步生效')]) ]),
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': function() { self.run(['refresh'], _('更新数据')); }
|
||||
}, [ svgIcon('download', 16), E('span', {}, [_('更新数据')]) ]),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-neutral',
|
||||
'click': function() { self.run(['verify'], _('核对')); }
|
||||
}, [ svgIcon('check', 16), E('span', {}, [_('核对规则')]) ]),
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': function() { self.refresh(); }
|
||||
}, [ svgIcon('refresh', 16), E('span', {}, [_('刷新')]) ])
|
||||
]);
|
||||
|
||||
var container = E('div', { 'class': 'isp-app' }, [
|
||||
E('link', {
|
||||
'rel': 'stylesheet',
|
||||
'href': L.resource('isp-split/isp-split.css')
|
||||
}),
|
||||
|
||||
E('div', { 'class': 'isp-hero' }, [
|
||||
E('h2', {}, [ _('运营商分流') ]),
|
||||
E('div', { 'class': 'isp-sub' }, [
|
||||
_('按目标 IP 归属选择出口线路;只影响直连流量,代理流量由 OpenClash 自行接管。')
|
||||
]),
|
||||
E('div', { 'class': 'isp-toolbar' }, [ statePill, pauseBtn ])
|
||||
]),
|
||||
|
||||
E('div', { 'id': 'isp-load-error', 'class': 'isp-err', 'style': 'display:none' }),
|
||||
E('div', { 'id': 'isp-cards', 'class': 'isp-cards' }),
|
||||
|
||||
E('div', { 'class': 'isp-grid-2' }, [
|
||||
E('section', { 'class': 'isp-card' }, [
|
||||
E('h3', {}, [ _('双线健康') ]),
|
||||
E('div', { 'id': 'isp-lines' })
|
||||
]),
|
||||
E('section', { 'class': 'isp-card' }, [
|
||||
E('h3', {}, [ _('兜底流量') ]),
|
||||
E('div', { 'id': 'isp-fallback' })
|
||||
])
|
||||
]),
|
||||
|
||||
E('div', { 'class': 'isp-grid-2' }, [
|
||||
E('section', { 'class': 'isp-card' }, [
|
||||
E('h3', {}, [ _('数据源健康度') ]),
|
||||
E('div', { 'id': 'isp-sources' })
|
||||
]),
|
||||
E('section', { 'class': 'isp-card' }, [
|
||||
E('h3', {}, [ _('数据时间') ]),
|
||||
E('div', { 'id': 'isp-time' })
|
||||
])
|
||||
]),
|
||||
|
||||
actions,
|
||||
|
||||
E('pre', { 'id': 'isp-out', 'class': 'isp-out' }, [ _('命令输出会显示在这里') ])
|
||||
]);
|
||||
|
||||
this.refresh();
|
||||
|
||||
/* 自带定时器替代 LuCI 的 poll(见文件头注释):暂停即停,页面被换走即收摊。 */
|
||||
this.timer = window.setInterval(function() {
|
||||
if (!document.getElementById('isp-time')) {
|
||||
window.clearInterval(self.timer);
|
||||
self.timer = null;
|
||||
return;
|
||||
}
|
||||
if (self.paused || self.busy)
|
||||
return;
|
||||
self.refresh();
|
||||
}, REFRESH_SEC * 1000);
|
||||
|
||||
return container;
|
||||
},
|
||||
|
||||
handleSave: null,
|
||||
handleSaveApply: null,
|
||||
handleReset: null
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"admin/network/isp-split": {
|
||||
"title": "运营商分流",
|
||||
"order": 62,
|
||||
"action": {
|
||||
"type": "firstchild"
|
||||
},
|
||||
"depends": {
|
||||
"acl": [ "luci-app-isp-split" ],
|
||||
"uci": { "mwan3": true }
|
||||
}
|
||||
},
|
||||
"admin/network/isp-split/overview": {
|
||||
"title": "状态",
|
||||
"order": 1,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "isp-split/overview"
|
||||
}
|
||||
},
|
||||
"admin/network/isp-split/config": {
|
||||
"title": "配置",
|
||||
"order": 2,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "isp-split/config"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"luci-app-isp-split": {
|
||||
"description": "Grant access to ISP split status and control",
|
||||
"read": {
|
||||
"ubus": {
|
||||
"file": [ "read", "stat" ]
|
||||
},
|
||||
"file": {
|
||||
"/etc/isp-split.conf": [ "read" ],
|
||||
"/etc/isp-split": [ "read" ],
|
||||
"/etc/isp-split/*": [ "read" ]
|
||||
}
|
||||
},
|
||||
"write": {
|
||||
"ubus": {
|
||||
"file": [ "exec" ]
|
||||
},
|
||||
"file": {
|
||||
"/usr/sbin/isp-split": [ "exec" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user