mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-09-14 12:24:20 +08:00
1989 lines
81 KiB
JavaScript
1989 lines
81 KiB
JavaScript
'use strict';
|
||
'require view';
|
||
'require poll';
|
||
'require ui';
|
||
'require rpc';
|
||
'require uci';
|
||
'require tools.clashoo as clashoo';
|
||
|
||
var CSS = [
|
||
'.cl-wrap{padding:8px 0}',
|
||
'.cl-wrap{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}',
|
||
'.cl-wrap{--cl-live-title-color:rgba(40,50,68,.62);--cl-live-main-color:rgba(84,96,116,.92);--cl-live-sub-color:rgba(74,86,106,.72);--cl-live-foot-color:rgba(74,86,106,.72);--cl-live-zero-color:rgba(128,128,128,.5)}',
|
||
'body.dark .cl-wrap,body.dark .cl-wrap,body[data-theme="dark"] .cl-wrap,body[data-darkmode="1"] .cl-wrap,body[data-darkmode="true"] .cl-wrap,html.dark .cl-wrap,html.dark .cl-wrap,html[data-theme="dark"] .cl-wrap,html[data-bs-theme="dark"] .cl-wrap,html[data-darkmode="1"] .cl-wrap,html[data-darkmode="true"] .cl-wrap{--cl-live-title-color:rgba(230,238,252,.74);--cl-live-main-color:rgba(228,236,248,.9);--cl-live-sub-color:rgba(208,219,238,.72);--cl-live-foot-color:rgba(208,219,238,.72)}',
|
||
'.cl-status-kernel{display:inline-flex;align-items:center;gap:2px;padding:2px;border-radius:8px;border:0;background:rgba(128,128,128,.1)}',
|
||
'.cl-status-kernel .cl-core-btn{height:24px;line-height:24px;min-width:60px;padding:0 10px;border:0;border-radius:6px;background:transparent;font-size:11px;font-weight:500;color:var(--cl-live-main-color);opacity:.6;cursor:pointer;transition:background-color .18s ease,color .18s ease,opacity .18s ease}',
|
||
'.cl-status-kernel .cl-core-btn.active{background:linear-gradient(#3886a1,#2f7288);border:0;color:#fff;opacity:1;font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.12)}',
|
||
'.cl-status-kernel .cl-core-btn:disabled{cursor:not-allowed}',
|
||
'.cl-status-kernel.is-busy{opacity:.6}',
|
||
'.cl-core-switch-wrap{display:flex;flex-direction:column;align-items:flex-start;gap:6px}',
|
||
'.cl-core-switch-hint{font-size:12px;line-height:1.35;color:rgba(74,86,106,.72)}',
|
||
'body.dark .cl-core-switch-hint,html[data-theme="dark"] .cl-core-switch-hint,html[data-bs-theme="dark"] .cl-core-switch-hint{color:rgba(208,219,238,.72)}',
|
||
'.cl-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:16px}',
|
||
'.cl-card{border:1px solid rgba(0,0,0,.05);border-radius:10px;padding:14px 16px;min-height:70px;box-shadow:0 4px 12px rgba(0,0,0,.03)}',
|
||
'.cl-card-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:4px}',
|
||
'.cl-card .lbl{font-size:11px;opacity:.55;margin:0}',
|
||
'.cl-card-tools{display:flex;align-items:center;gap:6px}',
|
||
'.cl-icon-btn{border:0;background:transparent;padding:0;width:16px;height:16px;line-height:16px;font-size:13px;opacity:.55;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;color:inherit}',
|
||
'.cl-icon-btn:hover{opacity:.9}',
|
||
'.cl-icon-btn.is-loading{animation:cl-spin 1s linear infinite;pointer-events:none;opacity:.75}',
|
||
'.cl-card .val{font-size:11px;font-weight:500;word-break:break-all}',
|
||
'.cl-card-mode .val{line-height:1.45;word-break:normal}',
|
||
'.cl-mode-stack{display:flex;flex-direction:column;gap:4px;justify-content:flex-start;align-items:flex-start}',
|
||
'.cl-mode-tag{display:flex;align-items:center;justify-content:flex-start;padding:0;border-radius:0;font-size:11px;line-height:1.35;background:transparent;color:inherit;opacity:.5;width:max-content;max-width:100%;text-align:left}',
|
||
'body.dark .cl-mode-tag,body.dark .cl-mode-tag,body[data-theme="dark"] .cl-mode-tag,body[data-darkmode="1"] .cl-mode-tag,body[data-darkmode="true"] .cl-mode-tag,html.dark .cl-mode-tag,html.dark .cl-mode-tag,html[data-theme="dark"] .cl-mode-tag,html[data-bs-theme="dark"] .cl-mode-tag,html[data-darkmode="1"] .cl-mode-tag,html[data-darkmode="true"] .cl-mode-tag{background:transparent;color:inherit;opacity:.5}',
|
||
'.cl-mode-tag-degraded{color:#b56f00}',
|
||
'.cl-check-note{display:block;margin-top:6px;font-size:11px;line-height:1.45;opacity:.6;font-weight:400}',
|
||
'.cl-badge{display:inline-flex;align-items:center;justify-content:center;padding:2px 12px;border-radius:999px;font-size:11px;font-weight:700;line-height:1.2;background:transparent;border:1px solid transparent}',
|
||
'.cl-badge-run{background:transparent;color:#4aa065;border-color:rgba(74,160,101,.46)}',
|
||
'.cl-badge-stop{background:transparent;color:#df6e6e;border-color:rgba(223,110,110,.52)}',
|
||
'.cl-badge-sync{background:rgba(128,128,128,.08);color:rgba(128,128,128,.92);border-color:rgba(128,128,128,.35)}',
|
||
'.cl-service-tools{display:inline-flex;align-items:center;gap:8px;white-space:nowrap}',
|
||
'.cl-service-label{font-size:11px;font-weight:600;opacity:.62}',
|
||
'.cl-service-switch{position:relative;width:48px;height:26px;border:0;border-radius:999px;background:rgba(128,128,128,.22);padding:2px;box-shadow:none;cursor:pointer;transition:background .18s ease,opacity .18s ease}',
|
||
'.cl-service-switch .cl-service-knob{position:absolute;top:3px;left:3px;width:20px;height:20px;border-radius:50%;background:rgba(255,255,255,.94);box-shadow:0 1px 4px rgba(0,0,0,.18);transition:transform .18s ease,background .18s ease}',
|
||
'.cl-service-switch.is-on{background:rgba(74,160,101,.52)}',
|
||
'.cl-service-switch.is-on .cl-service-knob{transform:translateX(22px)}',
|
||
'.cl-service-switch:disabled{opacity:.48;cursor:not-allowed}',
|
||
'.cl-controls{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:16px}',
|
||
'.cl-ctrl{border:0 !important;border-radius:8px;padding:10px 12px;box-shadow:none !important;background:rgba(128,128,128,.04)}',
|
||
'.cl-ctrl label{display:block;font-size:11px;opacity:.55;margin-bottom:6px}',
|
||
'.cl-ctrl select{width:100%;font-size:13px;box-sizing:border-box}',
|
||
'.cl-ctrl-row{display:flex;gap:6px;align-items:center}',
|
||
'.cl-ctrl-row select{flex:1;min-width:0}',
|
||
'.cl-live-box{border:0 !important;border-radius:10px;padding:12px 14px;box-shadow:none !important;background:transparent}',
|
||
'.cl-traffic-wrap{margin-bottom:12px}',
|
||
'.cl-live-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}',
|
||
'.cl-live-card,.cl-stat-card{position:relative;height:120px;min-height:120px;max-height:120px;box-sizing:border-box;border:0 !important;border-radius:12px;overflow:hidden;box-shadow:0 2px 10px rgba(0,0,0,.03)}',
|
||
'.cl-live-content{position:relative;z-index:2;padding:11px 12px}',
|
||
'.cl-live-bg-chart{position:absolute;left:8px;right:8px;bottom:8px;height:42px;z-index:1;pointer-events:none;opacity:.92}',
|
||
'.cl-live-title{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600;margin-bottom:8px;color:var(--cl-live-title-color)}',
|
||
'.cl-live-main{display:flex;align-items:flex-end;gap:6px;line-height:1;margin-bottom:14px}',
|
||
'.cl-live-num{font-size:18px;font-weight:800;letter-spacing:-1px;color:var(--cl-live-main-color,rgba(84,96,116,.92));transition:color .18s ease,opacity .18s ease,text-shadow .22s ease,font-size .18s ease}',
|
||
'.cl-live-unit{font-size:11px;font-weight:600;margin-left:3px;padding-bottom:2px;color:var(--cl-live-sub-color,rgba(74,86,106,.72));transition:color .18s ease,opacity .18s ease}',
|
||
'.cl-live-svg{display:block;width:100%;height:100%}',
|
||
'.cl-live-line{fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round}',
|
||
'.cl-live-area{opacity:.16}',
|
||
'.cl-live-foot{font-size:11px;font-weight:500;color:var(--cl-live-foot-color,rgba(74,86,106,.72));transition:color .18s ease,opacity .18s ease}',
|
||
'.cl-live-card.is-active .cl-live-num{color:var(--cl-live-main-color,rgba(84,96,116,.92))!important}',
|
||
'.cl-live-card.is-zero .cl-live-num{font-size:16px;color:var(--cl-live-zero-color)!important;opacity:1;text-shadow:none}',
|
||
'.cl-live-card.is-zero .cl-live-unit,.cl-live-card.is-zero .cl-live-foot{color:var(--cl-live-zero-color)!important;opacity:1}',
|
||
'.cl-live-card.is-zero .cl-live-bg-chart{opacity:0!important}',
|
||
'.cl-live-card.is-zero .cl-live-line,.cl-live-card.is-zero .cl-live-area{stroke:transparent!important;fill:transparent!important}',
|
||
'.cl-card-access .val{font-size:11px;font-weight:500}',
|
||
'.cl-check-modern{display:flex;flex-direction:column;gap:10px}',
|
||
'.cl-check-group{display:flex;flex-direction:column;gap:6px}',
|
||
'.cl-check-group-title{font-size:11px;line-height:1;opacity:.55;font-weight:600;letter-spacing:.2px}',
|
||
'.cl-check-item{display:flex;align-items:center;justify-content:flex-start}',
|
||
'.cl-check-main{display:flex;align-items:center;gap:10px;min-width:0}',
|
||
'.cl-check-icon-wrap{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;background:transparent!important;border:0!important;border-radius:0!important;box-shadow:none!important;padding:0!important;overflow:visible}',
|
||
'.cl-check-icon{width:18px;height:18px;object-fit:contain;display:block;flex-shrink:0;background:transparent!important;border:0!important;box-shadow:none!important;border-radius:0!important;padding:0!important;outline:0!important}',
|
||
'.cl-check-item img,.cl-check-main img{background:transparent!important;border:0!important;box-shadow:none!important;border-radius:0!important;padding:0!important;outline:0!important}',
|
||
'.cl-check-icon-fallback{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;border-radius:50%;font-size:10px;font-weight:700;background:rgba(31,157,85,.12);color:#1f9d55;flex-shrink:0}',
|
||
'.cl-check-name{font-size:11px;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:100px;min-width:100px;max-width:100px;flex:0 0 100px}',
|
||
'.cl-check-latency{margin-left:20px;width:78px;min-width:78px;text-align:left;font-size:11px;font-weight:600;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace;font-variant-numeric:tabular-nums}',
|
||
'.cl-lat-green{color:#2aa85b}',
|
||
'.cl-lat-warn{color:#d39e00}',
|
||
'.cl-lat-red{color:#c96a5a}',
|
||
'.cl-signal{display:inline-flex;align-items:flex-end;justify-content:flex-start;gap:2px;width:30px;min-width:30px;margin-left:8px}',
|
||
'.cl-signal i{display:block;width:4px;border-radius:2px;background:rgba(120,120,120,.25)}',
|
||
'.cl-signal i:nth-child(1){height:6px}',
|
||
'.cl-signal i:nth-child(2){height:9px}',
|
||
'.cl-signal i:nth-child(3){height:12px}',
|
||
'.cl-signal i:nth-child(4){height:15px}',
|
||
'.cl-signal-green i.active{background:#2aa85b}',
|
||
'.cl-signal-warn i.active{background:#d39e00}',
|
||
'.cl-signal-red i.active{background:#c96a5a}',
|
||
'.cl-signal-offline{opacity:.78;transform:translateX(3px)}',
|
||
'.cl-check-updated{margin-top:6px;font-size:10px;line-height:1.3;opacity:.5}',
|
||
'.cl-check-skeleton{display:flex;flex-direction:column;gap:10px}',
|
||
'.cl-skel-row{display:flex;align-items:center;gap:10px}',
|
||
'.cl-skel-icon{width:18px;height:18px;border-radius:4px;background:rgba(128,128,128,.16)}',
|
||
'.cl-skel-line{height:10px;border-radius:999px;flex:1;background:linear-gradient(90deg,rgba(128,128,128,.10) 25%,rgba(128,128,128,.22) 50%,rgba(128,128,128,.10) 75%);background-size:220% 100%;animation:cl-shimmer 1.3s linear infinite}',
|
||
'.cl-skel-line.short{max-width:90px}',
|
||
'.cl-check-empty{font-size:12px;opacity:.55}',
|
||
'.cl-wrap .cbi-input-text,.cl-wrap .cbi-input-select,.cl-wrap select,.cl-wrap input,.cl-wrap textarea,.cl-wrap .btn,.cl-wrap .cbi-button{font-size:13px !important;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif !important}',
|
||
'.cl-wrap .btn,.cl-wrap .cbi-button{padding:4px 10px;line-height:1.35}',
|
||
'@keyframes cl-fadein{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:translateY(0)}}',
|
||
'@keyframes cl-spin{to{transform:rotate(360deg)}}',
|
||
'@keyframes cl-shimmer{0%{background-position:220% 0}100%{background-position:-220% 0}}',
|
||
'.cl-op-msg{font-size:12px;font-weight:500;opacity:.85;animation:cl-fadein .25s ease}',
|
||
/* 简易节点面板(模态浮层)*/
|
||
'.cl-pm-open{display:inline-flex;align-items:center;height:24px;line-height:1;padding:0;border:0 !important;background:transparent !important;box-shadow:none !important;font-size:12px;font-weight:500;color:var(--cl-primary,rgba(0,122,255,.9));cursor:pointer;white-space:nowrap}',
|
||
'.cl-pm-open:hover{opacity:.7}',
|
||
'#cl-core-switch-inline-wrap{display:flex;align-items:center;gap:14px;flex-wrap:wrap}',
|
||
'.cl-pm-mask{display:none;position:fixed;inset:0;background:rgba(0,0,0,.52);z-index:99998}',
|
||
'.cl-pm-mask.cl-on{display:block}',
|
||
'.cl-pm{display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:min(900px,calc(100vw - 28px));max-height:calc(100vh - 56px);overflow:auto;background:#fff;color:#2a3142;border:1px solid rgba(0,0,0,.08);border-radius:12px;box-shadow:0 22px 60px rgba(0,0,0,.38);z-index:99999;padding:0 22px 18px;opacity:1}',
|
||
'.cl-pm.cl-on{display:block}',
|
||
'.cl-pm-mask.cl-theme-dark{background:rgba(0,0,0,.66)}',
|
||
'.cl-pm.cl-theme-dark{background:#1f242c;color:#e4ecf8;border-color:rgba(255,255,255,.12);box-shadow:0 22px 68px rgba(0,0,0,.62)}',
|
||
'.cl-pm-head{position:sticky;top:0;background:inherit;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:15px 0 11px;border-bottom:1px solid rgba(128,128,128,.16)}',
|
||
'.cl-pm.cl-theme-dark .cl-pm-head{border-bottom-color:rgba(255,255,255,.12)}',
|
||
'.cl-pm-ttl{font-size:16px;font-weight:600;display:flex;align-items:baseline;gap:10px}',
|
||
'.cl-pm-st{font-size:12px;font-weight:500}',
|
||
'.cl-pm-acts{display:flex;gap:8px}',
|
||
'.cl-pm-close{font-size:12px;padding:5px 14px;border-radius:7px;border:1px solid rgba(128,128,128,.3);background:transparent;color:inherit;cursor:pointer}',
|
||
'.cl-pm.cl-theme-dark .cl-pm-close,.cl-pm.cl-theme-dark .cl-pm-test{background:#252a33;color:#e4ecf8;border-color:rgba(255,255,255,.24)}',
|
||
'.cl-pm-body{display:flex;flex-direction:column;gap:9px;padding-top:12px}',
|
||
'.cl-pm-empty{font-size:13px;opacity:.6;padding:34px 4px;text-align:center}',
|
||
'.cl-pm-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}',
|
||
'.cl-pm-name{flex:0 0 170px;text-align:right;font-size:13px;font-weight:500;display:flex;justify-content:flex-end;align-items:center;gap:6px;word-break:break-all}',
|
||
'.cl-pm-badge{font-size:10px;font-weight:600;padding:1px 6px;border-radius:8px;background:rgba(128,128,128,.16);opacity:.72;flex:none}',
|
||
'.cl-pm.cl-theme-dark .cl-pm-badge{background:rgba(255,255,255,.1);color:rgba(228,236,248,.82)}',
|
||
'.cl-pm-sel{flex:1;min-width:190px;font-size:13px;padding:7px 10px;border:1px solid rgba(128,128,128,.3);border-radius:8px;background:transparent;color:inherit}',
|
||
'.cl-pm-now{flex:1;min-width:190px;font-size:13px;padding:7px 10px;border:1px dashed rgba(128,128,128,.32);border-radius:8px;opacity:.78}',
|
||
'.cl-pm.cl-theme-dark .cl-pm-sel,.cl-pm.cl-theme-dark .cl-pm-now{background:#252a33;color:#e4ecf8;border-color:rgba(255,255,255,.22)}',
|
||
'.cl-pm.cl-theme-dark .cl-pm-sel option{background:#252a33;color:#e4ecf8}',
|
||
'.cl-pm-test{font-size:12px;padding:6px 13px;border-radius:7px;border:1px solid rgba(128,128,128,.3);background:transparent;color:inherit;cursor:pointer;flex:none}',
|
||
'.cl-pm-test[disabled]{opacity:.5;cursor:default}',
|
||
'.cl-dly{font-size:11px;font-weight:600}',
|
||
'.cl-dly-g{color:#2e9b51}',
|
||
'.cl-dly-y{color:#c98a16}',
|
||
'.cl-dly-r{color:#d65c4a}',
|
||
'.cl-dly-x{opacity:.4;font-weight:400}',
|
||
'@media(max-width:900px){.cl-cards{grid-template-columns:repeat(2,1fr)}.cl-controls{grid-template-columns:repeat(2,1fr)}}',
|
||
'@media(max-width:640px){.cl-live-grid{grid-template-columns:1fr}}',
|
||
'@media(max-width:480px){.cl-cards{grid-template-columns:1fr}.cl-controls{grid-template-columns:1fr}.cl-live-grid{grid-template-columns:1fr}}'
|
||
].join('');
|
||
|
||
var callUpdateCurrentSubscription = rpc.declare({ object: 'luci.clashoo', method: 'update_current_subscription', expect: {} });
|
||
var callOverview = rpc.declare({ object: 'luci.clashoo', method: 'overview', expect: {} });
|
||
var callProxiesList = rpc.declare({ object: 'luci.clashoo', method: 'proxies_list', expect: {} });
|
||
var callProxySelect = rpc.declare({ object: 'luci.clashoo', method: 'proxy_select', params: ['group', 'name'], expect: {} });
|
||
var callProxyDelay = rpc.declare({ object: 'luci.clashoo', method: 'proxy_delay', params: ['group'], expect: {} });
|
||
function fastResolve(promise, timeoutMs, fallback) {
|
||
var t = new Promise(function (resolve) {
|
||
setTimeout(function () { resolve(fallback); }, timeoutMs);
|
||
});
|
||
return Promise.race([L.resolveDefault(promise, fallback), t]);
|
||
}
|
||
|
||
function rpcOverview() {
|
||
var fallback = {
|
||
status: {},
|
||
stats: {},
|
||
configs: { configs: [], current: '', core_type: '' },
|
||
access: {}
|
||
};
|
||
return L.resolveDefault(callOverview(), fallback);
|
||
}
|
||
|
||
/* Progress message sequences — displayed step-by-step during async operations */
|
||
var MSGS = {
|
||
mihomo: {
|
||
start: [
|
||
_("Starting client"),
|
||
_("Checking configuration file"),
|
||
_("Setting mihomo network rules"),
|
||
_("Setting DNS forwarder / enabling custom DNS"),
|
||
_("Setting Cron scheduled tasks and starting process supervisor..."),
|
||
_("Restarting Dnsmasq"),
|
||
_("mihomo started successfully, please wait for servers to come online.")
|
||
],
|
||
stop: [
|
||
_("Stopping client..."),
|
||
_("Cleaning mihomo network rules"),
|
||
_("Disabling DNS cache"),
|
||
_("Stopping mihomo process supervisor"),
|
||
_("Removing Cron scheduled tasks"),
|
||
_("Restarting Dnsmasq")
|
||
],
|
||
restart: [
|
||
_("Restarting client..."),
|
||
_("Cleaning mihomo network rules"),
|
||
_("Stopping mihomo"),
|
||
_("Starting client"),
|
||
_("Checking configuration file"),
|
||
_("Setting mihomo network rules"),
|
||
_("Restarting Dnsmasq"),
|
||
_("mihomo restarted successfully, please wait for servers to come online.")
|
||
]
|
||
},
|
||
singbox: {
|
||
start: [
|
||
_("Starting client"),
|
||
_("Checking configuration file"),
|
||
_("Setting sing-box network rules"),
|
||
_("Setting DNS forwarder / enabling custom DNS"),
|
||
_("Setting Cron scheduled tasks and starting process supervisor..."),
|
||
_("Restarting Dnsmasq"),
|
||
_("sing-box started successfully, please wait for servers to come online.")
|
||
],
|
||
stop: [
|
||
_("Stopping client..."),
|
||
_("Cleaning sing-box network rules"),
|
||
_("Disabling DNS cache"),
|
||
_("Stopping sing-box process supervisor"),
|
||
_("Removing Cron scheduled tasks"),
|
||
_("Restarting Dnsmasq")
|
||
],
|
||
restart: [
|
||
_("Restarting client..."),
|
||
_("Cleaning sing-box network rules"),
|
||
_("Stopping sing-box"),
|
||
_("Starting client"),
|
||
_("Checking configuration file"),
|
||
_("Setting sing-box network rules"),
|
||
_("Restarting Dnsmasq"),
|
||
_("sing-box restarted successfully, please wait for servers to come online.")
|
||
]
|
||
}
|
||
};
|
||
|
||
return view.extend({
|
||
_busy: false,
|
||
_op: null, /* 'start' | 'stop' | 'restart' | null */
|
||
_opTimers: null, /* array of setTimeout IDs for cleanup */
|
||
_accessRefreshing: false,
|
||
_coreSwitchBusy: false,
|
||
_coreSwitchMsg: '',
|
||
_rtHistory: null,
|
||
|
||
_lastAc: null,
|
||
_lastSt: null,
|
||
_lastCfgData: null,
|
||
|
||
load: function () {
|
||
return Promise.resolve(null);
|
||
},
|
||
|
||
_overviewCacheKey: 'clashoo.overview.last',
|
||
|
||
_readCachedOverview: function () {
|
||
try {
|
||
if (!window.sessionStorage)
|
||
return null;
|
||
var raw = window.sessionStorage.getItem(this._overviewCacheKey);
|
||
if (!raw)
|
||
return null;
|
||
var cached = JSON.parse(raw);
|
||
if (!cached || !cached._ts || (Date.now() - cached._ts) > 60000)
|
||
return null;
|
||
if (!cached.status || typeof cached.status.running !== 'boolean')
|
||
return null;
|
||
return cached;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
},
|
||
|
||
_writeCachedOverview: function (ov) {
|
||
try {
|
||
if (!window.sessionStorage || !ov || !ov.status || typeof ov.status.running !== 'boolean')
|
||
return;
|
||
var copy = JSON.parse(JSON.stringify(ov));
|
||
copy._ts = Date.now();
|
||
window.sessionStorage.setItem(this._overviewCacheKey, JSON.stringify(copy));
|
||
} catch (e) {}
|
||
},
|
||
|
||
_initialOverview: function () {
|
||
var core = uci.get('clashoo', 'config', 'core_type') || 'mihomo';
|
||
var useConfig = uci.get('clashoo', 'config', 'use_config') || '';
|
||
var singboxActive = uci.get('clashoo', 'config', 'singbox_active') || '';
|
||
var slash = useConfig.lastIndexOf('/');
|
||
var configName = slash >= 0 ? useConfig.slice(slash + 1) : useConfig;
|
||
if (core === 'singbox' && singboxActive)
|
||
configName = singboxActive;
|
||
|
||
return {
|
||
status: {
|
||
loading: true,
|
||
running: null,
|
||
core_type: core,
|
||
dash_port: uci.get('clashoo', 'config', 'dash_port') || '9090',
|
||
dash_pass: uci.get('clashoo', 'config', 'dash_pass') || '',
|
||
panel_type: uci.get('clashoo', 'config', 'dashboard_panel') || 'zashboard',
|
||
proxy_mode: uci.get('clashoo', 'config', 'p_mode') || 'rule',
|
||
tcp_mode: uci.get('clashoo', 'config', 'tcp_mode') || 'tun',
|
||
udp_mode: uci.get('clashoo', 'config', 'udp_mode') || (uci.get('clashoo', 'config', 'tcp_mode') || 'tun'),
|
||
stack: uci.get('clashoo', 'config', 'stack') || 'system',
|
||
config: configName,
|
||
local_ip: location.hostname,
|
||
health_status: 'loading'
|
||
},
|
||
stats: {},
|
||
configs: {
|
||
configs: configName ? [configName] : [],
|
||
current: configName,
|
||
core_type: core
|
||
},
|
||
access: {
|
||
updated_at: '0',
|
||
updating: true,
|
||
items: []
|
||
}
|
||
};
|
||
},
|
||
|
||
render: function (data) {
|
||
var ov = this._readCachedOverview() || this._initialOverview();
|
||
var st = ov.status || {};
|
||
var cfgData = ov.configs || { configs: [], current: '' };
|
||
var ac = ov.access || {};
|
||
var stats = ov.stats || {};
|
||
|
||
if (!document.getElementById('cl-css')) {
|
||
var s = document.createElement('style');
|
||
s.id = 'cl-css'; s.textContent = CSS;
|
||
document.head.appendChild(s);
|
||
}
|
||
if (!document.getElementById('cl-css-ext')) {
|
||
var link = document.createElement('link');
|
||
link.id = 'cl-css-ext';
|
||
link.rel = 'stylesheet';
|
||
link.href = L.resource('view/clashoo/clashoo.css') + '?v=20260806a1';
|
||
document.head.appendChild(link);
|
||
} else {
|
||
document.getElementById('cl-css-ext').href = L.resource('view/clashoo/clashoo.css') + '?v=20260806a1';
|
||
}
|
||
|
||
this._lastSt = st;
|
||
this._lastCfgData = cfgData;
|
||
this._lastAc = ac;
|
||
this._overviewLoaded = false;
|
||
|
||
var root = E('div', { 'class': 'cl-wrap clashoo-container cl-overview-page' }, [
|
||
E('div', { 'class': 'cl-cards', id: 'cl-cards' }, this._cards(st, cfgData, ac)),
|
||
E('div', { 'class': 'cl-traffic-wrap', id: 'cl-traffic-wrap' }, [
|
||
this._card(_("Traffic Monitor"), this._renderRealtimePanel(), 'cl-card-traffic')
|
||
]),
|
||
E('div', { 'class': 'cl-controls', id: 'cl-controls' }, this._controls(st, cfgData)),
|
||
this._renderProxyModal()
|
||
]);
|
||
this._rootEl = root;
|
||
this._applyThemeClass();
|
||
this._observeThemeChanges();
|
||
this._updateRealtimePanel(stats);
|
||
this._loadUciInBackground();
|
||
|
||
poll.add(L.bind(this._pollOverview, this), 5);
|
||
this._pollOverview();
|
||
|
||
return root;
|
||
},
|
||
|
||
_loadUciInBackground: function () {
|
||
var self = this;
|
||
setTimeout(function () {
|
||
L.resolveDefault(uci.load('clashoo'), null).then(function () {
|
||
if (self._overviewLoaded || !self._rootEl)
|
||
return;
|
||
var ov = self._initialOverview();
|
||
self._lastCfgData = ov.configs || { configs: [], current: '' };
|
||
|
||
var controls = document.getElementById('cl-controls');
|
||
if (controls) {
|
||
controls.innerHTML = '';
|
||
self._controls(self._lastSt || ov.status || {}, self._lastCfgData).forEach(function (ctrl) {
|
||
controls.appendChild(ctrl);
|
||
});
|
||
}
|
||
});
|
||
}, 0);
|
||
},
|
||
|
||
_applyThemeClass: function () {
|
||
if (!this._rootEl || !this._rootEl.classList)
|
||
return;
|
||
var dark = this._isDarkUi();
|
||
this._rootEl.classList.toggle('cl-theme-dark', !!dark);
|
||
this._rootEl.classList.toggle('cl-theme-light', !dark);
|
||
},
|
||
|
||
_observeThemeChanges: function () {
|
||
var self = this;
|
||
if (self._themeObserver) {
|
||
try { self._themeObserver.disconnect(); } catch (e0) {}
|
||
self._themeObserver = null;
|
||
}
|
||
if (self._themeMedia && self._themeMediaHandler) {
|
||
try {
|
||
if (self._themeMedia.removeEventListener)
|
||
self._themeMedia.removeEventListener('change', self._themeMediaHandler);
|
||
else if (self._themeMedia.removeListener)
|
||
self._themeMedia.removeListener(self._themeMediaHandler);
|
||
} catch (e00) {}
|
||
self._themeMedia = null;
|
||
self._themeMediaHandler = null;
|
||
}
|
||
if (typeof MutationObserver === 'undefined')
|
||
return self._applyThemeClass();
|
||
|
||
var obs = new MutationObserver(function (mutations) {
|
||
for (var i = 0; i < mutations.length; i++) {
|
||
var a = mutations[i] && mutations[i].attributeName;
|
||
if (!a)
|
||
continue;
|
||
if (a === 'class' || a === 'data-theme' || a === 'data-bs-theme' || a === 'data-darkmode') {
|
||
self._applyThemeClass();
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
var opt = { attributes: true, attributeFilter: ['class', 'data-theme', 'data-bs-theme', 'data-darkmode'] };
|
||
try { obs.observe(document.documentElement, opt); } catch (e1) {}
|
||
if (document.body) {
|
||
try { obs.observe(document.body, opt); } catch (e2) {}
|
||
}
|
||
self._themeObserver = obs;
|
||
|
||
if (window.matchMedia) {
|
||
var mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||
var handler = function () { self._applyThemeClass(); };
|
||
try {
|
||
if (mq.addEventListener)
|
||
mq.addEventListener('change', handler);
|
||
else if (mq.addListener)
|
||
mq.addListener(handler);
|
||
} catch (e3) {}
|
||
self._themeMedia = mq;
|
||
self._themeMediaHandler = handler;
|
||
}
|
||
|
||
self._applyThemeClass();
|
||
},
|
||
|
||
_isDarkUi: function () {
|
||
var docEl = document.documentElement || {};
|
||
var body = document.body || {};
|
||
var theme = String(
|
||
(docEl.getAttribute && (docEl.getAttribute('data-theme') || docEl.getAttribute('data-bs-theme'))) ||
|
||
(body.getAttribute && (body.getAttribute('data-theme') || body.getAttribute('data-bs-theme'))) ||
|
||
''
|
||
).toLowerCase();
|
||
var darkMode = String(
|
||
(docEl.getAttribute && docEl.getAttribute('data-darkmode')) ||
|
||
(body.getAttribute && body.getAttribute('data-darkmode')) ||
|
||
''
|
||
).toLowerCase();
|
||
|
||
if (theme === 'dark' || darkMode === '1' || darkMode === 'true')
|
||
return true;
|
||
if (theme === 'light' || darkMode === '0' || darkMode === 'false')
|
||
return false;
|
||
|
||
var cls = String((docEl.className || '') + ' ' + (body.className || '')).toLowerCase();
|
||
if (/(^|\s)(dark|theme-dark|darkmode)(\s|$)/.test(cls))
|
||
return true;
|
||
if (/(^|\s)(light|theme-light)(\s|$)/.test(cls))
|
||
return false;
|
||
|
||
try {
|
||
var links = document.querySelectorAll('link[rel="stylesheet"]');
|
||
for (var i = 0; i < links.length; i++) {
|
||
var href = String(links[i].href || '').toLowerCase();
|
||
if (href.indexOf('dark.css') >= 0 || href.indexOf('darkmode') >= 0 || href.indexOf('argon-dark') >= 0)
|
||
return true;
|
||
}
|
||
} catch (e0) {}
|
||
|
||
/* Fallback: inspect sidebar brightness for themes that don't expose standard attrs */
|
||
try {
|
||
var byLuminance = function (node) {
|
||
if (!node)
|
||
return null;
|
||
var bg = window.getComputedStyle(node).backgroundColor || '';
|
||
var rgb = String(bg).match(/\d+/g);
|
||
if (rgb && rgb.length >= 3) {
|
||
var sum = (parseInt(rgb[0], 10) || 0) +
|
||
(parseInt(rgb[1], 10) || 0) +
|
||
(parseInt(rgb[2], 10) || 0);
|
||
return sum < 380;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
var sidebar = document.querySelector('.main-sidebar, .main-left, .sidebar, #mainmenu, #menubar, .left');
|
||
var darkFromSidebar = byLuminance(sidebar);
|
||
if (darkFromSidebar != null)
|
||
return darkFromSidebar;
|
||
|
||
var darkFromBody = byLuminance(body);
|
||
if (darkFromBody != null)
|
||
return darkFromBody;
|
||
} catch (e) {}
|
||
|
||
if (window.matchMedia)
|
||
return !!window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||
|
||
return false;
|
||
},
|
||
|
||
_coreLabel: function (family, channel) {
|
||
if (!family)
|
||
return _("Not running");
|
||
var f = family === 'singbox' ? 'sing-box' : 'mihomo';
|
||
if (channel !== 'smart' && channel !== 'alpha')
|
||
return _(family === 'singbox' ? "sing-box Stable" : "mihomo Stable");
|
||
var c = channel === 'smart' ? _("Smart") : _("Alpha");
|
||
return f + ' ' + c;
|
||
},
|
||
|
||
_configuredChannel: function (family, st) {
|
||
var dcore = (st && st.dcore) || uci.get('clashoo', 'config', 'dcore') || '2';
|
||
if (family === 'singbox')
|
||
return dcore === '5' ? 'alpha' : 'stable';
|
||
if (dcore === '1') return 'smart';
|
||
return dcore === '3' ? 'alpha' : 'stable';
|
||
},
|
||
|
||
_currentCoreType: function (st) {
|
||
return (st && st.core_type) || uci.get('clashoo', 'config', 'core_type') || 'mihomo';
|
||
},
|
||
|
||
_effectiveCore: function (st) {
|
||
var family = this._currentCoreType(st);
|
||
if (family === 'singbox') return 'singbox';
|
||
/* 优先用 ubus status 实时返回的 dcore,避免 uci.get LuCI 缓存切换后滞后 */
|
||
var dcore = (st && st.dcore) || uci.get('clashoo', 'config', 'dcore') || '2';
|
||
return dcore === '1' ? 'smart' : 'mihomo';
|
||
},
|
||
|
||
_refreshCoreSwitch: function () {
|
||
var wrap = document.getElementById('cl-core-switch-inline-wrap');
|
||
if (!wrap) return;
|
||
wrap.innerHTML = '';
|
||
wrap.appendChild(this._renderCoreSwitch(this._lastSt || {}));
|
||
wrap.appendChild(this._proxyTriggerBtn());
|
||
},
|
||
|
||
_showCoreSwitchHint: function (msg) {
|
||
var self = this;
|
||
self._coreSwitchMsg = msg;
|
||
self._refreshCoreSwitch();
|
||
setTimeout(function () {
|
||
if (self._coreSwitchMsg === msg) {
|
||
self._coreSwitchMsg = '';
|
||
self._refreshCoreSwitch();
|
||
}
|
||
}, 3500);
|
||
},
|
||
|
||
_switchCore: function (targetCore) {
|
||
var self = this;
|
||
if (self._coreSwitchBusy)
|
||
return Promise.resolve();
|
||
|
||
if (targetCore === 'smart' && self._lastSt && self._lastSt.has_smart === false) {
|
||
self._showCoreSwitchHint(_("Smart core not detected; updating the model will not install the core. Download the Smart build from the System page."));
|
||
return Promise.resolve();
|
||
}
|
||
|
||
var currentEffective = self._effectiveCore(self._lastSt || {});
|
||
if (targetCore === currentEffective)
|
||
return Promise.resolve();
|
||
|
||
var currentDcore = (self._lastSt && self._lastSt.dcore) || uci.get('clashoo', 'config', 'dcore') || '2';
|
||
var rpcCore, nextDcore, targetLabel;
|
||
if (targetCore === 'smart') {
|
||
rpcCore = 'mihomo'; nextDcore = '1'; targetLabel = 'Smart';
|
||
} else if (targetCore === 'singbox') {
|
||
rpcCore = 'singbox';
|
||
if (currentDcore === '5' && self._lastSt.has_singbox_alpha)
|
||
nextDcore = '5';
|
||
else if (currentDcore === '4' && self._lastSt.has_singbox_stable)
|
||
nextDcore = '4';
|
||
else
|
||
nextDcore = self._lastSt.has_singbox_stable ? '4' : '5';
|
||
targetLabel = 'sing-box';
|
||
} else {
|
||
rpcCore = 'mihomo';
|
||
if (currentDcore === '3' && self._lastSt.has_mihomo_alpha)
|
||
nextDcore = '3';
|
||
else if (currentDcore === '2' && self._lastSt.has_mihomo_stable)
|
||
nextDcore = '2';
|
||
else
|
||
nextDcore = self._lastSt.has_mihomo_stable ? '2' : '3';
|
||
targetLabel = 'mihomo';
|
||
}
|
||
|
||
self._coreSwitchBusy = true;
|
||
self._coreSwitchMsg = _("Switching to ") + targetLabel + '...';
|
||
self._refreshCoreSwitch();
|
||
|
||
return clashoo.setCore(rpcCore, nextDcore, 'apply')
|
||
.then(function (r) {
|
||
if (r && r.success === false && r.error === 'smart_core_missing') {
|
||
self._showCoreSwitchHint(_("Smart core not detected; updating the model will not install the core. Download the Smart build from the System page."));
|
||
throw { soft: true };
|
||
}
|
||
if (r && r.success === false && r.error === 'singbox_core_missing') {
|
||
self._showCoreSwitchHint(_("sing-box core not detected; download sing-box from the System page"));
|
||
throw { soft: true };
|
||
}
|
||
if (r && r.success === false)
|
||
throw new Error(r.message || _("Core switch failed"));
|
||
self._lastSt = self._lastSt || {};
|
||
self._lastSt.core_type = rpcCore;
|
||
self._lastSt.dcore = nextDcore; /* 乐观更新,避免重绘前 uci 缓存滞后 */
|
||
if (!r || !r.restarting) {
|
||
self._lastSt.running = false;
|
||
self._lastSt.health_status = 'stopped';
|
||
}
|
||
return r;
|
||
})
|
||
.then(function (r) {
|
||
/* set_core auto-enables smart_auto_switch; init.d at boot
|
||
* smart_inject 会把 url-test/load-balance 转成 Smart 策略,无需手动操作。 */
|
||
if (!r || !r.restarting)
|
||
self._coreSwitchMsg = _("Switched to ") + targetLabel + _(", click Start to take effect");
|
||
self._refreshCoreSwitch();
|
||
return new Promise(function (resolve) { setTimeout(resolve, 500); });
|
||
})
|
||
.then(function () { return self._pollStatus(); })
|
||
.catch(function (e) {
|
||
if (e && e.soft)
|
||
return;
|
||
/* card already surfaces this via _coreSwitchMsg; no toast needed */
|
||
self._coreSwitchMsg = _("Switch failed: ") + (e.message || e);
|
||
self._refreshCoreSwitch();
|
||
})
|
||
.then(function () {
|
||
self._coreSwitchBusy = false;
|
||
setTimeout(function () {
|
||
self._coreSwitchMsg = '';
|
||
self._refreshCoreSwitch();
|
||
}, 900);
|
||
});
|
||
},
|
||
|
||
/* Inline panel-update feedback: button shows progress, status span shows result.
|
||
Replaces the old fire-and-forget toast so success/failure is visible in place. */
|
||
_runPanelUpdate: function (panel, btn, statusEl) {
|
||
var self = this;
|
||
if (btn.disabled) return;
|
||
var label = self._panelLabel(panel);
|
||
var orig = btn.textContent;
|
||
self._panelUpdateBusy = true; /* pause overview poll so the rebuild won't wipe this feedback */
|
||
btn.disabled = true;
|
||
btn.textContent = _("Downloading…");
|
||
statusEl.textContent = '';
|
||
statusEl.className = 'cl-panel-status';
|
||
|
||
var done = function (cls, text) {
|
||
self._panelUpdateBusy = false;
|
||
btn.disabled = false;
|
||
btn.textContent = orig;
|
||
statusEl.textContent = text;
|
||
statusEl.className = 'cl-panel-status ' + cls;
|
||
setTimeout(function () {
|
||
statusEl.textContent = '';
|
||
statusEl.className = 'cl-panel-status';
|
||
}, 6000);
|
||
};
|
||
|
||
var polls = 0;
|
||
var poll = function () {
|
||
clashoo.panelStatus().then(function (s) {
|
||
s = s || {};
|
||
if (s.state === 'success') done('ok', '✓ ' + label + _(" updated"));
|
||
else if (s.state === 'error') done('err', '✗ ' + (s.msg || _("Update failed")));
|
||
else if (polls++ >= 600) done('err', _("✗ Panel update timeout"));
|
||
else {
|
||
statusEl.textContent = s.msg || _("Downloading…");
|
||
setTimeout(poll, 1200);
|
||
}
|
||
});
|
||
};
|
||
|
||
clashoo.updatePanel(panel)
|
||
.then(function (r) {
|
||
if (r && r.busy) {
|
||
statusEl.textContent = r.message || _("Panel is downloading");
|
||
setTimeout(poll, 600);
|
||
return;
|
||
}
|
||
if (!r || r.success === false) {
|
||
done('err', '✗ ' + ((r && r.message) || _("Submission failed")));
|
||
return;
|
||
}
|
||
setTimeout(poll, 600);
|
||
})
|
||
.catch(function () { done('err', _("✗ Submission failed")); });
|
||
},
|
||
|
||
_renderCoreSwitch: function (st) {
|
||
var self = this;
|
||
var effective = this._effectiveCore(st);
|
||
var note = this._coreSwitchMsg || '';
|
||
|
||
var mkBtn = function (core, label) {
|
||
var active = core === effective;
|
||
var missingSmart = core === 'smart' && st && st.has_smart === false;
|
||
return E('button', {
|
||
type: 'button',
|
||
title: missingSmart ? _("Smart core not detected; updating the model will not install the core") : null,
|
||
'class': 'cl-core-btn' + (active ? ' active' : ''),
|
||
disabled: self._coreSwitchBusy ? '' : null,
|
||
click: function (ev) {
|
||
ev.preventDefault();
|
||
self._switchCore(core);
|
||
}
|
||
}, label);
|
||
};
|
||
|
||
var children = [
|
||
E('div', {
|
||
'class': 'cl-status-kernel' + (this._coreSwitchBusy ? ' is-busy' : ''),
|
||
title: note || null
|
||
}, [
|
||
mkBtn('mihomo', 'Mihomo'),
|
||
mkBtn('smart', 'Smart'),
|
||
mkBtn('singbox', 'Sing-box')
|
||
])
|
||
];
|
||
if (note)
|
||
children.push(E('div', { 'class': 'cl-core-switch-hint' }, note));
|
||
|
||
return E('div', { 'class': 'cl-core-switch-wrap' }, children);
|
||
},
|
||
|
||
_proxyModeLabel: function (mode) {
|
||
var map = { rule: _("Rule"), global: _("Global"), direct: _("Direct") };
|
||
return map[mode] || mode || '—';
|
||
},
|
||
|
||
_panelLabel: function (name) {
|
||
var map = {
|
||
metacubexd: 'MetaCubeXD',
|
||
yacd: 'YACD',
|
||
zashboard: 'Zashboard',
|
||
razord: 'Razord'
|
||
};
|
||
return map[name] || name || _("Panel");
|
||
},
|
||
|
||
_renderServiceSwitch: function (st) {
|
||
var self = this;
|
||
var known = st && typeof st.running === 'boolean';
|
||
var running = known && st.running === true;
|
||
var disabled = this._busy || !known || this._coreSwitchBusy;
|
||
return E('div', { 'class': 'cl-service-tools' }, [
|
||
E('span', { 'class': 'cl-service-label' }, _("Enable Service")),
|
||
E('button', {
|
||
type: 'button',
|
||
title: !known ? _("Syncing runtime state") : (running ? _("Stop Service") : _("Start Service")),
|
||
'aria-pressed': running ? 'true' : 'false',
|
||
'class': 'cl-service-switch' + (running ? ' is-on' : ' is-off'),
|
||
disabled: disabled ? '' : null,
|
||
click: function (ev) {
|
||
ev.preventDefault();
|
||
if (disabled) return;
|
||
return running ? self._stop() : self._start();
|
||
}
|
||
}, [E('span', { 'class': 'cl-service-knob' })])
|
||
]);
|
||
},
|
||
|
||
_cards: function (st, cfgData, ac) {
|
||
var statusKnown = st && typeof st.running === 'boolean';
|
||
var running = statusKnown && st.running === true;
|
||
var health = st.health_status || 'unknown';
|
||
var configuredCoreLabel = this._coreLabel(st.core_type, this._configuredChannel(st.core_type, st));
|
||
|
||
var statusChildren = [
|
||
!statusKnown
|
||
? E('span', { 'class': 'cl-status-state cl-status-state-sync' }, _("Syncing"))
|
||
: running
|
||
? E('span', { 'class': 'cl-status-state cl-status-state-run' }, _("Running 🟢"))
|
||
: E('span', { 'class': 'cl-status-state cl-status-state-stop' }, _("Stopped") + ' ⚪'),
|
||
E('span', { 'class': 'cl-status-note cl-status-note-core' }, configuredCoreLabel)
|
||
];
|
||
if (st.runtime_degraded)
|
||
statusChildren.push(E('span', { 'class': 'cl-status-note cl-status-note-degraded' }, _("Running degraded")));
|
||
if (running && health === 'fail')
|
||
statusChildren.push(E('span', { 'class': 'cl-status-note cl-status-note-fail' }, _("Health check failed")));
|
||
else if (running && health === 'pass')
|
||
statusChildren.push(E('span', { 'class': 'cl-status-note cl-status-note-pass' }, _("Health check passed")));
|
||
var statusEl = E('span', { id: 'cl-status-val' }, statusChildren);
|
||
|
||
return [
|
||
this._card(_("Runtime Status"), statusEl, 'cl-card-status',
|
||
this._renderServiceSwitch(st)),
|
||
this._card(_("Core Switch"),
|
||
E('div', { id: 'cl-core-switch-inline-wrap' }, [
|
||
this._renderCoreSwitch(st),
|
||
this._proxyTriggerBtn()
|
||
]),
|
||
'cl-card-kernel'
|
||
),
|
||
this._card(_("Transparent Proxy"), this._renderTpMode(st), 'cl-card-mode'),
|
||
this._card(_("Access Check"), this._renderCheckStatus(st, ac), 'cl-card-access', this._renderAccessRefresh(ac))
|
||
];
|
||
},
|
||
|
||
/* Directly update the status card content without rebuilding all cards */
|
||
_showOpMsg: function (msg) {
|
||
var el = document.getElementById('cl-status-val');
|
||
if (!el) return;
|
||
el.innerHTML = '';
|
||
el.appendChild(E('span', { 'class': 'cl-op-msg' }, msg));
|
||
},
|
||
|
||
/* Schedule message animation: space messages evenly over (totalMs - tailMs) */
|
||
_startMsgAnim: function (messages, totalMs) {
|
||
var self = this;
|
||
var tailMs = 2500; /* last message stays visible for tailMs before poll */
|
||
var spanMs = Math.max(totalMs - tailMs, 1000);
|
||
var stepMs = messages.length > 1 ? Math.floor(spanMs / (messages.length - 1)) : spanMs;
|
||
self._opTimers = [];
|
||
messages.forEach(function (msg, i) {
|
||
var t = setTimeout(function () {
|
||
self._showOpMsg(msg);
|
||
}, i * stepMs);
|
||
self._opTimers.push(t);
|
||
});
|
||
},
|
||
|
||
_clearOpTimers: function () {
|
||
if (this._opTimers) {
|
||
this._opTimers.forEach(function (t) { clearTimeout(t); });
|
||
this._opTimers = null;
|
||
}
|
||
},
|
||
|
||
_tpModeName: function (mode) {
|
||
var m = String(mode || '').toLowerCase();
|
||
var map = {
|
||
redirect: 'Redirect',
|
||
redir: 'Redirect',
|
||
tproxy: 'TProxy',
|
||
tun: 'TUN',
|
||
off: _("Off")
|
||
};
|
||
if (map[m]) return map[m];
|
||
if (!mode) return '—';
|
||
return String(mode);
|
||
},
|
||
|
||
_stackLabel: function (stack) {
|
||
var s = String(stack || '').toLowerCase();
|
||
var map = { gvisor: 'gVisor', system: 'System', mixed: 'Mixed' };
|
||
return map[s] || (stack ? String(stack) : 'System');
|
||
},
|
||
|
||
_tpModeRows: function (st) {
|
||
var tcpRaw = st.tcp_node || st.effective_tcp_mode || st.tcp_mode || uci.get('clashoo', 'config', 'tcp_mode') || 'off';
|
||
var udpRaw = st.udp_node || st.effective_udp_mode || st.udp_mode || uci.get('clashoo', 'config', 'udp_mode') || tcpRaw;
|
||
var stackRaw = st.stack_type || st.stack || uci.get('clashoo', 'config', 'stack') || 'system';
|
||
var tunEnabled = String(tcpRaw).toLowerCase() === 'tun' || String(udpRaw).toLowerCase() === 'tun';
|
||
|
||
return {
|
||
rows: [
|
||
{ proto: 'TCP', mode: this._tpModeName(tcpRaw), suffix: String(tcpRaw).toLowerCase() === 'off' ? '' : _("Mode") },
|
||
{ proto: 'UDP', mode: this._tpModeName(udpRaw), suffix: String(udpRaw).toLowerCase() === 'off' ? '' : _("Mode") },
|
||
{ proto: _("Network Stack"), mode: tunEnabled ? this._stackLabel(stackRaw) : _("Not enabled"), suffix: tunEnabled ? _("Mode") : '' }
|
||
]
|
||
};
|
||
},
|
||
|
||
_isTpModeDegraded: function (st) {
|
||
if (!st)
|
||
return false;
|
||
|
||
if (st.runtime_degraded)
|
||
return true;
|
||
|
||
var tcp = String(st.tcp_mode || uci.get('clashoo', 'config', 'tcp_mode') || '').toLowerCase();
|
||
var udp = String(st.udp_mode || uci.get('clashoo', 'config', 'udp_mode') || tcp).toLowerCase();
|
||
var wantsTun = tcp === 'tun' || udp === 'tun';
|
||
return wantsTun && String(st.has_tun_device) === '0';
|
||
},
|
||
|
||
_renderTpMode: function (st) {
|
||
var data = this._tpModeRows(st);
|
||
var rows = data.rows.map(function (row) {
|
||
return E('div', { 'class': 'cl-mode-row' }, [
|
||
E('span', { 'class': 'cl-mode-proto' }, row.proto),
|
||
E('span', { 'class': 'cl-mode-name' }, row.mode),
|
||
E('span', { 'class': 'cl-mode-suffix' }, row.suffix)
|
||
]);
|
||
});
|
||
if (this._isTpModeDegraded(st))
|
||
rows.push(E('div', { 'class': 'cl-check-updated cl-mode-degraded' }, _("Degraded")));
|
||
|
||
return E('div', { id: 'cl-tpmode', 'class': 'cl-mode-stack' }, rows);
|
||
},
|
||
|
||
_renderAccessRefresh: function (ac) {
|
||
var self = this;
|
||
/* 核心未运行时访问检查无意义,强制不转圈,避免守护进程周期探测一直转 */
|
||
var running = !!(this._lastSt && this._lastSt.running);
|
||
var loading = running && !!(this._accessRefreshing || (ac && ac.updating));
|
||
return E('button', {
|
||
type: 'button',
|
||
id: 'cl-access-refresh',
|
||
title: _("Refresh Access Check"),
|
||
'class': 'cl-icon-btn' + (loading ? ' is-loading' : ''),
|
||
click: function (ev) {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
self._manualRefreshAccess();
|
||
}
|
||
}, '↻');
|
||
},
|
||
|
||
_card: function (lbl, val, extraCls, tool) {
|
||
var head = [
|
||
E('div', { 'class': 'lbl' }, lbl)
|
||
];
|
||
if (tool)
|
||
head.push(E('div', { 'class': 'cl-card-tools' }, [tool]));
|
||
|
||
return E('div', { 'class': 'cl-card' + (extraCls ? (' ' + extraCls) : '') }, [
|
||
E('div', { 'class': 'cl-card-head' }, head),
|
||
E('div', { 'class': 'val' }, [val])
|
||
]);
|
||
},
|
||
|
||
_toInt: function (v) {
|
||
var n = parseInt(v, 10);
|
||
return isFinite(n) && n > 0 ? n : 0;
|
||
},
|
||
|
||
/* ── 简易节点面板(模态浮层)── */
|
||
_delayBadge: function (ms) {
|
||
ms = parseInt(ms, 10) || 0;
|
||
if (ms <= 0) return E('span', { 'class': 'cl-dly cl-dly-x' }, '—');
|
||
var cls = ms < 100 ? 'cl-dly-g' : (ms < 300 ? 'cl-dly-y' : 'cl-dly-r');
|
||
return E('span', { 'class': 'cl-dly ' + cls }, ms + 'ms');
|
||
},
|
||
|
||
_pgTypeLabel: function (t) {
|
||
var m = { 'Selector': _("Selector"), 'URLTest': _("Url-test"), 'Fallback': _("Fallback"),
|
||
'LoadBalance': _("Load-balance"), 'Relay': _("Relay") };
|
||
return m[t] || t;
|
||
},
|
||
|
||
_proxyTriggerBtn: function () {
|
||
var self = this;
|
||
var btn = E('button', { 'class': 'cl-pm-open' }, _("Node Panel ›"));
|
||
btn.addEventListener('click', function () { self._openProxyModal(); });
|
||
return btn;
|
||
},
|
||
|
||
_setPmStatus: function (text, tone) {
|
||
var el = this._pmStatusEl;
|
||
if (!el) return;
|
||
el.textContent = text || '';
|
||
el.style.color = tone === 'ok' ? '#2e9b51' : (tone === 'err' ? '#d65c4a' : '');
|
||
},
|
||
|
||
_renderProxyModal: function () {
|
||
var self = this;
|
||
var statusEl = E('span', { 'class': 'cl-pm-st' }, '');
|
||
var body = E('div', { 'class': 'cl-pm-body', id: 'cl-pm-body' }, []);
|
||
this._pmStatusEl = statusEl;
|
||
this._pmBodyEl = body;
|
||
|
||
var refreshBtn = E('button', { 'class': 'cl-pm-close' }, _("Refresh"));
|
||
refreshBtn.addEventListener('click', function () { self._loadProxies(); });
|
||
var closeBtn = E('button', { 'class': 'cl-pm-close' }, _("Off"));
|
||
closeBtn.addEventListener('click', function () { self._closeProxyModal(); });
|
||
|
||
var modal = E('div', { 'class': 'cl-pm', id: 'cl-pm' }, [
|
||
E('div', { 'class': 'cl-pm-head' }, [
|
||
E('div', { 'class': 'cl-pm-ttl' }, [E('span', {}, _("Node Selection")), statusEl]),
|
||
E('div', { 'class': 'cl-pm-acts' }, [refreshBtn, closeBtn])
|
||
]),
|
||
body
|
||
]);
|
||
modal.addEventListener('click', function (ev) { ev.stopPropagation(); });
|
||
|
||
var mask = E('div', { 'class': 'cl-pm-mask', id: 'cl-pm-mask' }, [modal]);
|
||
mask.addEventListener('click', function () { self._closeProxyModal(); });
|
||
this._pmMask = mask;
|
||
this._pmModal = modal;
|
||
return mask;
|
||
},
|
||
|
||
_openProxyModal: function () {
|
||
var self = this;
|
||
if (!this._pmMask) return;
|
||
if (this._pmMask.parentNode !== document.body)
|
||
document.body.appendChild(this._pmMask);
|
||
var dark = this._isDarkUi();
|
||
this._pmMask.classList.toggle('cl-theme-dark', !!dark);
|
||
this._pmModal.classList.toggle('cl-theme-dark', !!dark);
|
||
this._pmMask.classList.add('cl-on');
|
||
this._pmModal.classList.add('cl-on');
|
||
if (!this._pmEscBound) {
|
||
this._pmEscHandler = function (ev) { if (ev.key === 'Escape') self._closeProxyModal(); };
|
||
document.addEventListener('keydown', this._pmEscHandler);
|
||
this._pmEscBound = true;
|
||
}
|
||
this._loadProxies();
|
||
},
|
||
|
||
_closeProxyModal: function () {
|
||
if (this._pmMask) this._pmMask.classList.remove('cl-on');
|
||
if (this._pmModal) this._pmModal.classList.remove('cl-on');
|
||
if (this._pmEscBound) {
|
||
document.removeEventListener('keydown', this._pmEscHandler);
|
||
this._pmEscBound = false;
|
||
}
|
||
},
|
||
|
||
_loadProxies: function () {
|
||
var self = this;
|
||
var body = this._pmBodyEl;
|
||
if (!body) return;
|
||
var st = this._lastSt || {};
|
||
if (!st.running) {
|
||
body.innerHTML = '';
|
||
body.appendChild(E('div', { 'class': 'cl-pm-empty' }, _("Core is not running. Start it before switching nodes.")));
|
||
this._setPmStatus(_("Not running"), 'err');
|
||
return;
|
||
}
|
||
body.innerHTML = '';
|
||
body.appendChild(E('div', { 'class': 'cl-pm-empty' }, _("Loading…")));
|
||
this._setPmStatus(_("Loading…"), '');
|
||
callProxiesList().then(function (r) {
|
||
var b = self._pmBodyEl;
|
||
if (!b) return;
|
||
b.innerHTML = '';
|
||
if (!r || !r.ok) {
|
||
b.appendChild(E('div', { 'class': 'cl-pm-empty' }, _("Cannot connect to core API. Check runtime status.")));
|
||
self._setPmStatus(_("Connection failed"), 'err');
|
||
return;
|
||
}
|
||
var groups = r.groups || [];
|
||
if (!groups.length) {
|
||
b.appendChild(E('div', { 'class': 'cl-pm-empty' }, _("Current configuration has no manually switchable proxy groups")));
|
||
self._setPmStatus(_("No proxy groups"), '');
|
||
return;
|
||
}
|
||
groups.forEach(function (g) { b.appendChild(self._renderProxyGroup(g)); });
|
||
self._setPmStatus(_("Ready · ") + groups.length + _(" groups"), 'ok');
|
||
}).catch(function () {
|
||
var b = self._pmBodyEl;
|
||
if (b) { b.innerHTML = ''; b.appendChild(E('div', { 'class': 'cl-pm-empty' }, _("Load failed"))); }
|
||
self._setPmStatus(_("Load failed"), 'err');
|
||
});
|
||
},
|
||
|
||
_doProxySelect: function (group, name, onOk, onFail) {
|
||
callProxySelect(group, name).then(function (r) {
|
||
if (r && r.ok) { if (onOk) onOk(); }
|
||
else {
|
||
ui.addNotification(null, E('p', group + _(" switch failed: ") + ((r && r.message) || '')));
|
||
if (onFail) onFail();
|
||
}
|
||
}).catch(function () {
|
||
ui.addNotification(null, E('p', group + _(" switch error")));
|
||
if (onFail) onFail();
|
||
});
|
||
},
|
||
|
||
_renderProxyGroup: function (g) {
|
||
var self = this;
|
||
var row = E('div', { 'class': 'cl-pm-row' }, [
|
||
E('div', { 'class': 'cl-pm-name' }, [
|
||
E('span', {}, g.name),
|
||
E('span', { 'class': 'cl-pm-badge' }, this._pgTypeLabel(g.type))
|
||
])
|
||
]);
|
||
|
||
if (g.selectable) {
|
||
var members = g.members || [];
|
||
var prevVal = g.now;
|
||
var sel = E('select', { 'class': 'cl-pm-sel' },
|
||
members.map(function (m) {
|
||
var label = m.name + (m.delay > 0 ? ' · ' + m.delay + 'ms' : '');
|
||
return E('option', { value: m.name, selected: (m.name === g.now) ? '' : null }, label);
|
||
})
|
||
);
|
||
sel.addEventListener('change', function () {
|
||
var picked = sel.value;
|
||
sel.disabled = true;
|
||
self._doProxySelect(g.name, picked,
|
||
function () { prevVal = picked; sel.disabled = false; },
|
||
function () { sel.value = prevVal; sel.disabled = false; });
|
||
});
|
||
row.appendChild(sel);
|
||
} else {
|
||
var dmap = {};
|
||
(g.members || []).forEach(function (m) { dmap[m.name] = m.delay || 0; });
|
||
row.appendChild(E('div', { 'class': 'cl-pm-now' }, [
|
||
E('span', {}, (g.now || '—') + ' '),
|
||
this._delayBadge(dmap[g.now])
|
||
]));
|
||
}
|
||
|
||
var testBtn = E('button', { 'class': 'cl-pm-test' }, _("Test Latency"));
|
||
testBtn.addEventListener('click', function () {
|
||
testBtn.disabled = true;
|
||
testBtn.textContent = _("Testing");
|
||
callProxyDelay(g.name).then(function (r) {
|
||
testBtn.disabled = false;
|
||
testBtn.textContent = _("Test Latency");
|
||
if (!r || !r.ok) {
|
||
ui.addNotification(null, E('p', g.name + _(" latency test failed: ") + ((r && r.message) || '')));
|
||
return;
|
||
}
|
||
var fresh = {
|
||
name: g.name, type: g.type, now: g.now, selectable: g.selectable,
|
||
members: (g.members || []).map(function (m) {
|
||
return { name: m.name, delay: (r.delays && r.delays[m.name]) || m.delay || 0 };
|
||
})
|
||
};
|
||
var newRow = self._renderProxyGroup(fresh);
|
||
if (row.parentNode) row.parentNode.replaceChild(newRow, row);
|
||
}).catch(function () {
|
||
testBtn.disabled = false;
|
||
testBtn.textContent = _("Test Latency");
|
||
});
|
||
});
|
||
row.appendChild(testBtn);
|
||
return row;
|
||
},
|
||
|
||
_fmtRate: function (bytes) {
|
||
var b = this._toInt(bytes);
|
||
if (b >= 1024 * 1024)
|
||
return { value: (b / (1024 * 1024)).toFixed(2), unit: 'MB/s' };
|
||
if (b >= 1024)
|
||
return { value: (b / 1024).toFixed(2), unit: 'kB/s' };
|
||
return { value: '' + b, unit: 'B/s' };
|
||
},
|
||
|
||
_fmtBytes: function (bytes) {
|
||
var b = this._toInt(bytes);
|
||
if (b >= 1024 * 1024 * 1024)
|
||
return (b / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||
if (b >= 1024 * 1024)
|
||
return (b / (1024 * 1024)).toFixed(0) + ' MB';
|
||
if (b >= 1024)
|
||
return (b / 1024).toFixed(0) + ' KB';
|
||
return b + ' B';
|
||
},
|
||
|
||
_fmtMiB: function (bytes) {
|
||
var b = this._toInt(bytes);
|
||
return (b / (1024 * 1024)).toFixed(0) + ' MiB';
|
||
},
|
||
|
||
_initRealtimeHistory: function () {
|
||
if (!this._rtHistory) {
|
||
this._rtHistory = { up: [], down: [], conn: [] };
|
||
}
|
||
},
|
||
|
||
_pushRealtimeSample: function (key, value) {
|
||
this._initRealtimeHistory();
|
||
var hist = this._rtHistory[key] || (this._rtHistory[key] = []);
|
||
hist.push(this._toInt(value));
|
||
if (hist.length > 36)
|
||
hist.shift();
|
||
return hist;
|
||
},
|
||
|
||
_smoothPath: function (pts) {
|
||
if (!pts || !pts.length)
|
||
return '';
|
||
if (pts.length === 1)
|
||
return 'M ' + pts[0].x.toFixed(1) + ' ' + pts[0].y.toFixed(1);
|
||
|
||
var d = 'M ' + pts[0].x.toFixed(1) + ' ' + pts[0].y.toFixed(1);
|
||
for (var i = 0; i < pts.length - 1; i++) {
|
||
var p0 = i > 0 ? pts[i - 1] : pts[i];
|
||
var p1 = pts[i];
|
||
var p2 = pts[i + 1];
|
||
var p3 = (i !== pts.length - 2) ? pts[i + 2] : p2;
|
||
|
||
var cp1x = p1.x + (p2.x - p0.x) / 6;
|
||
var cp1y = p1.y + (p2.y - p0.y) / 6;
|
||
var cp2x = p2.x - (p3.x - p1.x) / 6;
|
||
var cp2y = p2.y - (p3.y - p1.y) / 6;
|
||
|
||
d += ' C ' +
|
||
cp1x.toFixed(1) + ' ' + cp1y.toFixed(1) + ', ' +
|
||
cp2x.toFixed(1) + ' ' + cp2y.toFixed(1) + ', ' +
|
||
p2.x.toFixed(1) + ' ' + p2.y.toFixed(1);
|
||
}
|
||
return d;
|
||
},
|
||
|
||
_renderSparkline: function (points, tone) {
|
||
var w = 260, h = 42;
|
||
var vals = (points && points.length) ? points.slice() : [0];
|
||
if (vals.length < 2)
|
||
vals.push(vals[0]);
|
||
|
||
var max = 1;
|
||
for (var i = 0; i < vals.length; i++) {
|
||
if (vals[i] > max)
|
||
max = vals[i];
|
||
}
|
||
|
||
var n = vals.length;
|
||
var pts = [];
|
||
for (var j = 0; j < n; j++) {
|
||
var x = n === 1 ? 0 : (j * w / (n - 1));
|
||
var y = h - ((vals[j] / max) * (h - 2)) - 1;
|
||
pts.push({ x: x, y: y });
|
||
}
|
||
|
||
var linePath = this._smoothPath(pts);
|
||
var areaPath = linePath + ' L ' + w.toFixed(1) + ' ' + h + ' L 0 ' + h + ' Z';
|
||
|
||
return E('svg', { 'class': 'cl-live-svg', viewBox: '0 0 ' + w + ' ' + h, preserveAspectRatio: 'none' }, [
|
||
E('path', { 'class': 'cl-live-area ' + tone, d: areaPath }),
|
||
E('path', { 'class': 'cl-live-line ' + tone, d: linePath })
|
||
]);
|
||
},
|
||
|
||
_renderRealtimeCard: function (name, title) {
|
||
var mainId = 'cl-live-' + name + '-main';
|
||
var unitId = 'cl-live-' + name + '-unit';
|
||
var footId = 'cl-live-' + name + '-foot';
|
||
var chartId = 'cl-live-' + name + '-chart';
|
||
var titleChildren = [title];
|
||
|
||
return E('div', { 'class': 'cl-live-card cl-stat-card cl-live-card-' + name + ' is-zero', id: 'cl-live-' + name + '-card' }, [
|
||
E('div', { 'class': 'cl-live-bg-chart', id: chartId }, [this._renderSparkline([0, 0], name)]),
|
||
E('div', { 'class': 'cl-live-content' }, [
|
||
E('div', { 'class': 'cl-live-title' }, titleChildren),
|
||
E('div', { 'class': 'cl-live-main' }, [
|
||
E('span', { 'class': 'cl-live-num', id: mainId }, '0'),
|
||
E('span', { 'class': 'cl-live-unit', id: unitId }, name === 'conn' ? '' : 'B/s')
|
||
]),
|
||
E('div', { 'class': 'cl-live-foot', id: footId }, _("Waiting for data..."))
|
||
])
|
||
]);
|
||
},
|
||
|
||
_renderRealtimePanel: function () {
|
||
this._initRealtimeHistory();
|
||
return E('div', { 'class': 'cl-live-box' }, [
|
||
E('div', { 'class': 'cl-live-grid' }, [
|
||
this._renderRealtimeCard('up', _("Upload")),
|
||
this._renderRealtimeCard('down', _("Download")),
|
||
this._renderRealtimeCard('conn', _("Connections"))
|
||
])
|
||
]);
|
||
},
|
||
|
||
_setLiveCardState: function (name, value, hotThreshold, online) {
|
||
var card = document.getElementById('cl-live-' + name + '-card');
|
||
if (!card) return;
|
||
var cls = 'cl-live-card cl-stat-card cl-live-card-' + name;
|
||
if (name === 'conn' && !online)
|
||
cls += ' is-offline';
|
||
if (value > 0)
|
||
cls += ' is-active';
|
||
else
|
||
cls += ' is-zero';
|
||
if (value >= hotThreshold)
|
||
cls += ' is-hot';
|
||
card.className = cls;
|
||
},
|
||
|
||
_updateRealtimePanel: function (raw) {
|
||
var st = raw || {};
|
||
var up = this._toInt(st.up);
|
||
var down = this._toInt(st.down);
|
||
var upTotal = this._toInt(st.up_total);
|
||
var downTotal = this._toInt(st.down_total);
|
||
var conn = this._toInt(st.connections);
|
||
var mem = this._toInt(st.memory_inuse);
|
||
var online = !!(st.ok && st.running);
|
||
|
||
var upRate = this._fmtRate(up);
|
||
var downRate = this._fmtRate(down);
|
||
var upHist = this._pushRealtimeSample('up', up);
|
||
var downHist = this._pushRealtimeSample('down', down);
|
||
var connHist = this._pushRealtimeSample('conn', conn);
|
||
|
||
var setText = function (id, text) {
|
||
var el = document.getElementById(id);
|
||
if (el) el.textContent = text;
|
||
};
|
||
var setChart = L.bind(function (id, points, tone) {
|
||
var box = document.getElementById(id);
|
||
if (!box) return;
|
||
box.innerHTML = '';
|
||
box.appendChild(this._renderSparkline(points, tone));
|
||
}, this);
|
||
|
||
setText('cl-live-up-main', upRate.value);
|
||
setText('cl-live-up-unit', upRate.unit);
|
||
setText('cl-live-up-foot', _("Total %s").replace('%s', this._fmtBytes(upTotal)));
|
||
setChart('cl-live-up-chart', upHist, 'up');
|
||
|
||
setText('cl-live-down-main', downRate.value);
|
||
setText('cl-live-down-unit', downRate.unit);
|
||
setText('cl-live-down-foot', _("Total %s").replace('%s', this._fmtBytes(downTotal)));
|
||
setChart('cl-live-down-chart', downHist, 'down');
|
||
|
||
setText('cl-live-conn-main', '' + conn);
|
||
setText('cl-live-conn-unit', '');
|
||
setText('cl-live-conn-foot', _("Memory usage %s").replace('%s', this._fmtMiB(mem)));
|
||
setChart('cl-live-conn-chart', connHist, 'conn');
|
||
|
||
this._setLiveCardState('up', up, 512 * 1024, online);
|
||
this._setLiveCardState('down', down, 1024 * 1024, online);
|
||
this._setLiveCardState('conn', conn, 80, online);
|
||
|
||
},
|
||
|
||
_probeLabel: function (key, probe) {
|
||
if (probe && probe.name) return String(probe.name);
|
||
if (probe && probe.title) return String(probe.title);
|
||
if (probe && probe.site) return String(probe.site);
|
||
var map = {
|
||
baidu: 'Baidu',
|
||
google: 'Google',
|
||
github: 'GitHub',
|
||
youtube: 'YouTube',
|
||
bytedance: _("ByteDance"),
|
||
cloudflare: 'Cloudflare',
|
||
bilibili: 'Bilibili',
|
||
taobao: 'Taobao'
|
||
};
|
||
var k = String(key || '').toLowerCase();
|
||
return map[k] || String(key || 'Unknown');
|
||
},
|
||
|
||
_probeIcon: function (key, probe) {
|
||
if (probe && probe.icon) {
|
||
if (/^(https?:)?\//.test(probe.icon))
|
||
return probe.icon;
|
||
return L.resource('view/clashoo/' + probe.icon);
|
||
}
|
||
var map = {
|
||
baidu: 'baidu.svg',
|
||
google: 'google.svg',
|
||
github: 'github.svg',
|
||
youtube: 'youtube.svg',
|
||
bytedance: 'bytedance.svg',
|
||
cloudflare: 'cloudflare.svg',
|
||
bilibili: 'bilibili.svg',
|
||
taobao: 'taobao.svg'
|
||
};
|
||
var k = String(key || '').toLowerCase();
|
||
return map[k] ? (L.resource('view/clashoo/' + map[k]) + '?v=20260421p') : null;
|
||
},
|
||
|
||
_probeGrade: function (probe) {
|
||
var ms = probe && probe.avg_ms != null ? parseInt(probe.avg_ms, 10) :
|
||
(probe && probe.ms != null ? parseInt(probe.ms, 10) :
|
||
(probe && probe.latency != null ? parseInt(probe.latency, 10) : NaN));
|
||
var ok = !!(probe && (probe.ok === true || probe.state === 'ok' || probe.state === 'high_latency' || probe.state === 'loss'));
|
||
if (!ok || !isFinite(ms))
|
||
return {
|
||
tone: 'red',
|
||
bars: 1,
|
||
text: (probe && probe.code === 'skip') ? 'Core down' :
|
||
((probe && probe.code && probe.code !== '000') ? 'Offline' : 'Timeout'),
|
||
offline: true
|
||
};
|
||
|
||
if (ms <= 400)
|
||
return { tone: 'green', bars: 4, text: ms + 'ms' };
|
||
if (ms <= 800)
|
||
return { tone: 'warn', bars: 3, text: ms + 'ms' };
|
||
return { tone: 'red', bars: 2, text: ms + 'ms' };
|
||
},
|
||
|
||
_renderSignalBars: function (grade) {
|
||
var bars = [];
|
||
for (var i = 0; i < 4; i++) {
|
||
bars.push(E('i', { 'class': i < grade.bars ? 'active' : '' }));
|
||
}
|
||
var cls = 'cl-signal cl-signal-' + grade.tone + (grade.offline ? ' cl-signal-offline' : '');
|
||
return E('span', { 'class': cls }, bars);
|
||
},
|
||
|
||
_renderProbeIcon: function (icon, label) {
|
||
var wrap = E('span', { 'class': 'cl-check-icon-wrap' });
|
||
if (!icon) {
|
||
wrap.appendChild(E('span', { 'class': 'cl-check-icon-fallback' }, (label || '?').charAt(0).toUpperCase()));
|
||
return wrap;
|
||
}
|
||
wrap.appendChild(E('img', {
|
||
'class': 'cl-check-icon',
|
||
src: icon,
|
||
alt: label,
|
||
error: function (ev) {
|
||
var fb = E('span', { 'class': 'cl-check-icon-fallback' }, (label || '?').charAt(0).toUpperCase());
|
||
if (ev.target && ev.target.parentNode)
|
||
ev.target.parentNode.replaceChild(fb, ev.target);
|
||
}
|
||
}));
|
||
return wrap;
|
||
},
|
||
|
||
_normalizeCheckItems: function (ac) {
|
||
if (!ac) return [];
|
||
if ((parseInt(ac.updated_at || '0', 10) || 0) <= 0 && ac.updating)
|
||
return [];
|
||
var self = this;
|
||
|
||
var normalizeItems = function (source) {
|
||
if (!source) return [];
|
||
|
||
if (Array.isArray(source)) {
|
||
return source.map(function (item, idx) {
|
||
var key = item.key || item.name || item.title || item.site || ('item_' + idx);
|
||
return {
|
||
key: key,
|
||
label: self._probeLabel(key, item),
|
||
icon: self._probeIcon(key, item),
|
||
probe: item
|
||
};
|
||
});
|
||
}
|
||
|
||
var preferred = ['bytedance', 'youtube', 'google', 'github', 'baidu', 'cloudflare'];
|
||
var keys = Object.keys(source);
|
||
keys.sort(function (a, b) {
|
||
var ia = preferred.indexOf(a), ib = preferred.indexOf(b);
|
||
if (ia !== -1 || ib !== -1) {
|
||
if (ia === -1) return 1;
|
||
if (ib === -1) return -1;
|
||
return ia - ib;
|
||
}
|
||
return a.localeCompare(b);
|
||
});
|
||
|
||
return keys.map(function (key) {
|
||
var probe = source[key] || {};
|
||
return {
|
||
key: key,
|
||
label: self._probeLabel(key, probe),
|
||
icon: self._probeIcon(key, probe),
|
||
probe: probe
|
||
};
|
||
});
|
||
};
|
||
|
||
if (Array.isArray(ac.items) && ac.items.length)
|
||
return normalizeItems(ac.items);
|
||
if (ac.proxy)
|
||
return normalizeItems(ac.proxy);
|
||
if (ac.direct)
|
||
return normalizeItems(ac.direct);
|
||
return [];
|
||
},
|
||
|
||
_renderCheckItem: function (item) {
|
||
var grade = this._probeGrade(item.probe || {});
|
||
return E('div', { 'class': 'cl-check-item' }, [
|
||
E('div', { 'class': 'cl-check-main' }, [
|
||
this._renderProbeIcon(item.icon, item.label),
|
||
E('span', { 'class': 'cl-check-name site-name' }, item.label)
|
||
]),
|
||
E('span', { 'class': 'cl-check-latency latency-val cl-lat-' + grade.tone }, grade.text),
|
||
this._renderSignalBars(grade)
|
||
]);
|
||
},
|
||
|
||
_formatNumberText: function (tpl, n) {
|
||
return String(tpl).replace('%d', String(n));
|
||
},
|
||
|
||
_lastCheckText: function (ts) {
|
||
var now = Math.floor(Date.now() / 1000);
|
||
var delta = now - (parseInt(ts, 10) || 0);
|
||
if (!isFinite(delta) || delta < 0) delta = 0;
|
||
if (delta < 5) return _("Last check: just now");
|
||
if (delta < 60) return this._formatNumberText(_("Last check: %d seconds ago"), delta);
|
||
if (delta < 3600) return this._formatNumberText(_("Last check: %d minutes ago"), Math.floor(delta / 60));
|
||
return this._formatNumberText(_("Last check: %d hours ago"), Math.floor(delta / 3600));
|
||
},
|
||
|
||
_renderCheckUpdated: function (ac) {
|
||
if (!ac) return null;
|
||
var updatedAt = parseInt(ac.updated_at || '0', 10) || 0;
|
||
if (updatedAt <= 0) {
|
||
return E('div', { 'class': 'cl-check-updated' }, _("Last check: initializing"));
|
||
}
|
||
var msg = this._lastCheckText(updatedAt);
|
||
if (ac.updating && this._accessRefreshing)
|
||
msg += _(" (refreshing)");
|
||
return E('div', { 'class': 'cl-check-updated' }, msg);
|
||
},
|
||
|
||
_renderCheckSkeleton: function () {
|
||
return E('div', { 'class': 'cl-check-skeleton' }, [
|
||
E('div', { 'class': 'cl-skel-row' }, [
|
||
E('span', { 'class': 'cl-skel-icon' }),
|
||
E('span', { 'class': 'cl-skel-line' }),
|
||
E('span', { 'class': 'cl-skel-line short' })
|
||
]),
|
||
E('div', { 'class': 'cl-skel-row' }, [
|
||
E('span', { 'class': 'cl-skel-icon' }),
|
||
E('span', { 'class': 'cl-skel-line' }),
|
||
E('span', { 'class': 'cl-skel-line short' })
|
||
]),
|
||
E('div', { 'class': 'cl-check-updated' }, _("Last check: initializing"))
|
||
]);
|
||
},
|
||
|
||
_renderCheckStatus: function (st, ac) {
|
||
var items = this._normalizeCheckItems(ac);
|
||
if (!items.length) {
|
||
return this._renderCheckSkeleton();
|
||
}
|
||
|
||
var self = this;
|
||
var children = items.map(function (item) {
|
||
return self._renderCheckItem(item);
|
||
});
|
||
var updated = this._renderCheckUpdated(ac);
|
||
if (updated) children.push(updated);
|
||
return E('div', { 'class': 'cl-check-modern' }, children);
|
||
},
|
||
|
||
_controls: function (st, cfgData) {
|
||
var self = this;
|
||
var configs = (cfgData && cfgData.configs) ? cfgData.configs : [];
|
||
var current = (cfgData && cfgData.current) || '';
|
||
var proxyMode = st.proxy_mode || 'rule';
|
||
var tcpMode = String(st.tcp_mode || '').toLowerCase();
|
||
var udpMode = String(st.udp_mode || tcpMode).toLowerCase();
|
||
var stackMode = String(st.stack || '').toLowerCase();
|
||
var tpMode = 'custom';
|
||
|
||
if (tcpMode === 'tun' && udpMode === 'tun' && stackMode === 'mixed')
|
||
tpMode = 'tun-mixed';
|
||
else if (tcpMode === 'tun' && udpMode === 'tun' && stackMode === 'gvisor')
|
||
tpMode = 'tun-gvisor';
|
||
else if (tcpMode === 'tun' && udpMode === 'tun')
|
||
tpMode = 'tun-system';
|
||
else if (tcpMode === 'redirect' && udpMode === 'tproxy')
|
||
tpMode = 'fake-ip';
|
||
var panelType = st.panel_type || 'zashboard';
|
||
var panelUrl = this._dashboardUrl(st);
|
||
var panels = ['metacubexd', 'yacd', 'zashboard', 'razord'];
|
||
|
||
var mkSel = function (opts, val, fn) {
|
||
return E('select', { 'class': 'cbi-input-select', change: fn },
|
||
opts.map(function (o) {
|
||
return E('option', { value: o[0], selected: o[0] === val ? '' : null, disabled: o[2] ? '' : null }, o[1]);
|
||
}));
|
||
};
|
||
var tpModeOptions = [['fake-ip','Fake-IP'],['tun-system','System'],['tun-gvisor','gVisor'],['tun-mixed','Mixed']];
|
||
if (tpMode === 'custom')
|
||
tpModeOptions.unshift(['custom',_("Custom")]);
|
||
|
||
var panelStatusEl = E('span', { 'class': 'cl-panel-status' }, '');
|
||
|
||
var panelSel = mkSel(panels.map(function (p) {
|
||
return [p, self._panelLabel(p)];
|
||
}), panelType, function (ev) {
|
||
var next = ev.target.value;
|
||
var prev = panelType;
|
||
ev.target.disabled = true;
|
||
self._lastSt = self._lastSt || {};
|
||
self._lastSt.panel_type = next;
|
||
clashoo.setPanel(next).then(function (r) {
|
||
if (r && r.error)
|
||
throw new Error(r.error);
|
||
return self._pollOverview(true);
|
||
}).catch(function (e) {
|
||
self._lastSt.panel_type = prev;
|
||
ev.target.value = prev;
|
||
ui.addNotification(null, E('p', _("Panel switch failed: ") + (e.message || e)));
|
||
}).then(function () {
|
||
ev.target.disabled = false;
|
||
});
|
||
});
|
||
|
||
return [
|
||
E('div', { 'class': 'cl-ctrl' }, [
|
||
E('label', {}, _("Proxy Mode")),
|
||
mkSel([['rule',_("Rule")],['global',_("Global")],['direct',_("Direct")]], proxyMode,
|
||
function (ev) { clashoo.setProxyMode(ev.target.value); })
|
||
]),
|
||
E('div', { 'class': 'cl-ctrl' }, [
|
||
E('label', {}, _("Run Mode")),
|
||
mkSel(tpModeOptions, tpMode,
|
||
function (ev) {
|
||
var mode = ev.target.value;
|
||
if (mode === 'custom')
|
||
return;
|
||
ev.target.disabled = true;
|
||
self._op = 'mode';
|
||
self._lastSt = self._lastSt || {};
|
||
if (mode === 'tun-mixed') {
|
||
self._lastSt.tcp_mode = 'tun';
|
||
self._lastSt.udp_mode = 'tun';
|
||
self._lastSt.stack = 'mixed';
|
||
} else if (mode === 'tun-gvisor') {
|
||
self._lastSt.tcp_mode = 'tun';
|
||
self._lastSt.udp_mode = 'tun';
|
||
self._lastSt.stack = 'gvisor';
|
||
} else if (mode === 'tun-system') {
|
||
self._lastSt.tcp_mode = 'tun';
|
||
self._lastSt.udp_mode = 'tun';
|
||
self._lastSt.stack = 'system';
|
||
} else {
|
||
self._lastSt.tcp_mode = 'redirect';
|
||
self._lastSt.udp_mode = 'tproxy';
|
||
}
|
||
clashoo.setMode(mode).then(function (r) {
|
||
if (r && r.error)
|
||
ui.addNotification(null, E('p', _("Run mode setting failed: ") + r.error));
|
||
return self._pollOverview(true);
|
||
}).catch(function (e) {
|
||
ui.addNotification(null, E('p', _("Run mode setting failed: ") + (e.message || e)));
|
||
}).then(function () {
|
||
self._op = null;
|
||
ev.target.disabled = false;
|
||
});
|
||
})
|
||
]),
|
||
E('div', { 'class': 'cl-ctrl' }, [
|
||
E('label', {}, _("Configuration Files")),
|
||
E('div', { 'class': 'cl-ctrl-row cl-config-row' }, [
|
||
mkSel(configs.length ? configs.map(function(c){return[c,c];}) : [['',_("(empty)")]], current,
|
||
function (ev) {
|
||
var sel = ev.target;
|
||
var name = sel.value;
|
||
var prev = sel.getAttribute('data-cl-prev') || current;
|
||
sel.disabled = true;
|
||
var setter = (st.core_type === 'singbox')
|
||
? clashoo.setSingboxProfile(name)
|
||
: clashoo.setConfig(name);
|
||
setter
|
||
.then(function (r) {
|
||
if (r && r.error) {
|
||
ui.addNotification(null, E('p', _("Switch configuration failed: ") + r.error));
|
||
sel.value = prev;
|
||
return;
|
||
}
|
||
sel.setAttribute('data-cl-prev', name);
|
||
})
|
||
.catch(function (e) {
|
||
ui.addNotification(null, E('p', _("Switch configuration failed: ") + (e.message || e)));
|
||
sel.value = prev;
|
||
})
|
||
.then(function () {
|
||
sel.disabled = false;
|
||
/* force immediate state refresh so cards get new current */
|
||
return self._pollOverview(true);
|
||
});
|
||
}),
|
||
E('button', {
|
||
'class': 'btn cbi-button-action cl-btn-update-sub',
|
||
click: L.bind(this._updSubs, this)
|
||
}, _("Update Subscription"))
|
||
])
|
||
]),
|
||
E('div', { 'class': 'cl-ctrl' }, [
|
||
E('label', {}, _("Dashboard")),
|
||
E('div', { 'class': 'cl-ctrl-row' }, [
|
||
panelSel,
|
||
E('button', {
|
||
'class': 'btn cbi-button cl-btn-panel-update',
|
||
click: function () {
|
||
self._runPanelUpdate(panelSel.value, this, panelStatusEl);
|
||
}
|
||
}, _("Update")),
|
||
panelStatusEl,
|
||
E('a', {
|
||
'class': 'btn cbi-button cl-btn-panel-open',
|
||
href: panelUrl,
|
||
target: '_blank',
|
||
rel: 'noopener noreferrer',
|
||
click: function (ev) {
|
||
ev.preventDefault();
|
||
window.open(self._dashboardUrl(self._lastSt || st), '_blank', 'noopener');
|
||
}
|
||
}, _("Open Dashboard"))
|
||
])
|
||
])
|
||
];
|
||
},
|
||
|
||
_dashboardUrl: function (st) {
|
||
st = st || {};
|
||
var dashPort = st.dash_port ||
|
||
uci.get('clashoo', 'config', 'dash_port') ||
|
||
uci.get('clashoo', 'config', 'dashboard_port') ||
|
||
'9090';
|
||
var host = location.hostname || st.local_ip || '127.0.0.1';
|
||
var secret = st.dash_pass ||
|
||
uci.get('clashoo', 'config', 'dash_pass') ||
|
||
'';
|
||
var params = [
|
||
['host', host],
|
||
['hostname', host],
|
||
['port', dashPort],
|
||
['secret', secret]
|
||
].map(function (kv) {
|
||
return encodeURIComponent(kv[0]) + '=' + encodeURIComponent(kv[1] || '');
|
||
}).join('&');
|
||
|
||
return 'http://' + host + ':' + dashPort + '/ui/?' + params + '#/proxies';
|
||
},
|
||
|
||
_pollOverview: function (force) {
|
||
this._applyThemeClass();
|
||
if (!force && this._op) return Promise.resolve();
|
||
if (!force && this._coreSwitchBusy) return Promise.resolve();
|
||
if (!force && this._panelUpdateBusy) return Promise.resolve();
|
||
if (!force && document.hidden) return Promise.resolve();
|
||
var self = this;
|
||
return rpcOverview()
|
||
.then(function (ov) {
|
||
ov = ov || {};
|
||
var st = ov.status || {};
|
||
var cfgData = ov.configs || { configs: [], current: '' };
|
||
var ac = ov.access || {};
|
||
var stats = ov.stats || {};
|
||
|
||
/* force proxy probes down when stopped (skip daemon refresh) */
|
||
if (!st.running && ac && ac.proxy) {
|
||
var downProbe = { ok: false, state: 'down', code: 'skip', ok_count: 0, attempts: 1, loss: 1, avg_ms: 0 };
|
||
Object.keys(ac.proxy).forEach(function (k) { ac.proxy[k] = downProbe; });
|
||
}
|
||
|
||
self._overviewLoaded = true;
|
||
self._lastSt = st;
|
||
self._lastCfgData = cfgData;
|
||
self._lastAc = ac;
|
||
self._writeCachedOverview(ov);
|
||
self._refreshCoreSwitch();
|
||
|
||
/* only rebuild DOM on user-visible field changes, avoid flicker every 5s
|
||
尤其是切换 tab 回到 overview 时缓存数据与首次 poll 数据通常一致 */
|
||
var sig = JSON.stringify({
|
||
running: st.running,
|
||
health: st.health_status,
|
||
proxy_mode: st.proxy_mode,
|
||
tcp_mode: st.tcp_mode,
|
||
udp_mode: st.udp_mode,
|
||
stack: st.stack_type || st.stack,
|
||
config: st.config,
|
||
core_type: st.core_type,
|
||
panel_type: st.panel_type,
|
||
dcore: st.dcore,
|
||
configs: cfgData.configs || [],
|
||
current: cfgData.current,
|
||
ac_updated: ac.updated_at,
|
||
ac_updating: ac.updating
|
||
});
|
||
var skipRebuild = (sig === self._lastRenderSig);
|
||
self._lastRenderSig = sig;
|
||
|
||
var cards = document.getElementById('cl-cards');
|
||
if (!cards) return;
|
||
if (!skipRebuild) {
|
||
var newCards = self._cards(st, cfgData, ac);
|
||
cards.innerHTML = '';
|
||
newCards.forEach(function (card) { cards.appendChild(card); });
|
||
|
||
var controls = document.getElementById('cl-controls');
|
||
if (controls) {
|
||
controls.innerHTML = '';
|
||
self._controls(st, cfgData).forEach(function (ctrl) { controls.appendChild(ctrl); });
|
||
}
|
||
}
|
||
|
||
self._updateRealtimePanel(stats);
|
||
});
|
||
},
|
||
|
||
/* Compatibility wrapper for existing call sites */
|
||
_pollStatus: function () {
|
||
return this._pollOverview();
|
||
},
|
||
|
||
/* Slow poll for connectivity check — every 60s, network probes are expensive */
|
||
_pollAccess: function () {
|
||
return this._pollOverview(true);
|
||
},
|
||
|
||
_refreshAccessCard: function () {
|
||
var cards = document.getElementById('cl-cards');
|
||
if (!cards) return;
|
||
var st = this._lastSt || {};
|
||
var ac = this._lastAc || {};
|
||
var newCard = this._card(_("Access Check"), this._renderCheckStatus(st, ac), 'cl-card-access', this._renderAccessRefresh(ac));
|
||
var oldCard = cards.querySelector('.cl-card-access');
|
||
if (oldCard && oldCard.parentNode) {
|
||
oldCard.parentNode.replaceChild(newCard, oldCard);
|
||
} else {
|
||
cards.appendChild(newCard);
|
||
}
|
||
},
|
||
|
||
_manualRefreshAccess: function () {
|
||
var self = this;
|
||
if (self._accessRefreshing) return Promise.resolve();
|
||
var previousUpdatedAt = parseInt((self._lastAc && self._lastAc.updated_at) || 0, 10) || 0;
|
||
self._accessRefreshing = true;
|
||
self._refreshAccessCard();
|
||
|
||
var trigger = (clashoo && typeof clashoo.accessCheckRefresh === 'function')
|
||
? clashoo.accessCheckRefresh()
|
||
: Promise.resolve({ success: true });
|
||
|
||
return L.resolveDefault(trigger, { success: false })
|
||
.then(function () {
|
||
return self._waitAccessRefresh(previousUpdatedAt);
|
||
})
|
||
.catch(function () {
|
||
return self._pollAccess();
|
||
})
|
||
.then(function () {
|
||
self._accessRefreshing = false;
|
||
self._refreshAccessCard();
|
||
});
|
||
},
|
||
|
||
_waitAccessRefresh: function (previousUpdatedAt) {
|
||
var self = this;
|
||
var started = Date.now();
|
||
var maxWait = 25000;
|
||
|
||
function sleep(ms) {
|
||
return new Promise(function (resolve) { setTimeout(resolve, ms); });
|
||
}
|
||
|
||
function loop() {
|
||
return self._pollAccess().then(function () {
|
||
var ac = self._lastAc || {};
|
||
var updatedAt = parseInt(ac.updated_at || 0, 10) || 0;
|
||
if (updatedAt > previousUpdatedAt && !ac.updating)
|
||
return;
|
||
if (!ac.updating && updatedAt > 0 && (Date.now() - started) > 2500)
|
||
return;
|
||
if ((Date.now() - started) >= maxWait)
|
||
return;
|
||
return sleep(700).then(loop);
|
||
});
|
||
}
|
||
|
||
return sleep(500).then(loop);
|
||
},
|
||
|
||
_pollRealtime: function () {
|
||
return this._pollOverview();
|
||
},
|
||
|
||
/* health_detail → 用户看得懂的中文(参考 P2-F 字典;详细状态时只在状态卡上展示) */
|
||
_friendlyHealth: function (detail) {
|
||
var map = {
|
||
'boot_disabled': _("Stopped (not enabled at boot)"),
|
||
'service_disabled': _("Service disabled"),
|
||
'service_stopped': _("Service stopped"),
|
||
'preflight:openclash_conflict': _("OpenClash is enabled; disable it first to avoid rule conflicts"),
|
||
'preflight:config_missing': _("No configuration file found. Import a subscription or upload a configuration on the Configuration page first."),
|
||
'preflight:missing_fw4_stack': _("System is missing nftables runtime"),
|
||
'preflight:core_validation_failed': _("Core verification failed. Check System → Core."),
|
||
'start:core_not_running': _("Core did not start within 15 seconds (procd is recovering)"),
|
||
'start:singbox_service_failed': _("sing-box service failed to start. Check System → Log."),
|
||
'init': _("Initializing…")
|
||
};
|
||
return map[detail] || '';
|
||
},
|
||
|
||
/* health_detail → 启动 / 停止 进度文字 */
|
||
_opPhase: function (opKey, st) {
|
||
var hd = (st && st.health_detail) || '';
|
||
if (opKey === 'stop') {
|
||
if (st && st.running === false) return _("Stopped") + ' ✓';
|
||
return _("Stopping…");
|
||
}
|
||
if (!st || st.running !== true) return _("Starting core…");
|
||
if (hd === 'init') return _("Checking environment…");
|
||
if (hd.indexOf('preflight:') === 0) return _("Checking environment…");
|
||
if (hd.indexOf('start:') === 0) return this._friendlyHealth(hd) || _("Starting core…");
|
||
if (st.health_status === 'pass') return _("Ready ✓");
|
||
if (st.health_status === 'fail') return _("Started (") + (this._friendlyHealth(hd) || _("Health check failed")) + ')';
|
||
return _("Health check in progress…");
|
||
},
|
||
|
||
/* fn: fire-and-forget RPC + 秒速轮询直到状态到位 */
|
||
/* toggle switch instantly (optimistic), do not wait for RPC */
|
||
_svc: function (fn, opKey) {
|
||
if (this._busy) return Promise.resolve();
|
||
|
||
// preflight: refuse start when no profile selected.
|
||
// backend select_config also defends but toast is slow, UI would flash.
|
||
if (opKey === 'start' || opKey === 'restart') {
|
||
var st0 = this._lastSt || {};
|
||
var hasConfig = !!(st0.config || st0.conf_path);
|
||
if (!hasConfig) {
|
||
ui.addNotification(null, E('p', _("No configuration file selected. Select or upload one on the Configuration page before starting.")), 'warning');
|
||
return Promise.resolve();
|
||
}
|
||
}
|
||
|
||
this._busy = true;
|
||
var self = this;
|
||
self._op = opKey;
|
||
|
||
/* 立即翻转 switch 视觉 */
|
||
var btn = document.querySelector('.cl-service-switch');
|
||
if (btn) {
|
||
var running = btn.getAttribute('aria-pressed') === 'true';
|
||
var newRunning = opKey === 'start';
|
||
if (newRunning !== running) {
|
||
btn.setAttribute('aria-pressed', newRunning ? 'true' : 'false');
|
||
btn.className = 'cl-service-switch' + (newRunning ? ' is-on' : ' is-off');
|
||
}
|
||
self._showOpMsg(opKey === 'stop' ? _("Stopping…") : _("Starting…"));
|
||
}
|
||
|
||
var maxWait = opKey === 'stop' ? 15000 : 35000;
|
||
fn().catch(function () {}); /* fire-and-forget */
|
||
|
||
var started = Date.now();
|
||
var pollTimer = null;
|
||
|
||
function finish(finalMsg) {
|
||
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
||
if (finalMsg) self._showOpMsg(finalMsg);
|
||
self._busy = false;
|
||
self._op = null;
|
||
self._pollOverview(true);
|
||
}
|
||
|
||
function pollOnce() {
|
||
L.resolveDefault(clashoo.status(), {}).then(function (st) {
|
||
st = st || {};
|
||
var elapsed = Date.now() - started;
|
||
if (opKey === 'stop') {
|
||
if (st.running === false) return finish(_("Stopped") + ' ⚪');
|
||
} else {
|
||
if (st.running === true) return finish(_("Running 🟢"));
|
||
if (st.health_status === 'fail') return finish(null);
|
||
}
|
||
if (elapsed >= maxWait) return finish(null);
|
||
pollTimer = setTimeout(pollOnce, 500);
|
||
}).catch(function () {
|
||
if (Date.now() - started < maxWait) pollTimer = setTimeout(pollOnce, 500);
|
||
else finish(null);
|
||
});
|
||
}
|
||
pollTimer = setTimeout(pollOnce, 100);
|
||
},
|
||
|
||
_start: function () { return this._svc(function () { return clashoo.start(); }, 'start'); },
|
||
_stop: function () { return this._svc(function () { return clashoo.stop(); }, 'stop'); },
|
||
_restart: function () { return this._svc(function () { return clashoo.restart(); }, 'restart'); },
|
||
|
||
_updSubs: function () {
|
||
return L.resolveDefault(callUpdateCurrentSubscription(), {}).then(function (r) {
|
||
if (r && r.reason === 'not_subscription') {
|
||
ui.addNotification(null, E('p', _("Current configuration is a custom file; no subscription update needed")), 'info');
|
||
return;
|
||
}
|
||
ui.addNotification(null, E('p', r.success ? (r.message || _("Subscription updated successfully"))
|
||
: (_("Update failed: ") + (r.message || _("Current configuration has no recorded subscription URL")))));
|
||
});
|
||
},
|
||
|
||
handleSaveApply: null,
|
||
handleSave: null,
|
||
handleReset: null
|
||
});
|