mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-14 20:48:17 +08:00
🎨 Sync 2026-10-29 20:07:12
This commit is contained in:
1 parent
0546d1d8e4
commit
737acd5a18
182 files changed
+10470
-1649
No files matched your search
@@ -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
|
||||
});
|
||||
Reference in new issue
Block a user