🎨 Sync 2026-10-29 20:07:12

This commit is contained in:
github-actions[bot] committed 2026-10-29 20:07:12 +08:00
1 parent 0546d1d8e4
commit 737acd5a18
182 files changed
+10470 -1649

No files matched your search

+34 -10
View File
@@ -1,14 +1,17 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-isp-split
PKG_VERSION:=0.2.2
PKG_RELEASE:=3
PKG_VERSION:=0.3.0
PKG_RELEASE:=4
PKG_LICENSE:=MIT
PKG_LICENSE_FILES:=LICENSE
PKG_MAINTAINER:=jihebube <jihebube@users.noreply.github.com>
include $(INCLUDE_DIR)/package.mk
# 与主包同一个构建期开关:默认 1(完整版,含体检页 / 菜单节点);
# 0 = 剥离版,体检页与菜单节点不进包。
FEATURE_LINECHECK?=1
define Package/luci-app-isp-split
SECTION:=luci
CATEGORY:=LuCI
@@ -24,33 +27,54 @@ define Package/luci-app-isp-split/description
editing (wire/apply/dns).
endef
define Build/Prepare
rm -rf $(PKG_BUILD_DIR)
mkdir -p $(PKG_BUILD_DIR)
$(CP) ./htdocs $(PKG_BUILD_DIR)/htdocs
$(CP) ./root $(PKG_BUILD_DIR)/root
# 与主包同一份剥离实现(本目录自带 strip-linecheck.sh,与
# scripts/strip-linecheck.sh 逐字节一致)。曾经在这里手抄三行 sed,
# 只删 config.js,漏掉 isp-split.css 的 .lc-* 样式体 —— F7。
if [ "$(FEATURE_LINECHECK)" != "1" ]; then \
sh $(CURDIR)/strip-linecheck.sh $(PKG_BUILD_DIR); \
fi
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 \
$(INSTALL_DATA) $(PKG_BUILD_DIR)/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 \
$(INSTALL_DATA) $(PKG_BUILD_DIR)/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 \
$(INSTALL_DATA) $(PKG_BUILD_DIR)/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 \
$(INSTALL_DATA) $(PKG_BUILD_DIR)/htdocs/luci-static/resources/view/isp-split/config.js \
$(1)/www/luci-static/resources/view/isp-split/config.js
if [ -f $(PKG_BUILD_DIR)/htdocs/luci-static/resources/view/isp-split/linecheck.js ]; then \
$(INSTALL_DATA) $(PKG_BUILD_DIR)/htdocs/luci-static/resources/view/isp-split/linecheck.js \
$(1)/www/luci-static/resources/view/isp-split/linecheck.js; \
fi
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-isp-split.json \
$(INSTALL_DATA) $(PKG_BUILD_DIR)/root/usr/share/luci/menu.d/luci-app-isp-split.json \
$(1)/usr/share/luci/menu.d/luci-app-isp-split.json
if [ -f $(PKG_BUILD_DIR)/root/usr/share/luci/menu.d/luci-app-isp-split-linecheck.json ]; then \
$(INSTALL_DATA) $(PKG_BUILD_DIR)/root/usr/share/luci/menu.d/luci-app-isp-split-linecheck.json \
$(1)/usr/share/luci/menu.d/luci-app-isp-split-linecheck.json; \
fi
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-isp-split.json \
$(INSTALL_DATA) $(PKG_BUILD_DIR)/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
rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache* 2>/dev/null
/etc/init.d/rpcd restart >/dev/null 2>&1
exit 0
endef
@@ -37,6 +37,83 @@
box-sizing: border-box;
}
/* ---------------------------------------------------------------- 首装提示条(BUG-25) */
.isp-banner-wrap {
display: block;
}
.isp-banner {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px 18px;
margin: 0 0 16px 0;
padding: 14px 16px;
border: 1px solid var(--isp-border-strong);
border-left: 5px solid var(--isp-warn);
border-radius: var(--isp-radius);
background: var(--background-color-high, rgba(128, 128, 128, 0.08));
box-shadow: var(--isp-shadow);
}
.isp-banner.is-warn {
border-left-color: var(--warn-color-high, #d9822b);
background: var(--background-color-high, rgba(128, 128, 128, 0.08));
}
.isp-banner-msg {
flex: 1 1 360px;
min-width: 240px;
color: var(--isp-text);
font-size: 0.92rem;
line-height: 1.5;
}
.isp-banner-msg strong {
display: block;
margin-bottom: 4px;
font-size: 1rem;
color: var(--warn-color-high, #b35c00);
}
.isp-banner-hint {
margin-top: 6px;
color: var(--isp-muted);
font-size: 0.84rem;
}
.isp-banner-hint code {
padding: 1px 5px;
border-radius: 4px;
background: var(--isp-card-bg-2);
font-size: 0.84rem;
}
.isp-banner-btn {
flex: 0 0 auto;
align-self: center;
white-space: nowrap;
}
.isp-banner-err {
margin-top: 6px;
color: var(--isp-bad);
font-size: 0.84rem;
line-height: 1.45;
}
.isp-banner-err:empty {
display: none;
}
@media (max-width: 600px) {
.isp-banner-btn {
width: 100%;
}
}
/* ---------------------------------------------------------------- 头部 */
.isp-hero {
@@ -721,3 +798,441 @@
margin-bottom: 2px;
}
}
/* lc:begin */
/* ---------------------------------------------------------------- 线路体检(0.3.0)
本段到文件末尾全部是 .lc-* 样式(体检页 / 配置页开关区),
FEATURE_LINECHECK=0 时整段物理删除。
教训(F7):标记必须包住样式体;只包一行标题注释,规则会裸奔进 0.2.3 包。 */
.lc-cards {
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
}
.lc-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
border-top: 3px solid var(--lc-accent, var(--isp-border-strong));
position: relative;
}
.lc-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.lc-title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.lc-name {
font-size: 1.08rem;
font-weight: 650;
color: var(--isp-text);
letter-spacing: 0.01em;
}
.lc-dev {
font-size: 0.78rem;
color: var(--isp-muted);
background: var(--isp-card-bg-2);
border-radius: 999px;
padding: 1px 8px;
}
.lc-state {
font-size: 0.76rem;
color: var(--isp-muted);
}
.lc-state.is-on::before,
.lc-state.is-off::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
margin-right: 4px;
border-radius: 50%;
background: var(--isp-ok);
vertical-align: 1px;
}
.lc-state.is-off::before {
background: var(--isp-bad);
}
.lc-score {
display: flex;
align-items: baseline;
gap: 3px;
white-space: nowrap;
}
.lc-total {
font-size: 1.5rem;
font-weight: 700;
color: var(--isp-text);
line-height: 1;
}
.lc-max {
font-size: 0.75rem;
color: var(--isp-muted);
}
/* 评分徽标:A/B/C/D 四档,颜色只在徽标上,不铺满整张卡 */
.lc-grade {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
margin-right: 6px;
border-radius: 9px;
font-size: 1rem;
font-weight: 700;
color: #fff;
background: var(--isp-muted);
}
.lc-grade.is-a { background: var(--isp-ok); }
.lc-grade.is-b { background: var(--isp-primary); }
.lc-grade.is-c { background: var(--isp-warn); }
.lc-grade.is-d { background: var(--isp-bad); }
.lc-kv {
display: flex;
flex-direction: column;
gap: 5px;
padding: 9px 10px;
border-radius: 10px;
background: var(--isp-card-bg-2);
}
.lc-row {
display: flex;
gap: 10px;
align-items: baseline;
font-size: 0.86rem;
line-height: 1.45;
}
.lc-key {
flex: 0 0 4.9em;
color: var(--isp-muted);
}
.lc-val {
flex: 1 1 auto;
min-width: 0;
color: var(--isp-text);
word-break: break-word;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: baseline;
}
.lc-sub {
font-size: 0.76rem;
color: var(--isp-muted);
}
.lc-tag {
display: inline-block;
padding: 0 7px;
border-radius: 999px;
border: 1px solid var(--isp-border);
font-size: 0.72rem;
line-height: 1.6;
color: var(--isp-muted);
background: var(--isp-card-bg);
}
.lc-tag.is-ok { color: var(--isp-ok); border-color: currentColor; }
.lc-tag.is-warn { color: var(--isp-warn); border-color: currentColor; }
.lc-tag.is-bad { color: var(--isp-bad); border-color: currentColor; }
.lc-tag.is-muted { color: var(--isp-muted); }
/* 四段延迟:等宽四格,窄屏两列 */
.lc-segs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
}
.lc-seg {
display: flex;
flex-direction: column;
gap: 1px;
padding: 7px 8px;
border: 1px solid var(--isp-border);
border-radius: 9px;
background: var(--isp-card-bg);
min-width: 0;
}
.lc-seg-label {
font-size: 0.7rem;
color: var(--isp-muted);
letter-spacing: 0.02em;
}
.lc-seg-value {
font-size: 0.95rem;
font-weight: 600;
color: var(--isp-text);
font-variant-numeric: tabular-nums;
}
.lc-line2 {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
font-size: 0.78rem;
color: var(--isp-muted);
}
.lc-advice {
display: flex;
gap: 7px;
align-items: flex-start;
padding: 8px 10px;
border-radius: 9px;
border-left: 3px solid var(--lc-accent, var(--isp-border-strong));
background: var(--isp-card-bg-2);
font-size: 0.84rem;
line-height: 1.5;
color: var(--isp-text);
}
.lc-advice .isp-ico {
flex: 0 0 auto;
margin-top: 2px;
color: var(--lc-accent, var(--isp-muted));
}
.lc-advice.is-bad {
border-left-color: var(--isp-bad);
}
.lc-toggle {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 0;
border: 0;
background: none;
color: var(--isp-primary);
font-size: 0.8rem;
cursor: pointer;
}
.lc-toggle .isp-ico {
transition: transform 0.15s ease;
}
.lc-toggle.is-open .isp-ico {
transform: rotate(180deg);
}
.lc-detail {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 8px;
border-top: 1px dashed var(--isp-border);
}
.lc-detail[hidden] {
display: none !important;
}
.lc-detail h4 {
margin: 2px 0 0 0;
font-size: 0.8rem;
font-weight: 600;
color: var(--isp-muted);
}
.lc-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.lc-table th,
.lc-table td {
text-align: left;
padding: 4px 6px;
border-bottom: 1px solid var(--isp-border);
vertical-align: top;
word-break: break-word;
}
.lc-table th {
color: var(--isp-muted);
font-weight: 500;
white-space: nowrap;
}
.lc-table td:nth-child(3) {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.lc-caps {
margin: 0;
padding-left: 1.2em;
font-size: 0.78rem;
color: var(--isp-warn);
}
.lc-raw {
font-size: 0.74rem;
color: var(--isp-muted);
line-height: 1.5;
word-break: break-word;
}
.lc-meta {
font-size: 0.78rem;
color: var(--isp-muted);
margin: 0 0 10px 0;
line-height: 1.5;
}
.lc-privacy {
display: flex;
gap: 7px;
align-items: flex-start;
margin: 0 0 12px 0;
padding: 8px 10px;
border: 1px solid var(--isp-border);
border-radius: 10px;
background: var(--isp-card-bg);
font-size: 0.78rem;
color: var(--isp-muted);
line-height: 1.5;
}
.lc-privacy .isp-ico {
flex: 0 0 auto;
margin-top: 1px;
}
.lc-msg {
font-size: 0.82rem;
margin: 0 0 10px 0;
color: var(--isp-muted);
}
.lc-msg.is-info { color: var(--isp-primary); }
.lc-msg.is-ok { color: var(--isp-ok); }
.lc-msg.is-bad { color: var(--isp-bad); }
.lc-run:disabled {
opacity: 0.6;
cursor: default;
}
/* 尚未体检时的占位卡(BUG-23:没缓存也必须有一个能看的页面,不能白屏) */
.lc-placeholder {
grid-column: 1 / -1;
display: block;
padding: 18px 20px;
}
.lc-ph-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 0.95rem;
color: var(--isp-text);
}
.lc-ph-head .isp-ico {
color: var(--isp-primary);
}
.lc-ph-text {
margin: 0 0 8px 0;
font-size: 0.84rem;
line-height: 1.65;
color: var(--isp-muted);
}
.lc-ph-note {
margin: 0;
font-size: 0.78rem;
line-height: 1.6;
color: var(--isp-muted);
opacity: 0.85;
}
@media (max-width: 520px) {
.lc-cards {
grid-template-columns: 1fr;
}
.lc-segs {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.lc-table thead {
display: none;
}
.lc-table tr {
display: block;
padding: 4px 0;
border-bottom: 1px solid var(--isp-border);
}
.lc-table td {
display: flex;
gap: 8px;
border: 0;
padding: 1px 0;
}
.lc-table td::before {
content: attr(data-th);
flex: 0 0 4.5em;
color: var(--isp-muted);
}
}
/* 配置页:线路体检开关区的小字/行内控件 */
.lc-hint {
font-size: 0.74rem;
color: var(--isp-muted);
line-height: 1.4;
margin-top: 2px;
}
.lc-inline {
display: flex;
align-items: center;
gap: 6px;
}
.lc-inline input[type="number"] {
max-width: 7em;
}
.lc-privacy-note {
border-left: 3px solid var(--isp-primary);
padding-left: 10px;
}
/* lc:end */
@@ -103,10 +103,11 @@ function isSetName(s) {
return view.extend({
load: function() {
/* 配置字段走 conf get;数据源健康度走 health --json。
health 失败不能拖垮整个配置页:捕获后渲染成“读取失败”。 */
health/policy-check 失败不能拖垮整个配置页:捕获后渲染成“读取失败”。 */
return Promise.all([
run(['conf', 'get']),
fs.exec(CMD, ['health', '--json']).catch(function() { return null; })
fs.exec(CMD, ['health', '--json']).catch(function() { return null; }),
fs.exec(CMD, ['policy-check', '--json']).catch(function() { return null; })
]);
},
@@ -119,13 +120,24 @@ return view.extend({
} catch (e) {
health = null;
}
/* BUG-25:首装用户 mwan3 里没有 wan_only/wanb_only 策略时,
wire 会静默失效。这里在页面顶部给一条醒目提示 + 一键 init。 */
var pcheck = null;
try {
pcheck = JSON.parse(((res[2] && res[2].stdout) || '').trim() || 'null');
} catch (e) {
pcheck = null;
}
var model = {
mode: 'bind',
policies: [],
isp: [],
dns: [],
domains: []
domains: [],
/* lc:begin */
lc: { enable: true, geo: true, provider: 'auto', interval: 60 }
/* lc:end */
};
out.split('\n').forEach(function(line) {
@@ -146,6 +158,16 @@ return view.extend({
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] });
/* lc:begin */
else if (f[0] === 'line_check_enable')
model.lc.enable = (f[1] || '1') === '1';
else if (f[0] === 'line_check_geo')
model.lc.geo = (f[1] || '1') === '1';
else if (f[0] === 'line_check_provider')
model.lc.provider = f[1] || 'auto';
else if (f[0] === 'line_check_interval')
model.lc.interval = parseInt(f[1], 10) || 60;
/* lc:end */
});
if (!model.isp.length) {
@@ -528,6 +550,86 @@ return view.extend({
}
}, [ svgIcon('plus'), E('span', {}, [_('添加域名')]) ]);
/* lc:begin */
/* ============================================================ 三、线路体检 */
var lcEnable = E('input', { 'type': 'checkbox', 'checked': model.lc.enable ? '' : null });
lcEnable.addEventListener('change', function() { model.lc.enable = lcEnable.checked; });
var lcGeo = E('input', { 'type': 'checkbox', 'checked': model.lc.geo ? '' : null });
lcGeo.addEventListener('change', function() {
model.lc.geo = lcGeo.checked;
lcProvider.disabled = !lcGeo.checked;
});
var lcProvider = E('select', { 'class': 'cbi-input-select', 'disabled': model.lc.geo ? null : '' },
[
['auto', _('自动(百度优先,失败转 ip-api)')],
['baidu', _('只用百度(HTTPS)')],
['ip-api', _('只用 ip-api(HTTP,明文)')],
['off', _('关闭')]
].map(function(o) {
return E('option', { 'value': o[0], 'selected': model.lc.provider === o[0] ? '' : null }, [ o[1] ]);
}));
lcProvider.addEventListener('change', function() { model.lc.provider = lcProvider.value; });
var lcInterval = E('input', {
'type': 'number', 'class': 'cbi-input-text', 'min': '15', 'max': '3600',
'value': String(model.lc.interval || 60)
});
var lcIntervalErr = errBox();
lcInterval.addEventListener('input', function() {
var n = parseInt(lcInterval.value, 10);
model.lc.interval = isNaN(n) ? 0 : n;
if (model.lc.interval < 15 || model.lc.interval > 3600) {
setErr(lcIntervalErr, _('必须是 15-3600 之间的整数秒'));
model.lc._valid = false;
} else {
setErr(lcIntervalErr, '');
model.lc._valid = true;
}
});
model.lc._valid = !(model.lc.interval < 15 || model.lc.interval > 3600);
var lcSection = E('section', { 'class': 'isp-section' }, [
E('h3', {}, [ _('线路体检') ]),
E('p', { 'class': 'isp-desc' }, [
_('对每条 WAN 做出口类型、四段延迟、NAT 与归属地检查。体检只读,不修改 mwan3/nft 规则。')
]),
E('table', { 'class': 'isp-form-table' }, [
E('tbody', {}, [
E('tr', {}, [
E('td', { 'data-th': _('启用') }, [
E('label', { 'class': 'isp-switch' }, [ lcEnable, E('span') ]),
E('div', { 'class': 'lc-hint' }, [ _('关闭后命令行与页面都不再执行体检') ])
]),
E('td', { 'data-th': _('归属地查询') }, [
E('label', { 'class': 'isp-switch' }, [ lcGeo, E('span') ]),
E('div', { 'class': 'lc-hint' }, [
_('关闭后只停止归属地查询;出口 IP 回显、STUN 与延迟探测仍会发出。卡片显示“未查询”')
])
])
]),
E('tr', {}, [
E('td', { 'data-th': _('归属地服务商') }, [ lcProvider ]),
E('td', { 'data-th': _('最小间隔') }, [
E('div', { 'class': 'lc-inline' }, [
lcInterval, E('span', { 'class': 'lc-hint' }, [ _('秒(15-3600)') ])
]),
lcIntervalErr
])
])
])
]),
E('div', { 'class': 'isp-note lc-privacy-note' }, [
_('隐私说明:开启归属地后,体检会把每条 WAN 的出口公网 IP 发送给第三方(默认百度,失败时 ip-api.com)查询归属地。'),
_('只发送出口 IP,不发送内网地址、设备名、MAC、流量内容。ip-api 使用 HTTP,传输不加密;介意时请选择“关闭”或“只用百度”。'),
_('关闭后省/市显示“未查询”,但仍会用本地 @isp_* 集合判断运营商大类(本机判断,不外发)。'),
_('注意:关闭归属地只停止归属地查询;出口 IP 回显、STUN 与延迟探测仍会按配置的服务器发出。要完全停发,请关闭“启用”。')
])
]);
/* lc:end */
/* ============================================================ 四、数据源(只读) */
/* 数据源地址是 root 路径的下载入口,配置页不开放编辑:
@@ -616,7 +718,13 @@ return view.extend({
isp: ispVal,
dns: dnsVal,
dom: domVal,
mode: modeSel.value
mode: modeSel.value,
/* lc:begin */
lcEnable: model.lc.enable ? '1' : '0',
lcGeo: model.lc.geo ? '1' : '0',
lcProvider: model.lc.provider,
lcInterval: String(model.lc.interval || 60)
/* lc:end */
};
}
@@ -633,6 +741,14 @@ return view.extend({
]), 'danger');
ok = false;
}
/* lc:begin */
if (!model.lc._valid) {
ui.addNotification(null, E('p', {}, [
_('线路体检的最小间隔必须是 15-3600 之间的整数秒')
]), 'danger');
ok = false;
}
/* lc:end */
return ok;
}
@@ -675,6 +791,12 @@ return view.extend({
'dns_table', v.dns,
'domain_split', v.dom,
'dns_mode', v.mode
/* lc:begin */
,'line_check_enable', v.lcEnable,
'line_check_geo', v.lcGeo,
'line_check_provider', v.lcProvider,
'line_check_interval', v.lcInterval
/* lc:end */
]).then(function(r) {
showResult(((r.stdout || '') + (r.stderr || '')) || '(无输出)');
ui.addNotification(null, E('p', {}, [_('预检完成,配置未被修改')]), 'info');
@@ -698,6 +820,12 @@ return view.extend({
.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); })
/* lc:begin */
.then(function() { return step(['conf', 'set', 'line_check_enable', v.lcEnable], log); })
.then(function() { return step(['conf', 'set', 'line_check_geo', v.lcGeo], log); })
.then(function() { return step(['conf', 'set', 'line_check_provider', v.lcProvider], log); })
.then(function() { return step(['conf', 'set', 'line_check_interval', v.lcInterval], log); })
/* lc:end */
.then(function() {
if (!apply)
return null;
@@ -719,6 +847,48 @@ return view.extend({
});
}
/* BUG-25:策略缺失/空成员时页面顶部的醒目提示条。
只有 policy-check 明确返回 ok=false 才显示;读不到(旧版脚本/命令失败)
时保持安静,不用一个误报吓用户。 */
var policyBanner = E('div', { 'class': 'isp-banner-wrap' });
if (pcheck && pcheck.ok === false) {
var bad = (pcheck.policies || []).filter(function(p) { return !p.ok; });
var badText = bad.map(function(p) {
return p.name + '(' + (p.detail || _('不可用')) + ')';
}).join('、');
var bannerErr = E('div', { 'class': 'isp-banner-err' });
var bannerBtn;
bannerBtn = E('button', {
'class': 'cbi-button cbi-button-action isp-banner-btn',
'click': function() {
bannerBtn.disabled = true;
bannerBtn.textContent = _('正在初始化…');
bannerErr.textContent = '';
return run(['init', '--assume-yes']).then(function() {
ui.addNotification(null, E('p', {}, [ _('已绑定线路,页面将刷新') ]), 'info');
window.location.reload();
}).catch(function(e) {
bannerBtn.disabled = false;
bannerBtn.textContent = _('重新尝试初始化');
bannerErr.textContent = String(e).replace(/^.*失败([^)]*):/, '');
});
}
}, [ _('运行初始化绑定线路') ]);
policyBanner = E('div', { 'class': 'isp-banner is-warn' }, [
E('div', { 'class': 'isp-banner-msg' }, [
E('strong', {}, [ _('mwan3 策略缺失或没有成员:分流规则不会生效') ]),
E('div', {}, [ _('需要绑定的策略:') + badText ]),
E('div', { 'class': 'isp-banner-hint' }, [
_('按钮会按 ISP 表顺序自动绑定(wan_only→wan1、wanb_only→wan2…);也可以 SSH 执行 '),
E('code', {}, [ 'isp-split init' ]),
_(' 逐个选择接口。')
]),
bannerErr
]),
bannerBtn
]);
}
/* ============================================================ 页面 */
return E('div', { 'class': 'isp-app cbi-map' }, [
@@ -731,6 +901,8 @@ return view.extend({
])
]),
policyBanner,
/* ---- 策略映射 ---- */
E('section', { 'class': 'isp-section' }, [
E('h3', {}, [ _('策略映射') ]),
@@ -802,6 +974,11 @@ return view.extend({
E('div', { 'class': 'isp-btn-row' }, [ addDomBtn ])
]),
/* lc:begin */
/* ---- 线路体检 ---- */
lcSection,
/* lc:end */
/* ---- 数据源(只读) ---- */
E('section', { 'class': 'isp-section' }, [
E('h3', {}, [ _('数据源'), E('span', { 'class': 'isp-tag' }, [ _('只读') ]) ]),
@@ -0,0 +1,581 @@
'use strict';
'require view';
'require fs';
'require ui';
/*
* LuCI「线路体检」页(0.3.0)
*
* 设计注记(BUG-23,为什么 load() 里一行 exec 都没有):
* 这个页面的黄金场景恰恰是“网络最差的时候”:用户怀疑某条线坏了才来体检。
* 而真实探测(STUN 多点 × 超时 + 双线 HTTP 采样)要 21-60 秒,网络越差越慢;
* rpcd 的 exec 是硬超时(VM 上 /etc/config/rpcd timeout=30),超时后 promise
* reject,LuCI 连 render 都不会进——页面只剩菜单,用户在最需要体检的时候
* 拿到一张白屏。所以:
* · load() 只 fs.read 一份缓存快照(state/linecheck/last.json),零 exec;
* · 没缓存就渲染“尚未体检”占位卡,按钮仍然可用;
* · 点按钮 = 让 CLI 起一个后台探测进程(linecheck --background),然后页面
* 每 2.5s 读一次快照比较 generated,最长等 90s。
*
* 数据口径:评分、NAT 五态、四段延迟全部由 shell 侧算好写进 JSON,前端不猜、
* 不算分,避免两套规则。
*/
var CMD = '/usr/sbin/isp-split';
var CACHE = '/etc/isp-split/state/linecheck/last.json';
var LOCK = '/etc/isp-split/state/linecheck.lock';
var POLL_MS = 2500;
var POLL_MAX_MS = 90000;
/* 锁目录超过这个岁数就当崩溃残留,不再让页面“假装在体检” */
var LOCK_STALE_S = 300;
var ICONS = {
refresh: '<path d="M20 11a8 8 0 1 0-2.3 6.3"/><path d="M20 4v7h-7"/>',
pulse: '<path d="M2 12h4l2.5-6 3.5 12 2.5-6H22"/>',
chevron: '<path d="M6 9l6 6 6-6"/>',
shield: '<path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/>'
};
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.pulse) + '</svg>';
return span;
}
/* fs.read 解析出来的是字符串(不是 exec 的 {stdout});失败一律返回 null */
function parseStr(s) {
try {
var v = JSON.parse(String(s || '').trim() || 'null');
return (v && typeof v === 'object') ? v : null;
} catch (e) {
return null;
}
}
function nowSec() {
return Math.floor(Date.now() / 1000);
}
function fmtAge(sec) {
if (sec < 90)
return sec + _(' 秒前');
if (sec < 3600)
return Math.round(sec / 60) + _(' 分钟前');
return Math.round(sec / 3600) + _(' 小时前');
}
function fmtMs(v) {
if (v === null || v === undefined || v === '' || v === 0)
return '—';
return v + 'ms';
}
function fmtNum(v) {
return (v === null || v === undefined) ? '—' : String(v);
}
function gradeClass(g) {
return 'lc-grade is-' + String(g || 'd').toLowerCase();
}
function natClass(type) {
if (type === 'public' || type === 'cone')
return 'is-ok';
if (type === 'symmetric')
return 'is-warn';
if (type === 'cgnat')
return 'is-bad';
return 'is-muted';
}
function stateText(state) {
if (state === 'online')
return _('在线');
if (state === 'offline')
return _('离线');
return _('未知');
}
function classText(c) {
if (c === 'public')
return _('公网');
if (c === 'cgnat')
return _('CGNAT');
if (c === 'private')
return _('私网');
return _('无地址');
}
function geoText(geo) {
if (!geo || geo.status === 'off')
return _('未查询(隐私开关关闭)');
if (geo.status === 'noip')
return _('无公网出口 IP,跳过');
if (geo.status === 'ok')
return (geo.location || '') + (geo.provider ? '(' + geo.provider + ')' : '');
return _('查询失败(不影响评分)');
}
function targetText(t) {
if (!t)
return _('无可用目标');
return t.domain + ' → ' + t.ip + ' · ' + (t.label || t.isp || '?') +
' · ' + (t.mode === 'same_isp' ? _('同运营商') : _('跨网基线'));
}
/* 一行 key/value;value 可以是字符串、DOM 节点,或它们的数组。
*
* 曾经写成 E('span', {class:'lc-val'}, [value]):value 是数组时 LuCI 会把它
* String() 掉,页面上直接显示 “HTMLSpanElement,HTMLSpanElement”
* (WAN IP / 出口 IP / NAT 类型三行都是这样)。数组必须作为 children 展开,
* 并过滤 null(没有 multi_nat 时那一项就是 null),否则会渲染出空文本节点。
*/
function row(label, value) {
var kids = (Array.isArray(value) ? value : [ value ]).filter(function(x) {
return x !== null && x !== undefined;
});
return E('div', { 'class': 'lc-row' }, [
E('span', { 'class': 'lc-key' }, [ label ]),
E('span', { 'class': 'lc-val' }, kids)
]);
}
function seg(label, value) {
return E('div', { 'class': 'lc-seg' }, [
E('span', { 'class': 'lc-seg-label' }, [ label ]),
E('span', { 'class': 'lc-seg-value' }, [ value ])
]);
}
return view.extend({
/* 零 exec:只读缓存快照 + 看一眼锁(判断是否有体检在跑) */
load: function() {
return fs.read(CACHE).then(function(s) {
return { raw: s, data: parseStr(s) };
}).catch(function() {
/* 文件不存在 / 权限不足 / 还没体检过:都按“尚未体检”处理 */
return { raw: null, data: null };
}).then(function(res) {
return L.resolveDefault(fs.stat(LOCK), null).then(function(st) {
var mt = (st && (+st.mtime || 0)) || 0;
res.running = !!mt && (nowSec() - mt) < LOCK_STALE_S;
return res;
}, function() { return res; });
});
},
render: function(loaded) {
var self = this;
this.data = (loaded && loaded.data) || null;
this.baseGen = this.data ? (+this.data.generated || 0) : 0;
this.busy = false;
this._poll = null;
var cards = E('div', { 'id': 'lc-cards', 'class': 'isp-cards lc-cards' });
var meta = E('div', { 'id': 'lc-meta', 'class': 'lc-meta' });
var msg = E('div', { 'id': 'lc-msg', 'class': 'lc-msg' });
this.cardsBox = cards;
this.metaBox = meta;
this.msgBox = msg;
var runBtn = E('button', {
'class': 'cbi-button cbi-button-action lc-run',
'click': function() { self.run(); }
}, [ svgIcon('pulse'), E('span', {}, [ _('开始体检') ]) ]);
this.runBtn = runBtn;
var pill = E('span', { 'id': 'lc-pill', 'class': 'isp-pill' });
this.pill = pill;
this.paint();
/* 打开页面时如果已有一次后台体检在跑(例如刚才的探测还没结束、
或者用户重开了本页),直接进轮询态,不要再点一次。
但如果手上已经有一份没过期的快照,就不必陪着等——那可能只是一
个崩溃残留的锁目录。 */
if (loaded && loaded.running) {
var age = this.data && this.data.generated
? nowSec() - (+this.data.generated) : 1e9;
if (age > (+((this.data || {}).interval) || 60))
this.poll(0);
}
/* 只读页:不能带 cbi-map —— LuCI 见到它会补一条 “Save & Apply” 底栏,
而这一页没有任何可保存的表单,底栏只会误导用户。 */
return 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' }, [
_('逐条 WAN 检查出口类型、四段延迟、NAT 与归属地。只读探测,不改 mwan3/nft 规则;体检在后台跑,页面只读结果,不会卡住。')
]),
E('div', { 'class': 'isp-toolbar' }, [ runBtn, pill ])
]),
E('div', { 'class': 'lc-privacy' }, [
svgIcon('shield', 15),
E('span', {}, [
_('归属地查询只发送每条线的出口公网 IP,不发送内网地址、设备名、MAC 或流量内容;可在「配置 → 线路体检」关闭。')
])
]),
meta,
msg,
cards
]);
},
/* ---------------- 触发后台体检 ---------------- */
run: function() {
var self = this;
if (this.busy)
return;
this.busy = true;
this.paintButton();
this.setMsg(_('正在启动后台体检…'), 'info');
fs.exec(CMD, ['linecheck', '--background']).then(function(res) {
var out = ((res && res.stdout) || '') + ((res && res.stderr) || '');
var d = parseStr((res && res.stdout) || '');
if (!d)
throw new Error(out.trim() || _('linecheck --background 没有返回可解析的状态'));
if (d.started && d.finished)
return self.reload(_('体检完成。'));
if (d.started)
return self.poll(d.pid);
if (d.running) {
self.setMsg(_('已有一次体检在跑(pid ') + d.pid + _(',已 ') + (d.elapsed || 0) +
_(' 秒),继续等它的结果…'), 'info');
return self.poll(d.pid);
}
if (d.cached) {
self.busy = false;
return self.reload(_('60 秒内刚测过,直接显示缓存结果(要强制重测请用命令行 --force)。'));
}
throw new Error(d.error || _('未知状态'));
}).catch(function(err) {
self.busy = false;
self.stopPoll();
self.setMsg(_('启动体检失败:') + String(err), 'bad');
ui.addNotification(null, E('p', {}, [ _('启动体检失败:') + String(err) ]), 'danger');
});
},
/* 轮询:每 2.5s 读一次快照,generated 变大就渲染;最多 90s。 */
poll: function(pid) {
var self = this;
this.busy = true;
this.paintButton();
var start = Date.now();
if (this._poll)
clearInterval(this._poll);
this.setMsg(_('后台体检中(pid ') + (pid || 0) + _('),真实探测 20-60 秒,网络越差越慢…'), 'info');
this._poll = setInterval(function() {
/* 页面被切走/卸载后自己停,别在后台空转 */
if (!self.cardsBox || !document.body.contains(self.cardsBox)) {
self.stopPoll();
return;
}
var waited = Date.now() - start;
if (waited > POLL_MAX_MS) {
self.stopPoll();
self.setMsg(_('后台体检超过 90 秒还没出结果:可能线路极差或探测卡住。稍后重新打开本页即可看到结果,也可用命令行 isp-split linecheck --json 查看。'), 'bad');
return;
}
fs.read(CACHE).then(function(s) {
var d = parseStr(s);
if (!d || (+d.generated || 0) <= self.baseGen)
return;
self.data = d;
self.baseGen = +d.generated || 0;
self.paint();
self.stopPoll();
self.setMsg(_('体检完成。'), 'ok');
}).catch(function() { /* 还没写出来,下一轮再看 */ });
self.setMsg(_('后台体检中(已等待 ') + Math.round(waited / 1000) + _(' 秒)…'), 'info');
}, POLL_MS);
},
stopPoll: function() {
if (this._poll) {
clearInterval(this._poll);
this._poll = null;
}
this.busy = false;
this.paintButton();
},
reload: function(msg) {
var self = this;
return fs.read(CACHE).then(function(s) {
var d = parseStr(s);
if (d) {
self.data = d;
self.baseGen = +d.generated || 0;
}
self.stopPoll();
self.paint();
self.setMsg(msg || _('体检完成。'), 'ok');
}).catch(function() {
self.stopPoll();
self.paint();
self.setMsg(msg || _('还没有结果。'), 'info');
});
},
setMsg: function(t, kind) {
if (!this.msgBox)
return;
this.msgBox.textContent = t || '';
this.msgBox.className = 'lc-msg' + (kind ? ' is-' + kind : '');
},
paintButton: function() {
if (!this.runBtn)
return;
var span = this.runBtn.querySelector('span:last-child');
if (this.busy) {
this.runBtn.disabled = true;
if (span) span.textContent = _('体检中…');
} else {
this.runBtn.disabled = false;
if (span) span.textContent = this.data ? _('重新体检') : _('开始体检');
}
if (!this.pill)
return;
this.pill.className = 'isp-pill' + (this.busy ? ' is-busy' : '');
if (this.busy) {
this.pill.textContent = _('后台体检中…');
} else if (this.data) {
var gen = +this.data.generated || 0;
var age = gen ? Math.max(0, nowSec() - gen) : 0;
var intv = +this.data.interval || 60;
this.pill.textContent = (gen ? fmtAge(age) : _('无时间戳')) +
(age <= intv ? _(' · 缓存有效') : _(' · 已过期'));
} else {
this.pill.textContent = _('尚未体检');
}
},
/* ---------------- 渲染 ---------------- */
paint: function() {
if (!this.cardsBox)
return;
while (this.cardsBox.firstChild)
this.cardsBox.removeChild(this.cardsBox.firstChild);
while (this.metaBox.firstChild)
this.metaBox.removeChild(this.metaBox.firstChild);
this.paintButton();
/* 没缓存:占位卡。绝不为了“有没有数据”去跑一次探测。 */
if (!this.data) {
this.cardsBox.appendChild(E('div', { 'class': 'isp-card lc-placeholder' }, [
E('div', { 'class': 'lc-ph-head' }, [
svgIcon('pulse', 18),
E('strong', {}, [ _('尚未体检') ])
]),
E('p', { 'class': 'lc-ph-text' }, [
_('点上方「开始体检」:探测会在后台跑(逐条 WAN 检查出口类型、四段延迟、NAT、归属地),通常 20-60 秒,网络差时更久。本页不会卡住,中途可以离开。')
]),
E('p', { 'class': 'lc-ph-note' }, [
_('结果会缓存在设备上,60 秒内再次打开本页直接显示。')
])
]));
return;
}
var d = this.data;
var gen = +d.generated || 0;
var age = gen ? Math.max(0, nowSec() - gen) : 0;
var intv = +d.interval || 60;
var bits = [];
bits.push(_('版本 ') + (d.version || '?'));
if (gen)
bits.push(_('生成于 ') + new Date(gen * 1000).toLocaleString() + '(' + fmtAge(age) + ')');
bits.push(age <= intv ? _('缓存有效') : _('已过期,可重新体检'));
bits.push(_('缓存间隔 ') + intv + 's');
bits.push(d.socat ? _('socat 可用') : _('socat 未安装(NAT 只能判未知)'));
bits.push(d.geo_enabled ? _('归属地查询开') : _('归属地查询关'));
this.metaBox.textContent = bits.join(' · ');
var wans = d.wans || [];
if (!wans.length) {
this.cardsBox.appendChild(E('div', { 'class': 'isp-empty' }, [
(d.errors && d.errors.length)
? d.errors.join(';')
: _('没有可体检的 WAN。先在 mwan3 启用接口,或在命令行用 --wan 指定。')
]));
return;
}
for (var i = 0; i < wans.length; i++)
this.cardsBox.appendChild(this.card(wans[i], d));
},
card: function(w, d) {
var sc = w.score || {};
var nat = w.nat || {};
var lat = w.latency || {};
var geo = w.geo || {};
var http = w.http || {};
var detail = E('div', { 'class': 'lc-detail', 'hidden': '' });
var toggle = E('button', {
'class': 'lc-toggle',
'click': function() {
if (detail.hasAttribute('hidden')) {
detail.removeAttribute('hidden');
toggle.classList.add('is-open');
toggle.lastChild.textContent = _('收起明细');
} else {
detail.setAttribute('hidden', '');
toggle.classList.remove('is-open');
toggle.lastChild.textContent = _('查看明细');
}
}
}, [ svgIcon('chevron', 14), E('span', {}, [ _('查看明细') ]) ]);
var multi = w.multi_nat
? E('span', { 'class': 'lc-tag is-warn' }, [ _('多层 NAT/代理') ])
: null;
var natTag = E('span', { 'class': 'lc-tag ' + natClass(nat.type) }, [ nat.label || _('未知') ]);
var card = E('article', {
'class': 'isp-card lc-card',
'style': '--lc-accent: ' + (sc.grade === 'A' ? 'var(--isp-ok)' :
sc.grade === 'B' ? 'var(--isp-primary)' :
sc.grade === 'C' ? 'var(--isp-warn)' : 'var(--isp-bad)')
}, [
E('div', { 'class': 'lc-head' }, [
E('div', { 'class': 'lc-title' }, [
E('span', { 'class': 'lc-name' }, [ w.iface || '?' ]),
E('span', { 'class': 'lc-dev' }, [ w.device || '' ]),
E('span', { 'class': 'lc-state ' + (w.up ? 'is-on' : 'is-off') }, [
stateText(w.state)
])
]),
E('div', { 'class': 'lc-score' }, [
E('span', { 'class': gradeClass(sc.grade) }, [ sc.grade || 'D' ]),
E('span', { 'class': 'lc-total' }, [ fmtNum(sc.total) ]),
E('span', { 'class': 'lc-max' }, [ '/100' ])
])
]),
E('div', { 'class': 'lc-kv' }, [
row(_('WAN IP'), [
E('span', {}, [ w.wan_ip || '—' ]),
E('span', { 'class': 'lc-tag is-muted' }, [ classText(w.ip_class) ])
]),
row(_('出口 IP'), [
E('span', {}, [ w.exit_ip || _('未取到(可能未真正出网)') ]),
multi
]),
row(_('运营商'), [ w.isp_label || _('未知') ]),
row(_('NAT 类型'), [ natTag,
E('span', { 'class': 'lc-sub' }, [
nat.confidence === 'high' ? _('置信度高')
: nat.confidence === 'medium' ? _('置信度中(仅一个探测点)')
: _('置信度未知')
])
]),
row(_('归属地'), [ geoText(geo) ])
]),
E('div', { 'class': 'lc-segs' }, [
seg('DNS', fmtMs(lat.dns_ms)),
seg('TCP', fmtMs(lat.tcp_ms)),
seg('TLS', fmtMs(lat.tls_ms)),
seg(_('首字节'), fmtMs(lat.ttfb_ms))
]),
E('div', { 'class': 'lc-line2' }, [
E('span', {}, [ _('抖动 ') + fmtNum(lat.jitter_ms) + 'ms' ]),
E('span', {}, [ _('丢包 ') + fmtNum(lat.loss_pct) + '%' ]),
E('span', {}, [ _('HTTP ') + fmtNum(http.code) ]),
E('span', {}, [ _('目标:') + targetText(w.target) ])
]),
E('div', { 'class': 'lc-advice' + (sc.grade === 'D' || nat.type === 'cgnat' ? ' is-bad' : '') }, [
svgIcon('pulse', 15),
E('span', {}, [ sc.advice || _('无建议') ])
]),
toggle,
detail
]);
/* 明细:评分拆分 + 硬上限 + STUN 探测 + 原始目标/延迟 */
var tbody = E('tbody', {});
(sc.breakdown || []).forEach(function(b) {
tbody.appendChild(E('tr', {}, [
E('td', { 'data-th': _('项目') }, [ b.label || b.key ]),
E('td', { 'data-th': _('观测') }, [ b.value || '—' ]),
E('td', { 'data-th': _('得分') }, [ (b.points || 0) + ' / ' + (b.max || 0) ]),
E('td', { 'data-th': _('依据') }, [ b.reason || '—' ])
]));
});
detail.appendChild(E('h4', {}, [ _('评分明细') ]));
detail.appendChild(E('table', { 'class': 'lc-table' }, [
E('thead', {}, [ E('tr', {}, [
E('th', {}, [_('项目')]), E('th', {}, [_('观测')]),
E('th', {}, [_('得分')]), E('th', {}, [_('依据')])
]) ]),
tbody
]));
if (sc.caps && sc.caps.length) {
var ul = E('ul', { 'class': 'lc-caps' });
sc.caps.forEach(function(c) { ul.appendChild(E('li', {}, [ c ])); });
detail.appendChild(E('h4', {}, [ _('硬上限') ]));
detail.appendChild(ul);
}
var probes = nat.probes || [];
if (probes.length) {
var pt = E('tbody', {});
probes.forEach(function(p) {
pt.appendChild(E('tr', {}, [
E('td', { 'data-th': _('服务器') }, [ p.server || '—' ]),
E('td', { 'data-th': 'IP' }, [ p.ip || '—' ]),
E('td', { 'data-th': _('状态') }, [ p.status === 'ok' ? _('成功') : p.status === 'bad' ? _('坏包') : _('超时') ]),
E('td', { 'data-th': _('映射') }, [ p.mapped || '—' ]),
E('td', { 'data-th': 'RTT' }, [ p.rtt_ms ? p.rtt_ms + 'ms' : '—' ])
]));
});
detail.appendChild(E('h4', {}, [ _('STUN 探测') + (nat.detail ? ':' + nat.detail : '') ]));
detail.appendChild(E('table', { 'class': 'lc-table' }, [
E('thead', {}, [ E('tr', {}, [
E('th', {}, [_('服务器')]), E('th', {}, ['IP']),
E('th', {}, [_('状态')]), E('th', {}, [_('映射')]), E('th', {}, ['RTT'])
]) ]),
pt
]));
}
detail.appendChild(E('div', { 'class': 'lc-raw' }, [
_('目标:') + targetText(w.target) + ' · ' +
_('尝试 ') + fmtNum(lat.attempts) + _(' 次 / 成功 ') + fmtNum(lat.ok_count) + _(' 次 · ') +
_('本地 STUN 端口 ') + fmtNum(nat.local_port)
]));
return card;
},
/* 只读页:把三个保存钩子显式置空,LuCI 才不会补一条 “Save & Apply / Reset”
底栏(overview.js 早就这么写了,linecheck 漏了)。这一页没有任何可保存的
表单,底栏只会让人以为有未保存的改动。 */
handleSave: null,
handleSaveApply: null,
handleReset: null
});
@@ -0,0 +1,10 @@
{
"admin/network/isp-split/linecheck": {
"title": "线路体检",
"order": 3,
"action": {
"type": "view",
"path": "isp-split/linecheck"
}
}
}
@@ -8,7 +8,8 @@
"file": {
"/etc/isp-split.conf": [ "read" ],
"/etc/isp-split": [ "read" ],
"/etc/isp-split/*": [ "read" ]
"/etc/isp-split/*": [ "read" ],
"/etc/isp-split/state/*": [ "read" ]
}
},
"write": {
+125
View File
@@ -0,0 +1,125 @@
#!/bin/sh
# 构建期剥离线路体检(FEATURE_LINECHECK=0)。
#
# 用法:sh strip-linecheck.sh <staging-root>
#
# 这是**唯一**的剥离实现。手工打包(scripts/build-ipk.sh)与 SDK/buildroot
# (主包 / LuCI 包两份 Makefile 的 Build/Prepare)都必须调用它,不许再手抄
# sed。F7 的根因就是 SDK 侧另抄了三行 sed:只覆盖 config.js,漏了 htdocs 的
# isp-split.css,于是本地(走本脚本,全树扫)、VM 全绿,24.10 SDK 产物泄漏。
#
# 源文件里用成对标记包住 feature 专属内容(shell/conf/js/css 用 `lc:begin` /
# `lc:end` 注释,Markdown 用 `<!-- lc:begin -->` / `<!-- lc:end -->`)。
# 标记必须包住内容本体,不能只包一行标题注释:CSS 曾经只包了标题,`.lc-*`
# 样式体裸奔进包,而“grep linecheck 字符串”的断言看不见它——所以本脚本除了
# 字符串零残留,还断言标记配对、剥离后标记清零、CSS 里不留 `.lc-*` 样式类。
#
# 这不是运行时开关:删掉的内容不会进入包、不会进入安装目录,也不存在
# 一个环境变量或 UCI 键能把它打开。
set -eu
ROOT=${1:-}
[ -n "$ROOT" ] || { echo "用法: sh strip-linecheck.sh <staging-root>" >&2; exit 1; }
[ -d "$ROOT" ] || { echo "目录不存在: $ROOT" >&2; exit 1; }
WORK=$(mktemp -d 2>/dev/null) || WORK=$(mktemp -d /tmp/lcstrip.XXXXXX) ||
{ echo "无法创建临时目录" >&2; exit 1; }
trap 'rm -rf "$WORK"' EXIT
# 0) 标记配对自检:缺 lc:end 时 sed 会静默删到文件末尾,必须在剥离前拦住。
cat > "$WORK/check-marks.sh" <<'EOS'
#!/bin/sh
rc=0
for f do
grep -Iq 'lc:begin\|lc:end' "$f" 2>/dev/null || continue
awk '
FNR == 1 { d = 0 }
/lc:begin/ { d++ }
/lc:end/ {
d--
if (d < 0) {
printf "lc:end 出现在 lc:begin 之前: %s\n", FILENAME > "/dev/stderr"
bad = 1
exit 1
}
}
END {
if (!bad && d != 0) {
printf "lc:begin 缺 lc:end(%d 层未闭合): %s\n", d, FILENAME > "/dev/stderr"
exit 1
}
}
' "$f" || { echo "标记不配对: $f" >&2; rc=1; }
done
exit $rc
EOS
find "$ROOT" -type f -exec sh "$WORK/check-marks.sh" {} \; || exit 1
del_blocks() {
_f=$1
grep -Iq 'lc:begin' "$_f" 2>/dev/null || return 0
_tmp=$_f.lcstrip.$$
sed '/lc:begin/,/lc:end/d' "$_f" > "$_tmp" || { rm -f "$_tmp"; return 1; }
# 保留原权限位(isp-split 必须仍可执行)
chmod --reference="$_f" "$_tmp" 2>/dev/null || chmod 644 "$_tmp"
mv -f "$_tmp" "$_f"
}
# 1) 文本文件里的标记区
find "$ROOT" -type f | while IFS= read -r f; do
del_blocks "$f" || touch "$WORK/del-failed"
done
[ ! -e "$WORK/del-failed" ] || { echo "剥离时有文件写入失败" >&2; exit 1; }
# 2) feature=1 专属文件
find "$ROOT" -type f \
\( -name 'linecheck.js' -o -name 'lc-run' -o -name 'luci-app-isp-split-linecheck.json' \) \
-exec rm -f {} +
# 3) 空目录顺手收掉(LuCI view 目录只有 linecheck.js 时)
find "$ROOT" -type d -empty -delete 2>/dev/null || true
# 4) 零残留断言(白名单为空)
_leak=$(grep -rIl 'linecheck\|LINE_CHECK\|线路体检' "$ROOT" 2>/dev/null || true)
if [ -n "$_leak" ]; then
echo "剥离失败,以下文件仍有 linecheck 残留:" >&2
printf '%s\n' "$_leak" | sed 's/^/ /' >&2
exit 1
fi
# 4b) 标记必须被吃干净(配对自检通过 + sed 正常时必然成立;写死断言防回归)
_marks=$(grep -rIl 'lc:begin\|lc:end' "$ROOT" 2>/dev/null || true)
if [ -n "$_marks" ]; then
echo "剥离失败,以下文件仍残留 lc:begin / lc:end 标记:" >&2
printf '%s\n' "$_marks" | sed 's/^/ /' >&2
exit 1
fi
# 4c) CSS:标记必须包住样式体。`.lc-*` 不含 linecheck 字样,字符串断言抓不到,
# 所以单独断言;顺手断言花括号平衡,防止把规则体切成半截。
find "$ROOT" -type f -name '*.css' | while IFS= read -r _css; do
if grep -q '\.lc-' "$_css"; then
echo "CSS 仍有 .lc-* 样式(标记没有包住样式体): $_css" >&2
touch "$WORK/fail"
fi
# 用 tr 数花括号:老 busybox awk 对 gsub(/{/) 会报 "bad regex"
_o=$(tr -cd '{' <"$_css" | wc -c | tr -d ' \n')
_c=$(tr -cd '}' <"$_css" | wc -c | tr -d ' \n')
if [ "$_o" != "$_c" ]; then
echo "CSS 花括号不平衡(剥离切坏了规则体): $_css({=$_o }=$_c)" >&2
touch "$WORK/fail"
fi
done
[ ! -e "$WORK/fail" ] || exit 1
# 5) 语法自检(脚本仍能被 shell 解析)
_main=$ROOT/usr/sbin/isp-split
if [ -f "$_main" ]; then
sh -n "$_main" || { echo "剥离后 isp-split 语法错误" >&2; exit 1; }
fi
_conf=$ROOT/etc/isp-split.conf
if [ -f "$_conf" ]; then
sh -n "$_conf" || { echo "剥离后 isp-split.conf 语法错误" >&2; exit 1; }
fi
echo "已剥离线路体检: $ROOT"