'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 });