'use strict'; const PS = { status: null, config: null, }; // ─── API helpers ─── async function api(method, path, body) { const opts = { method, headers: body ? (body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) : {}, }; if (body && !(body instanceof FormData)) { opts.body = JSON.stringify(body); } else if (body instanceof FormData) { opts.body = body; } const res = await fetch(path, opts); const json = await res.json(); if (!res.ok || !json.success) { throw new Error(json.error || `HTTP ${res.status}`); } return json.data; } const get = (path) => api('GET', path); const post = (path, body) => api('POST', path, body); const put = (path, body) => api('PUT', path, body); function showToast(msg, type = 'info') { const t = document.getElementById('toast'); t.textContent = msg; t.className = `toast ${type}`; t.classList.remove('hidden'); clearTimeout(t._timer); t._timer = setTimeout(() => t.classList.add('hidden'), 3500); } function esc(s) { if (s == null) return ''; const d = document.createElement('div'); d.textContent = String(s); return d.innerHTML; } // ─── Segmented buttons ─── function setSegBtn(id, mode) { document.querySelectorAll(`#${id} .seg-btn`).forEach(b => { b.classList.toggle('active', b.dataset.mode === mode); }); } function getSegBtn(id) { const active = document.querySelector(`#${id} .seg-btn.active`); return active ? active.dataset.mode : ''; } // Generic seg btn click handler document.querySelectorAll('.segmented').forEach(seg => { seg.addEventListener('click', e => { const btn = e.target.closest('.seg-btn'); if (!btn || !seg.id) return; seg.querySelectorAll('.seg-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); }); }); // ─── Config accessors ─── function getProxies() { return PS.config && Array.isArray(PS.config.proxies) ? PS.config.proxies : []; } function getGroups() { return PS.config && Array.isArray(PS.config['proxy-groups']) ? PS.config['proxy-groups'] : []; } function getRules() { return PS.config && Array.isArray(PS.config.rules) ? PS.config.rules : []; } function getProxyProviders() { const pp = PS.config && PS.config['proxy-providers']; if (!pp || typeof pp !== 'object') return []; return Object.entries(pp).map(([name, val]) => ({ name, ...val })); } function getRuleProviders() { const rp = PS.config && PS.config['rule-providers']; if (!rp || typeof rp !== 'object') return []; return Object.entries(rp).map(([name, val]) => ({ name, ...val })); } // ─── Type labels ─── const PROTO_LABELS = { ss: 'Shadowsocks', ssr: 'ShadowsocksR', vmess: 'VMess', vless: 'VLESS', trojan: 'Trojan', hysteria: 'Hysteria', hysteria2: 'Hysteria2', tuic: 'TUIC', wireguard: 'WireGuard', snell: 'Snell', ssh: 'SSH', anytls: 'AnyTLS', mieru: 'Mieru', http: 'HTTP', socks5: 'SOCKS5', direct: 'DIRECT', }; function typeLabel(t) { return PROTO_LABELS[t] || t; } const GROUP_LABELS = { select: 'Выбор', 'url-test': 'Автотест', fallback: 'Резерв', 'load-balance': 'Балансировка', relay: 'Relay' }; function groupTypeLabel(t) { return GROUP_LABELS[t] || t; } // ─── Load / Save ─── async function loadStatus() { try { PS.status = await get('/api/mihomo/status'); renderStatus(); } catch (e) { console.error('load status', e); } } async function loadConfig() { try { PS.config = await get('/api/mihomo/config'); renderAll(); } catch (e) { console.error('load config', e); PS.config = {}; renderAll(); showToast('Ошибка загрузки конфига: ' + e.message, 'error'); } document.getElementById('loading').classList.add('hidden'); } async function saveFullConfig(restart) { try { await put('/api/mihomo/config', PS.config); showToast('Конфиг сохранён', 'success'); refreshYAMLPreview(); if (restart) { try { await post('/api/mihomo/restart', null); showToast('Mihomo перезапущен', 'success'); } catch (e) { showToast('Перезапуск не удался: ' + e.message, 'error'); } await loadStatus(); } } catch (e) { showToast('Ошибка сохранения: ' + e.message, 'error'); } } async function refreshYAMLPreview() { try { const res = await fetch('/api/mihomo/config.yaml'); if (res.ok) { const el = document.getElementById('yamlPreview'); if (el) el.value = await res.text(); } } catch (e) { /* ignore */ } } // ─── Status rendering ─── function renderStatus() { const s = PS.status; if (!s) return; const text = document.getElementById('statusText'); const headerBadge = document.getElementById('statusBadge'); const toggle = document.getElementById('serviceActive'); const toggleText = document.getElementById('serviceToggleText'); const restartBtn = document.getElementById('restartBtn'); const coreInfo = document.getElementById('coreInfo'); if (s.running) { text.className = 'svc-badge running'; text.textContent = 'Запущен (PID ' + (s.pid || '?') + ')'; headerBadge.className = 'svc-badge running'; headerBadge.textContent = 'Запущен'; toggle.checked = true; toggleText.textContent = 'Запущен'; restartBtn.disabled = false; } else { text.className = 'svc-badge stopped'; text.textContent = 'Остановлен'; headerBadge.className = 'svc-badge stopped'; headerBadge.textContent = 'Остановлен'; toggle.checked = false; toggleText.textContent = 'Остановлен'; restartBtn.disabled = true; } const corePath = document.getElementById('corePath'); const coreExists = document.getElementById('coreExists'); const corePid = document.getElementById('corePid'); if (corePath) corePath.textContent = s.core_path || '—'; if (coreExists) coreExists.textContent = s.core_exists ? 'Да' : 'Нет'; if (corePid) corePid.textContent = s.running && s.pid ? s.pid : '—'; if (!s.core_exists) { coreInfo.classList.remove('hidden'); } else { coreInfo.classList.add('hidden'); } document.getElementById('statusBar').classList.remove('hidden'); document.getElementById('loading').classList.add('hidden'); } // ─── Render all ─── function renderAll() { renderProxies(); renderGroups(); renderRules(); renderProxyProviders(); renderRuleProviders(); fillSettings(); refreshYAMLPreview(); } // ─── Render proxies ─── function renderProxies() { const list = document.getElementById('proxyList'); const empty = document.getElementById('proxyEmpty'); const proxies = getProxies(); list.innerHTML = ''; if (proxies.length === 0) { empty.classList.remove('hidden'); return; } empty.classList.add('hidden'); proxies.forEach(p => { const card = document.createElement('div'); card.className = 'proxy-card'; const server = p.type === 'direct' ? '' : p.type === 'wireguard' ? (p.ip || '—') : `
Сервер${esc(p.server || '—')}
Порт${p.port || '—'}
`; const tags = [ p.udp ? 'UDP' : '', p.tls ? 'TLS' : '', p.network ? `${esc(p.network)}` : '', ].filter(Boolean).join(''); card.innerHTML = `
${esc(p.name)} ${esc(typeLabel(p.type))}
${server}${tags ? `
${tags}
` : ''}
`; list.appendChild(card); }); } // ─── Render groups ─── function renderGroups() { const list = document.getElementById('groupList'); const empty = document.getElementById('groupEmpty'); const groups = getGroups(); list.innerHTML = ''; if (groups.length === 0) { empty.classList.remove('hidden'); return; } empty.classList.add('hidden'); groups.forEach(g => { const card = document.createElement('div'); card.className = 'proxy-card'; const pl = (g.proxies || []).slice(0, 5).map(p => esc(p)).join(', '); const more = (g.proxies || []).length > 5 ? ` +${(g.proxies || []).length - 5}` : ''; card.innerHTML = `
${esc(g.name)} ${esc(groupTypeLabel(g.type))} ${g['include-all'] ? 'Все прокси' : ''}
Узлы${pl || '—'}${more}
${g.url ? `
URL${esc(g.url)}
` : ''} ${g.interval ? `
Интервал${g.interval}с
` : ''}
`; list.appendChild(card); }); } // ─── Render rules ─── function renderRules() { const list = document.getElementById('rulesList'); const rules = getRules(); list.innerHTML = ''; if (rules.length === 0) { list.innerHTML = '
Нет правил маршрутизации
'; return; } rules.forEach((rule, i) => { const parts = rule.split(','); const type = parts[0] || ''; const value = parts[1] || ''; const target = parts[2] || ''; const flags = parts.slice(3).join(','); const el = document.createElement('div'); el.className = 'rule-item'; el.innerHTML = `
${esc(type)} ${esc(value || (type === 'MATCH' ? '*' : ''))} ${esc(target)} ${flags ? `${esc(flags)}` : ''}
`; list.appendChild(el); }); } // ─── Render proxy providers ─── function renderProxyProviders() { const list = document.getElementById('proxyProviderList'); const empty = document.getElementById('proxyProviderEmpty'); const providers = getProxyProviders(); list.innerHTML = ''; if (providers.length === 0) { empty.style.display = ''; return; } empty.style.display = 'none'; providers.forEach(p => { const card = document.createElement('div'); card.className = 'proxy-card'; card.innerHTML = `
${esc(p.name)} ${esc(p.type || 'http')}
${p.url ? `
URL${esc(p.url)}
` : ''} ${p.interval ? `
Интервал${p.interval}с
` : ''} ${p.filter ? `
Filter${esc(p.filter)}
` : ''}
`; list.appendChild(card); }); } // ─── Render rule providers ─── function renderRuleProviders() { const list = document.getElementById('ruleProviderList'); const empty = document.getElementById('ruleProviderEmpty'); const providers = getRuleProviders(); list.innerHTML = ''; if (providers.length === 0) { empty.style.display = ''; return; } empty.style.display = 'none'; providers.forEach(p => { const card = document.createElement('div'); card.className = 'proxy-card'; card.innerHTML = `
${esc(p.name)} ${esc(p.type || 'http')} ${esc(p.behavior || 'classical')}
${p.url ? `
URL${esc(p.url)}
` : ''} ${p.format ? `
Format${esc(p.format)}
` : ''}
`; list.appendChild(card); }); } // ─── Fill settings ─── function fillSettings() { if (!PS.config) return; const c = PS.config; const dns = c.dns || {}; const geo = c['geox-url'] || {}; const profile = c.profile || {}; // Basic setSegBtn('modeSwitch', c.mode || 'rule'); setSegBtn('logLevelSwitch', c['log-level'] || 'info'); setVal('allowLan', c['allow-lan'] !== false, true); setVal('bindAddress', c['bind-address'] || '*'); setVal('ipv6', c.ipv6 !== false, true); setVal('tcpConcurrent', c['tcp-concurrent'] !== false, true); setVal('unifiedDelay', !!c['unified-delay'], true); setSegBtn('findProcessSwitch', c['find-process-mode'] || 'off'); setVal('globalUA', c['global-ua'] || ''); setVal('keepAliveInterval', c['keep-alive-interval'] || ''); setVal('keepAliveIdle', c['keep-alive-idle'] || ''); // Ports setVal('mixedPort', c['mixed-port'] || 7890); setVal('httpPort', c.port || ''); setVal('socksPort', c['socks-port'] || ''); setVal('redirPort', c['redir-port'] || ''); // TProxy setVal('tproxyEnabled', !!c['tproxy-port'], true); setVal('tproxyPort', c['tproxy-port'] || 7894); // DNS setVal('dnsEnabled', dns.enable !== false, true); setVal('dnsListen', dns.listen || '0.0.0.0:1053'); setSegBtn('dnsModeSwitch', dns['enhanced-mode'] || 'redir-host'); setVal('dnsNameserver', (dns.nameserver || []).join('\n')); setVal('dnsFallback', (dns.fallback || []).join('\n')); setVal('dnsDefaultNS', (dns['default-nameserver'] || []).join('\n')); setVal('dnsProxyNS', (dns['proxy-server-nameserver'] || []).join('\n')); setVal('dnsFakeIPRange', dns['fake-ip-range'] || '198.18.0.1/16'); setVal('dnsFakeIPFilter', (dns['fake-ip-filter'] || []).join('\n')); const nsp = dns['nameserver-policy']; setVal('dnsNameserverPolicy', nsp && typeof nsp === 'object' ? Object.entries(nsp).map(([k, v]) => `${k}: ${v}`).join('\n') : ''); setVal('dnsUseHosts', dns['use-hosts'] !== false, true); setVal('dnsUseSystemHosts', dns['use-system-hosts'] !== false, true); // GEO setVal('geodataMode', !!c['geodata-mode'], true); setSegBtn('geodataLoaderSwitch', c['geodata-loader'] || 'memconservative'); setVal('geoAutoUpdate', !!c['geo-auto-update'], true); setVal('geoUpdateInterval', c['geo-update-interval'] || 24); setVal('geoxGeoIP', geo.geoip || ''); setVal('geoxGeoSite', geo.geosite || ''); setVal('geoxMMDB', geo.mmdb || ''); setVal('geoxASN', geo.asn || ''); // External controller setVal('externalController', c['external-controller'] || '0.0.0.0:9090'); setVal('secret', c.secret || ''); setVal('externalUI', c['external-ui'] || ''); setVal('externalUIName', c['external-ui-name'] || ''); setVal('externalUIURL', c['external-ui-url'] || ''); // Profile setVal('profileStoreSelected', profile['store-selected'] !== false, true); setVal('profileStoreFakeIP', !!profile['store-fake-ip'], true); } function setVal(id, val, isCheck) { const el = document.getElementById(id); if (!el) return; if (isCheck) el.checked = !!val; else el.value = val == null ? '' : val; } function gVal(id) { const el = document.getElementById(id); return el ? el.value : ''; } function gCheck(id) { const el = document.getElementById(id); return el ? el.checked : false; } function gNum(id, def) { const v = parseInt(gVal(id)); return isNaN(v) ? (def || 0) : v; } function gLines(id) { return gVal(id).split('\n').map(s => s.trim()).filter(Boolean); } // ─── Apply settings to config ─── function applySettingsToConfig() { if (!PS.config) PS.config = {}; const c = PS.config; c.mode = getSegBtn('modeSwitch'); c['log-level'] = getSegBtn('logLevelSwitch'); c['allow-lan'] = gCheck('allowLan'); c['bind-address'] = gVal('bindAddress') || '*'; c.ipv6 = gCheck('ipv6'); c['tcp-concurrent'] = gCheck('tcpConcurrent'); c['unified-delay'] = gCheck('unifiedDelay'); c['find-process-mode'] = getSegBtn('findProcessSwitch'); const ua = gVal('globalUA'); if (ua) c['global-ua'] = ua; else delete c['global-ua']; const kai = gNum('keepAliveInterval', 0); if (kai) c['keep-alive-interval'] = kai; else delete c['keep-alive-interval']; const kaIdle = gNum('keepAliveIdle', 0); if (kaIdle) c['keep-alive-idle'] = kaIdle; else delete c['keep-alive-idle']; // Ports c['mixed-port'] = gNum('mixedPort', 7890); const hp = gNum('httpPort', 0); if (hp) c.port = hp; else delete c.port; const sp = gNum('socksPort', 0); if (sp) c['socks-port'] = sp; else delete c['socks-port']; const rp = gNum('redirPort', 0); if (rp) c['redir-port'] = rp; else delete c['redir-port']; // TProxy if (gCheck('tproxyEnabled')) { c['tproxy-port'] = gNum('tproxyPort', 7894); } else { delete c['tproxy-port']; } // DNS const nspRaw = gVal('dnsNameserverPolicy').trim(); let nsp = null; if (nspRaw) { nsp = {}; nspRaw.split('\n').forEach(line => { const idx = line.indexOf(':'); if (idx > 0) { nsp[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); } }); } c.dns = { enable: gCheck('dnsEnabled'), ipv6: gCheck('ipv6'), listen: gVal('dnsListen'), 'enhanced-mode': getSegBtn('dnsModeSwitch'), 'fake-ip-range': gVal('dnsFakeIPRange') || '198.18.0.1/16', 'fake-ip-filter': gLines('dnsFakeIPFilter'), 'default-nameserver': gLines('dnsDefaultNS'), nameserver: gLines('dnsNameserver'), fallback: gLines('dnsFallback'), 'use-hosts': gCheck('dnsUseHosts'), 'use-system-hosts': gCheck('dnsUseSystemHosts'), }; const proxyNS = gLines('dnsProxyNS'); if (proxyNS.length) c.dns['proxy-server-nameserver'] = proxyNS; if (nsp) c.dns['nameserver-policy'] = nsp; // GEO c['geodata-mode'] = gCheck('geodataMode'); c['geodata-loader'] = getSegBtn('geodataLoaderSwitch'); c['geo-auto-update'] = gCheck('geoAutoUpdate'); c['geo-update-interval'] = gNum('geoUpdateInterval', 24); const geox = {}; ['geoip', 'geosite', 'mmdb', 'asn'].forEach(k => { const el = document.getElementById(`geox${k.charAt(0).toUpperCase() + k.slice(1)}`); if (el && el.value.trim()) geox[k] = el.value.trim(); }); if (Object.keys(geox).length) c['geox-url'] = geox; else delete c['geox-url']; // External controller c['external-controller'] = gVal('externalController'); c.secret = gVal('secret') || ''; const extUI = gVal('externalUI'); if (extUI) c['external-ui'] = extUI; else delete c['external-ui']; const extUIName = gVal('externalUIName'); if (extUIName) c['external-ui-name'] = extUIName; else delete c['external-ui-name']; const extUIURL = gVal('externalUIURL'); if (extUIURL) c['external-ui-url'] = extUIURL; else delete c['external-ui-url']; // Profile c.profile = { 'store-selected': gCheck('profileStoreSelected'), 'store-fake-ip': gCheck('profileStoreFakeIP'), }; } // ─── Tab switching ─── document.querySelectorAll('.ptab').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.ptab').forEach(b => b.classList.remove('active')); document.querySelectorAll('.ptab-content').forEach(c => c.classList.add('hidden')); btn.classList.add('active'); document.getElementById('tab-' + btn.dataset.tab).classList.remove('hidden'); if (btn.dataset.tab === 'dashboard') { updateDashVisibility(); if (PS.status && PS.status.running) startDashPolling(); } else { stopDashPolling(); } }); }); // ─── Core control ─── document.getElementById('serviceActive').addEventListener('change', async (e) => { const action = e.target.checked ? 'start' : 'stop'; try { await post('/api/mihomo/' + action, null); showToast('Mihomo ' + (action === 'start' ? 'запущен' : 'остановлен'), 'success'); await loadStatus(); updateDashVisibility(); if (action === 'start') { const dashTab = document.querySelector('.ptab[data-tab="dashboard"]'); if (dashTab && dashTab.classList.contains('active')) startDashPolling(); } else { stopDashPolling(); } } catch (err) { showToast('Ошибка: ' + err.message, 'error'); await loadStatus(); updateDashVisibility(); } }); document.getElementById('restartBtn').addEventListener('click', async () => { try { await post('/api/mihomo/restart', null); showToast('Mihomo перезапущен', 'success'); await loadStatus(); } catch (e) { showToast('Ошибка: ' + e.message, 'error'); } }); // ─── Settings ─── document.getElementById('settingsForm').addEventListener('submit', e => { e.preventDefault(); applySettingsToConfig(); saveFullConfig(false); }); document.getElementById('saveAndRestartBtn').addEventListener('click', () => { applySettingsToConfig(); saveFullConfig(true); }); // ════════════════════════════════════════════════════════════ // PROXY MODAL // ════════════════════════════════════════════════════════════ // Protocols needing transport const TRANSPORT_PROTOS = ['vmess', 'vless', 'trojan']; // Protocols needing TLS section (always shown) const TLS_PROTOS = ['ss', 'vmess', 'vless', 'trojan', 'hysteria', 'hysteria2', 'tuic', 'snell', 'anytls', 'http', 'socks5']; // TLS always on (no toggle) const TLS_ALWAYS = ['trojan', 'hysteria', 'hysteria2', 'tuic']; // Protocols with client-fingerprint const CLIENT_FP_PROTOS = ['vmess', 'vless', 'trojan', 'anytls']; // Protocols with Reality support (shows Reality section inside TLS) const REALITY_PROTOS = ['vless', 'vmess']; // All proto-specific section IDs const PROTO_SECTIONS = ['pf-ss', 'pf-ssr', 'pf-vmess', 'pf-vless', 'pf-trojan', 'pf-hysteria', 'pf-hysteria2', 'pf-tuic', 'pf-wireguard', 'pf-snell', 'pf-ssh', 'pf-anytls', 'pf-mieru', 'pf-http-auth']; function showEl(id) { const e = document.getElementById(id); if (e) e.classList.remove('hidden'); } function hideEl(id) { const e = document.getElementById(id); if (e) e.classList.add('hidden'); } function updateProxyFields() { const type = document.getElementById('proxyType').value; // Hide all proto sections PROTO_SECTIONS.forEach(hideEl); // Server/port visibility if (type === 'direct') { hideEl('pf-server-section'); } else { showEl('pf-server-section'); } // UDP row visibility const noUDP = ['wireguard']; // WireGuard has its own udp field // actually WireGuard does have udp field in common but let's keep it // Show the appropriate proto section switch (type) { case 'ss': showEl('pf-ss'); break; case 'ssr': showEl('pf-ssr'); break; case 'vmess': showEl('pf-vmess'); break; case 'vless': showEl('pf-vless'); break; case 'trojan': showEl('pf-trojan'); break; case 'hysteria': showEl('pf-hysteria'); break; case 'hysteria2': showEl('pf-hysteria2'); break; case 'tuic': showEl('pf-tuic'); break; case 'wireguard': showEl('pf-wireguard'); break; case 'snell': showEl('pf-snell'); break; case 'ssh': showEl('pf-ssh'); break; case 'anytls': showEl('pf-anytls'); break; case 'mieru': showEl('pf-mieru'); break; case 'http': case 'socks5': showEl('pf-http-auth'); // headers only for http const hrow = document.getElementById('pf-http-headers-row'); if (hrow) hrow.classList.toggle('hidden', type !== 'http'); break; } // Transport section if (TRANSPORT_PROTOS.includes(type)) { showEl('pf-transport-section'); updateTransportOpts(); // show/hide transport sub-opts // XHTTP only for VLESS const xopt = document.getElementById('pf-xhttp-opts'); if (xopt) { const net = document.getElementById('proxyNetwork').value; if (type === 'vless' && net === 'xhttp') xopt.classList.remove('hidden'); else xopt.classList.add('hidden'); } } else { hideEl('pf-transport-section'); } // TLS section if (TLS_PROTOS.includes(type)) { showEl('pf-tls-section'); const toggleRow = document.getElementById('pf-tls-toggle-row'); if (TLS_ALWAYS.includes(type)) { // Always on — hide toggle, show fields if (toggleRow) toggleRow.classList.add('hidden'); showEl('pf-tls-fields'); } else { if (toggleRow) toggleRow.classList.remove('hidden'); const tlsOn = document.getElementById('proxyTLS') && document.getElementById('proxyTLS').checked; if (tlsOn) showEl('pf-tls-fields'); else hideEl('pf-tls-fields'); } // Client fingerprint const cfpRow = document.getElementById('pf-client-fp-row'); if (cfpRow) cfpRow.classList.toggle('hidden', !CLIENT_FP_PROTOS.includes(type)); // Reality section const realSec = document.getElementById('pf-reality-section'); if (realSec) realSec.classList.toggle('hidden', !REALITY_PROTOS.includes(type)); } else { hideEl('pf-tls-section'); } } function updateTransportOpts() { const net = document.getElementById('proxyNetwork').value; const type = document.getElementById('proxyType').value; hideEl('pf-ws-opts'); hideEl('pf-h2-opts'); hideEl('pf-grpc-opts'); hideEl('pf-http-transport-opts'); hideEl('pf-xhttp-opts'); switch (net) { case 'ws': showEl('pf-ws-opts'); break; case 'h2': showEl('pf-h2-opts'); break; case 'grpc': showEl('pf-grpc-opts'); break; case 'http': showEl('pf-http-transport-opts'); break; case 'xhttp': if (type === 'vless') showEl('pf-xhttp-opts'); break; } } document.getElementById('proxyType').addEventListener('change', updateProxyFields); document.getElementById('proxyNetwork').addEventListener('change', updateTransportOpts); document.getElementById('proxyTLS').addEventListener('change', () => { const on = document.getElementById('proxyTLS').checked; if (on) showEl('pf-tls-fields'); else hideEl('pf-tls-fields'); }); document.getElementById('ssPlugin').addEventListener('change', () => { const plugin = document.getElementById('ssPlugin').value; if (!plugin) { hideEl('pf-ss-obfs'); hideEl('pf-ss-plugin-opts'); } else if (plugin === 'obfs') { showEl('pf-ss-obfs'); hideEl('pf-ss-plugin-opts'); } else { hideEl('pf-ss-obfs'); showEl('pf-ss-plugin-opts'); // Set placeholder for plugin opts const ta = document.getElementById('ssPluginOpts'); if (plugin === 'v2ray-plugin' || plugin === 'gost-plugin') { ta.placeholder = 'mode: websocket\ntls: true\nhost: example.com\npath: /'; } else if (plugin === 'shadow-tls') { ta.placeholder = 'host: cloud.tencent.com\npassword: shadow_tls_password\nversion: 3'; } else if (plugin === 'restls') { ta.placeholder = 'host: www.microsoft.com\npassword: your_password\nversion-hint: tls13'; } else if (plugin === 'kcptun') { ta.placeholder = 'key: secret\ncrypt: aes\nmode: fast'; } } }); document.getElementById('snellObfsMode').addEventListener('change', () => { const mode = document.getElementById('snellObfsMode').value; const row = document.getElementById('pf-snell-obfs-host'); if (row) row.classList.toggle('hidden', !mode); }); // TUIC version switch — visibility handled after generic seg handler document.getElementById('tuicVersionSwitch').addEventListener('click', e => { const btn = e.target.closest('.seg-btn'); if (!btn) return; // Let the generic handler set active class, then update visibility setTimeout(() => { const v = getSegBtn('tuicVersionSwitch') || '5'; const tokenRow = document.getElementById('pf-tuic-token'); const v5auth = document.getElementById('pf-tuic-v5-auth'); if (v === '4') { if (tokenRow) tokenRow.classList.remove('hidden'); if (v5auth) v5auth.classList.add('hidden'); } else { if (tokenRow) tokenRow.classList.add('hidden'); if (v5auth) v5auth.classList.remove('hidden'); } }, 0); }); // ─── Open proxy modal ─── let editProxyName = null; function openProxyModal(proxy) { editProxyName = proxy ? proxy.name : null; document.getElementById('proxyModalTitle').textContent = proxy ? 'Редактировать прокси' : 'Добавить прокси'; document.getElementById('proxyEditName').value = proxy ? proxy.name : ''; const p = proxy || {}; const type = p.type || 'ss'; document.getElementById('proxyType').value = type; document.getElementById('proxyName').value = p.name || ''; document.getElementById('proxyServer').value = p.server || ''; document.getElementById('proxyPort').value = p.port || ''; // Common advanced document.getElementById('proxyUDP').checked = p.udp !== false; document.getElementById('proxyIPVersion').value = p['ip-version'] || ''; document.getElementById('proxyInterface').value = p['interface-name'] || ''; document.getElementById('proxyRoutingMark').value = p['routing-mark'] || ''; document.getElementById('proxyTFO').checked = !!p.tfo; document.getElementById('proxyMPTCP').checked = !!p.mptcp; document.getElementById('proxyDialerProxy').value = p['dialer-proxy'] || ''; // TLS document.getElementById('proxyTLS').checked = !!p.tls; document.getElementById('proxySNI').value = p.servername || p.sni || ''; document.getElementById('proxyFingerprint').value = p.fingerprint || ''; document.getElementById('proxyALPN').value = Array.isArray(p.alpn) ? p.alpn.join('\n') : (p.alpn || ''); document.getElementById('proxySkipCertVerify').checked = !!p['skip-cert-verify']; document.getElementById('proxyClientFP').value = p['client-fingerprint'] || ''; // Reality const ro = p['reality-opts'] || {}; document.getElementById('realityPublicKey').value = ro['public-key'] || ''; document.getElementById('realityShortID').value = ro['short-id'] || ''; document.getElementById('realityX25519mlkem').checked = !!ro['support-x25519mlkem768']; // ECH const ech = p['ech-opts'] || {}; document.getElementById('echEnable').checked = !!ech.enable; document.getElementById('echConfig').value = ech.config || ''; document.getElementById('echQuerySNI').value = ech['query-server-name'] || ''; // Transport document.getElementById('proxyNetwork').value = p.network || ''; const ws = p['ws-opts'] || {}; document.getElementById('wsPath').value = ws.path || ''; document.getElementById('wsHost').value = (ws.headers && ws.headers.Host) || ''; document.getElementById('wsMaxEarlyData').value = ws['max-early-data'] || ''; document.getElementById('wsEarlyDataHeader').value = ws['early-data-header-name'] || ''; document.getElementById('wsV2rayUpgrade').checked = !!ws['v2ray-http-upgrade']; document.getElementById('wsV2rayUpgradeFO').checked = !!ws['v2ray-http-upgrade-fast-open']; const h2 = p['h2-opts'] || {}; document.getElementById('h2Host').value = Array.isArray(h2.host) ? h2.host.join('\n') : (h2.host || ''); document.getElementById('h2Path').value = h2.path || ''; const grpc = p['grpc-opts'] || {}; document.getElementById('grpcServiceName').value = grpc['grpc-service-name'] || ''; document.getElementById('grpcUserAgent').value = grpc['grpc-user-agent'] || ''; const http = p['http-opts'] || {}; document.getElementById('httpTransportMethod').value = http.method || 'GET'; document.getElementById('httpTransportPath').value = Array.isArray(http.path) ? http.path.join('\n') : (http.path || ''); const xhttp = p['xhttp-opts'] || {}; document.getElementById('xhttpPath').value = xhttp.path || ''; document.getElementById('xhttpHost').value = xhttp.host || ''; document.getElementById('xhttpMode').value = xhttp.mode || ''; document.getElementById('xhttpNoGRPC').checked = !!xhttp['no-grpc-header']; document.getElementById('xhttpPaddingBytes').value = xhttp['x-padding-bytes'] || ''; // ── Protocol fields ── // SS document.getElementById('ssCipher').value = p.cipher || 'auto'; document.getElementById('ssPassword').value = p.password || ''; document.getElementById('ssUOT').checked = !!p['udp-over-tcp']; setSegBtn('ssUOTVersionSwitch', String(p['udp-over-tcp-version'] || '1')); document.getElementById('ssPlugin').value = p.plugin || ''; if (p.plugin === 'obfs') { document.getElementById('ssObfsMode').value = (p['plugin-opts'] || {}).mode || 'tls'; document.getElementById('ssObfsHost').value = (p['plugin-opts'] || {}).host || ''; } else if (p.plugin && p['plugin-opts']) { // Serialize plugin-opts to YAML-like text document.getElementById('ssPluginOpts').value = objToYAML(p['plugin-opts']); } // SSR document.getElementById('ssrCipher').value = p.cipher || 'none'; document.getElementById('ssrPassword').value = p.password || ''; document.getElementById('ssrProtocol').value = p.protocol || 'origin'; document.getElementById('ssrProtocolParam').value = p['protocol-param'] || ''; document.getElementById('ssrObfs').value = p.obfs || 'plain'; document.getElementById('ssrObfsParam').value = p['obfs-param'] || ''; // VMess document.getElementById('vmessUUID').value = p.uuid || ''; document.getElementById('vmessAlterID').value = p.alterId != null ? p.alterId : 0; document.getElementById('vmessCipher').value = p.cipher || 'auto'; // VLESS document.getElementById('vlessUUID').value = p.uuid || ''; document.getElementById('vlessFlow').value = p.flow || ''; document.getElementById('vlessEncryption').value = p.encryption || 'none'; // Trojan document.getElementById('trojanPassword').value = p.password || ''; // Hysteria v1 document.getElementById('hyAuthStr').value = p['auth-str'] || ''; document.getElementById('hyProtocol').value = p.protocol || 'udp'; document.getElementById('hyUp').value = p.up || ''; document.getElementById('hyDown').value = p.down || ''; document.getElementById('hyObfs').value = p.obfs || ''; document.getElementById('hyPorts').value = p.ports || ''; document.getElementById('hyFastOpen').checked = !!p['fast-open']; // Hysteria2 document.getElementById('hy2Password').value = p.password || ''; document.getElementById('hy2Up').value = p.up || ''; document.getElementById('hy2Down').value = p.down || ''; document.getElementById('hy2ObfsType').value = p.obfs || ''; document.getElementById('hy2ObfsPassword').value = p['obfs-password'] || ''; document.getElementById('hy2Ports').value = p.ports || ''; document.getElementById('hy2HopInterval').value = p['hop-interval'] || ''; // TUIC const tuicV = p.token ? '4' : '5'; setSegBtn('tuicVersionSwitch', tuicV); document.getElementById('pf-tuic-token').classList.toggle('hidden', tuicV !== '4'); document.getElementById('pf-tuic-v5-auth').classList.toggle('hidden', tuicV !== '5'); document.getElementById('tuicToken').value = p.token || ''; document.getElementById('tuicUUID').value = p.uuid || ''; document.getElementById('tuicPassword').value = p.password || ''; document.getElementById('tuicIP').value = p.ip || ''; document.getElementById('tuicHeartbeat').value = p['heartbeat-interval'] || ''; document.getElementById('tuicUDPMode').value = p['udp-relay-mode'] || 'native'; document.getElementById('tuicCongestion').value = p['congestion-controller'] || ''; document.getElementById('tuicMaxUDP').value = p['max-udp-relay-packet-size'] || ''; document.getElementById('tuicReqTimeout').value = p['request-timeout'] || ''; document.getElementById('tuicMaxStreams').value = p['max-open-streams'] || ''; document.getElementById('tuicDisableSNI').checked = !!p['disable-sni']; document.getElementById('tuicReduceRTT').checked = !!p['reduce-rtt']; document.getElementById('tuicFastOpen').checked = !!p['fast-open']; // WireGuard document.getElementById('wgPrivateKey').value = p['private-key'] || ''; document.getElementById('wgIP').value = p.ip || ''; document.getElementById('wgIPv6').value = p.ipv6 || ''; // In simple mode, use top-level server/port/public-key document.getElementById('wgPublicKey').value = p['public-key'] || ''; document.getElementById('wgAllowedIPs').value = Array.isArray(p['allowed-ips']) ? p['allowed-ips'].join(', ') : (p['allowed-ips'] || '0.0.0.0/0'); document.getElementById('wgPreSharedKey').value = p['pre-shared-key'] || ''; document.getElementById('wgReserved').value = Array.isArray(p.reserved) ? p.reserved.join(',') : (p.reserved || ''); document.getElementById('wgMTU').value = p.mtu || ''; document.getElementById('wgKeepalive').value = p['persistent-keepalive'] || ''; document.getElementById('wgRemoteDNS').checked = !!p['remote-dns-resolve']; document.getElementById('wgDNS').value = Array.isArray(p.dns) ? p.dns.join(', ') : (p.dns || ''); document.getElementById('wgDialerProxy').value = p['dialer-proxy'] || ''; // Snell document.getElementById('snellPSK').value = p.psk || ''; document.getElementById('snellVersion').value = p.version || '3'; const snellObfsMode = (p['obfs-opts'] || {}).mode || ''; document.getElementById('snellObfsMode').value = snellObfsMode; document.getElementById('snellObfsHost').value = (p['obfs-opts'] || {}).host || ''; const snellHostRow = document.getElementById('pf-snell-obfs-host'); if (snellHostRow) snellHostRow.classList.toggle('hidden', !snellObfsMode); // SSH document.getElementById('sshUsername').value = p.username || ''; document.getElementById('sshPassword').value = p.password || ''; document.getElementById('sshPrivateKey').value = p['private-key'] || ''; document.getElementById('sshPrivateKeyPass').value = p['private-key-passphrase'] || ''; document.getElementById('sshHostKey').value = Array.isArray(p['host-key']) ? p['host-key'].join('\n') : ''; document.getElementById('sshHostKeyAlgos').value = Array.isArray(p['host-key-algorithms']) ? p['host-key-algorithms'].join('\n') : ''; // AnyTLS document.getElementById('anytlsPassword').value = p.password || ''; // Mieru document.getElementById('mieruUsername').value = p.username || ''; document.getElementById('mieruPassword').value = p.password || ''; if (p.transport) document.getElementById('mieruTransport').value = p.transport; // HTTP/SOCKS5 document.getElementById('httpUsername').value = p.username || ''; document.getElementById('httpPassword').value = p.password || ''; if (p.headers && typeof p.headers === 'object') { document.getElementById('httpHeaders').value = Object.entries(p.headers) .map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`).join('\n'); } else { document.getElementById('httpHeaders').value = ''; } updateProxyFields(); // Trigger plugin-related visibility document.getElementById('ssPlugin').dispatchEvent(new Event('change')); document.getElementById('proxyModal').classList.remove('hidden'); } function objToYAML(obj, indent) { if (!obj || typeof obj !== 'object') return String(obj || ''); indent = indent || ''; return Object.entries(obj).map(([k, v]) => { if (v && typeof v === 'object') { return `${indent}${k}:\n${objToYAML(v, indent + ' ')}`; } return `${indent}${k}: ${v}`; }).join('\n'); } function parseYAMLToObj(text) { const result = {}; if (!text) return result; text.split('\n').forEach(line => { const idx = line.indexOf(':'); if (idx > 0) { const k = line.slice(0, idx).trim(); const v = line.slice(idx + 1).trim(); if (k) result[k] = v; } }); return result; } function closeProxyModal() { document.getElementById('proxyModal').classList.add('hidden'); } document.getElementById('addProxyBtn').addEventListener('click', () => openProxyModal(null)); document.getElementById('closeProxyModal').addEventListener('click', closeProxyModal); document.getElementById('cancelProxyBtn').addEventListener('click', closeProxyModal); document.getElementById('proxyModalBackdrop').addEventListener('click', closeProxyModal); document.getElementById('saveProxyBtn').addEventListener('click', () => { const type = document.getElementById('proxyType').value; const name = document.getElementById('proxyName').value.trim(); if (!name) { showToast('Имя прокси обязательно', 'error'); return; } const proxy = { name, type }; // Server/port if (type !== 'direct') { proxy.server = gVal('proxyServer').trim(); const port = parseInt(gVal('proxyPort')); if (port) proxy.port = port; } // Common advanced proxy.udp = gCheck('proxyUDP'); const ipVer = gVal('proxyIPVersion'); if (ipVer) proxy['ip-version'] = ipVer; const iface = gVal('proxyInterface').trim(); if (iface) proxy['interface-name'] = iface; const mark = parseInt(gVal('proxyRoutingMark')); if (mark) proxy['routing-mark'] = mark; if (gCheck('proxyTFO')) proxy.tfo = true; if (gCheck('proxyMPTCP')) proxy.mptcp = true; const dialerProxy = gVal('proxyDialerProxy').trim(); if (dialerProxy) proxy['dialer-proxy'] = dialerProxy; // TLS const hasTLS = TLS_PROTOS.includes(type); const tlsAlways = TLS_ALWAYS.includes(type); const tlsEnabled = tlsAlways || (hasTLS && gCheck('proxyTLS')); if (hasTLS) { if (!tlsAlways) proxy.tls = gCheck('proxyTLS'); if (tlsEnabled) { const sni = gVal('proxySNI').trim(); if (sni) { if (type === 'vmess' || type === 'vless') proxy.servername = sni; else proxy.sni = sni; } const fp = gVal('proxyFingerprint').trim(); if (fp) proxy.fingerprint = fp; const alpn = gLines('proxyALPN'); if (alpn.length) proxy.alpn = alpn; if (gCheck('proxySkipCertVerify')) proxy['skip-cert-verify'] = true; if (CLIENT_FP_PROTOS.includes(type)) { const cfp = gVal('proxyClientFP'); if (cfp) proxy['client-fingerprint'] = cfp; } // Reality if (REALITY_PROTOS.includes(type)) { const rPubKey = gVal('realityPublicKey').trim(); const rShortID = gVal('realityShortID').trim(); if (rPubKey || rShortID) { proxy['reality-opts'] = {}; if (rPubKey) proxy['reality-opts']['public-key'] = rPubKey; if (rShortID) proxy['reality-opts']['short-id'] = rShortID; if (gCheck('realityX25519mlkem')) proxy['reality-opts']['support-x25519mlkem768'] = true; } } // ECH if (gCheck('echEnable')) { proxy['ech-opts'] = { enable: true }; const echCfg = gVal('echConfig').trim(); if (echCfg) proxy['ech-opts'].config = echCfg; const echQ = gVal('echQuerySNI').trim(); if (echQ) proxy['ech-opts']['query-server-name'] = echQ; } } } // Transport if (TRANSPORT_PROTOS.includes(type)) { const net = gVal('proxyNetwork'); if (net) { proxy.network = net; switch (net) { case 'ws': { const wsOpts = {}; const wsPath = gVal('wsPath').trim(); if (wsPath) wsOpts.path = wsPath; const wsHost = gVal('wsHost').trim(); if (wsHost) wsOpts.headers = { Host: wsHost }; const wsMaxED = parseInt(gVal('wsMaxEarlyData')); if (wsMaxED) wsOpts['max-early-data'] = wsMaxED; const wsEDH = gVal('wsEarlyDataHeader').trim(); if (wsEDH) wsOpts['early-data-header-name'] = wsEDH; if (gCheck('wsV2rayUpgrade')) wsOpts['v2ray-http-upgrade'] = true; if (gCheck('wsV2rayUpgradeFO')) wsOpts['v2ray-http-upgrade-fast-open'] = true; if (Object.keys(wsOpts).length) proxy['ws-opts'] = wsOpts; break; } case 'h2': { const h2Opts = {}; const h2host = gLines('h2Host'); if (h2host.length) h2Opts.host = h2host; const h2path = gVal('h2Path').trim(); if (h2path) h2Opts.path = h2path; if (Object.keys(h2Opts).length) proxy['h2-opts'] = h2Opts; break; } case 'grpc': { const grpcOpts = {}; const gsn = gVal('grpcServiceName').trim(); if (gsn) grpcOpts['grpc-service-name'] = gsn; const gua = gVal('grpcUserAgent').trim(); if (gua) grpcOpts['grpc-user-agent'] = gua; if (Object.keys(grpcOpts).length) proxy['grpc-opts'] = grpcOpts; break; } case 'http': { const httpOpts = {}; const method = gVal('httpTransportMethod'); if (method) httpOpts.method = method; const paths = gLines('httpTransportPath'); if (paths.length) httpOpts.path = paths; if (Object.keys(httpOpts).length) proxy['http-opts'] = httpOpts; break; } case 'xhttp': { if (type === 'vless') { const xopts = {}; const xp = gVal('xhttpPath').trim(); if (xp) xopts.path = xp; const xh = gVal('xhttpHost').trim(); if (xh) xopts.host = xh; const xm = gVal('xhttpMode'); if (xm) xopts.mode = xm; if (gCheck('xhttpNoGRPC')) xopts['no-grpc-header'] = true; const xpad = gVal('xhttpPaddingBytes').trim(); if (xpad) xopts['x-padding-bytes'] = xpad; if (Object.keys(xopts).length) proxy['xhttp-opts'] = xopts; } break; } } } } // ── Protocol-specific ── switch (type) { case 'ss': { proxy.cipher = gVal('ssCipher'); proxy.password = gVal('ssPassword'); if (gCheck('ssUOT')) { proxy['udp-over-tcp'] = true; const uotVer = parseInt(getSegBtn('ssUOTVersionSwitch') || '1'); if (uotVer === 2) proxy['udp-over-tcp-version'] = 2; } const plugin = gVal('ssPlugin'); if (plugin) { proxy.plugin = plugin; if (plugin === 'obfs') { proxy['plugin-opts'] = { mode: gVal('ssObfsMode') || 'tls' }; const oh = gVal('ssObfsHost').trim(); if (oh) proxy['plugin-opts'].host = oh; } else { const optsText = gVal('ssPluginOpts').trim(); if (optsText) proxy['plugin-opts'] = parseYAMLToObj(optsText); } } break; } case 'ssr': { proxy.cipher = gVal('ssrCipher'); proxy.password = gVal('ssrPassword'); proxy.protocol = gVal('ssrProtocol'); const pp = gVal('ssrProtocolParam').trim(); if (pp) proxy['protocol-param'] = pp; proxy.obfs = gVal('ssrObfs'); const op = gVal('ssrObfsParam').trim(); if (op) proxy['obfs-param'] = op; break; } case 'vmess': { proxy.uuid = gVal('vmessUUID').trim(); proxy.alterId = gNum('vmessAlterID', 0); proxy.cipher = gVal('vmessCipher'); break; } case 'vless': { proxy.uuid = gVal('vlessUUID').trim(); const flow = gVal('vlessFlow'); if (flow) proxy.flow = flow; proxy.encryption = gVal('vlessEncryption') || 'none'; break; } case 'trojan': { proxy.password = gVal('trojanPassword'); break; } case 'hysteria': { proxy['auth-str'] = gVal('hyAuthStr'); proxy.protocol = gVal('hyProtocol') || 'udp'; const up = gVal('hyUp').trim(); if (up) proxy.up = up; const down = gVal('hyDown').trim(); if (down) proxy.down = down; const obfs = gVal('hyObfs').trim(); if (obfs) proxy.obfs = obfs; const ports = gVal('hyPorts').trim(); if (ports) proxy.ports = ports; if (gCheck('hyFastOpen')) proxy['fast-open'] = true; break; } case 'hysteria2': { proxy.password = gVal('hy2Password'); const up2 = gVal('hy2Up').trim(); if (up2) proxy.up = up2; const down2 = gVal('hy2Down').trim(); if (down2) proxy.down = down2; const obfsType = gVal('hy2ObfsType'); if (obfsType) { proxy.obfs = obfsType; const obfsPass = gVal('hy2ObfsPassword').trim(); if (obfsPass) proxy['obfs-password'] = obfsPass; } const ports2 = gVal('hy2Ports').trim(); if (ports2) proxy.ports = ports2; const hopInt = parseInt(gVal('hy2HopInterval')); if (hopInt) proxy['hop-interval'] = hopInt; break; } case 'tuic': { const tuicV = getSegBtn('tuicVersionSwitch') || '5'; if (tuicV === '4') { proxy.token = gVal('tuicToken').trim(); } else { proxy.uuid = gVal('tuicUUID').trim(); proxy.password = gVal('tuicPassword'); } const tip = gVal('tuicIP').trim(); if (tip) proxy.ip = tip; const hb = parseInt(gVal('tuicHeartbeat')); if (hb) proxy['heartbeat-interval'] = hb; proxy['udp-relay-mode'] = gVal('tuicUDPMode') || 'native'; const cong = gVal('tuicCongestion'); if (cong) proxy['congestion-controller'] = cong; const maxUDP = parseInt(gVal('tuicMaxUDP')); if (maxUDP) proxy['max-udp-relay-packet-size'] = maxUDP; const reqTOut = parseInt(gVal('tuicReqTimeout')); if (reqTOut) proxy['request-timeout'] = reqTOut; const maxStr = parseInt(gVal('tuicMaxStreams')); if (maxStr) proxy['max-open-streams'] = maxStr; if (gCheck('tuicDisableSNI')) proxy['disable-sni'] = true; if (gCheck('tuicReduceRTT')) proxy['reduce-rtt'] = true; if (gCheck('tuicFastOpen')) proxy['fast-open'] = true; break; } case 'wireguard': { proxy['private-key'] = gVal('wgPrivateKey').trim(); const wip = gVal('wgIP').trim(); if (wip) proxy.ip = wip; const wipv6 = gVal('wgIPv6').trim(); if (wipv6) proxy.ipv6 = wipv6; proxy['public-key'] = gVal('wgPublicKey').trim(); const allowedIPs = gVal('wgAllowedIPs').split(',').map(s => s.trim()).filter(Boolean); proxy['allowed-ips'] = allowedIPs.length ? allowedIPs : ['0.0.0.0/0']; const psk = gVal('wgPreSharedKey').trim(); if (psk) proxy['pre-shared-key'] = psk; const wgRes = gVal('wgReserved').trim(); if (wgRes) proxy.reserved = wgRes; const wgMTU = parseInt(gVal('wgMTU')); if (wgMTU) proxy.mtu = wgMTU; const wgKA = parseInt(gVal('wgKeepalive')); if (wgKA) proxy['persistent-keepalive'] = wgKA; if (gCheck('wgRemoteDNS')) { proxy['remote-dns-resolve'] = true; const wgDNS = gVal('wgDNS').split(',').map(s => s.trim()).filter(Boolean); if (wgDNS.length) proxy.dns = wgDNS; } const wgDP = gVal('wgDialerProxy').trim(); if (wgDP) proxy['dialer-proxy'] = wgDP; break; } case 'snell': { proxy.psk = gVal('snellPSK'); proxy.version = parseInt(gVal('snellVersion')) || 3; const snellMode = gVal('snellObfsMode'); if (snellMode) { proxy['obfs-opts'] = { mode: snellMode }; const sh = gVal('snellObfsHost').trim(); if (sh) proxy['obfs-opts'].host = sh; } break; } case 'ssh': { proxy.username = gVal('sshUsername').trim(); const sshPass = gVal('sshPassword'); if (sshPass) proxy.password = sshPass; const sshPK = gVal('sshPrivateKey').trim(); if (sshPK) proxy['private-key'] = sshPK; const sshPKP = gVal('sshPrivateKeyPass').trim(); if (sshPKP) proxy['private-key-passphrase'] = sshPKP; const sshHK = gLines('sshHostKey'); if (sshHK.length) proxy['host-key'] = sshHK; const sshHKA = gLines('sshHostKeyAlgos'); if (sshHKA.length) proxy['host-key-algorithms'] = sshHKA; break; } case 'anytls': { proxy.password = gVal('anytlsPassword'); break; } case 'mieru': { proxy.username = gVal('mieruUsername').trim(); proxy.password = gVal('mieruPassword'); proxy.transport = gVal('mieruTransport'); break; } case 'http': case 'socks5': { const uname = gVal('httpUsername').trim(); if (uname) proxy.username = uname; const upass = gVal('httpPassword'); if (upass) proxy.password = upass; if (type === 'http') { const hdrText = gVal('httpHeaders').trim(); if (hdrText) { const hdrs = {}; hdrText.split('\n').forEach(line => { const idx = line.indexOf(':'); if (idx > 0) { hdrs[line.slice(0, idx).trim()] = [line.slice(idx + 1).trim()]; } }); if (Object.keys(hdrs).length) proxy.headers = hdrs; } } break; } case 'direct': // nothing extra needed break; } // Save to config const proxies = getProxies(); if (editProxyName) { const idx = proxies.findIndex(p => p.name === editProxyName); if (idx >= 0) { const oldName = proxies[idx].name; proxies[idx] = proxy; if (proxy.name !== oldName) { getGroups().forEach(g => { if (g.proxies) g.proxies = g.proxies.map(pn => pn === oldName ? proxy.name : pn); }); } } } else { if (proxies.some(p => p.name === proxy.name)) { showToast('Прокси с таким именем уже существует', 'error'); return; } proxies.push(proxy); } PS.config.proxies = proxies; closeProxyModal(); renderAll(); saveFullConfig(false); }); // Proxy list delegated events document.getElementById('proxyList').addEventListener('click', e => { const editBtn = e.target.closest('[data-edit-proxy]'); const delBtn = e.target.closest('[data-delete-proxy]'); if (editBtn) { const proxy = getProxies().find(p => p.name === editBtn.dataset.editProxy); if (proxy) openProxyModal(proxy); } else if (delBtn) { const name = delBtn.dataset.deleteProxy; if (confirm(`Удалить прокси "${name}"?`)) { PS.config.proxies = getProxies().filter(p => p.name !== name); getGroups().forEach(g => { if (g.proxies) g.proxies = g.proxies.filter(pn => pn !== name); }); renderAll(); saveFullConfig(false); } } }); // ════════════════════════════════════════════════════════════ // GROUP MODAL // ════════════════════════════════════════════════════════════ function updateGroupFields() { const type = document.getElementById('groupType').value; const urlField = document.getElementById('groupURLField'); const lbField = document.getElementById('groupLBStrategyDiv'); const needsURL = ['url-test', 'fallback', 'load-balance'].includes(type); urlField.classList.toggle('hidden', !needsURL); lbField.classList.toggle('hidden', type !== 'load-balance'); } document.getElementById('groupType').addEventListener('change', updateGroupFields); let editGroupName = null; function openGroupModal(group) { editGroupName = group ? group.name : null; document.getElementById('groupEditName').value = group ? group.name : ''; document.getElementById('groupModalTitle').textContent = group ? 'Редактировать группу' : 'Добавить группу'; document.getElementById('groupName').value = group ? group.name : ''; document.getElementById('groupType').value = group ? group.type : 'select'; document.getElementById('groupURL').value = group ? (group.url || 'https://www.gstatic.com/generate_204') : 'https://www.gstatic.com/generate_204'; document.getElementById('groupInterval').value = group ? (group.interval || 300) : 300; document.getElementById('groupTimeout').value = group ? (group.timeout || 5000) : 5000; document.getElementById('groupTolerance').value = group ? (group.tolerance || 50) : 50; document.getElementById('groupMaxFailed').value = group ? (group['max-failed-times'] || 5) : 5; document.getElementById('groupLazy').checked = group ? (group.lazy !== false) : true; document.getElementById('groupIncludeAll').checked = group ? (group['include-all'] || false) : false; document.getElementById('groupFilter').value = group ? (group.filter || '') : ''; document.getElementById('groupExcludeFilter').value = group ? (group['exclude-filter'] || '') : ''; document.getElementById('groupExcludeType').value = group ? (group['exclude-type'] || '') : ''; document.getElementById('groupExpectedStatus').value = group ? (group['expected-status'] || '') : ''; document.getElementById('groupLBStrategy').value = group ? (group.strategy || 'round-robin') : 'round-robin'; document.getElementById('groupDisableUDP').checked = group ? (group['disable-udp'] || false) : false; const checkboxes = document.getElementById('groupProxyCheckboxes'); checkboxes.innerHTML = ''; const selected = group ? (group.proxies || []) : []; const allItems = ['DIRECT', 'REJECT', ...getProxies().map(p => p.name), ...getGroups().filter(g => !group || g.name !== group.name).map(g => g.name)]; allItems.forEach(name => { const label = document.createElement('label'); label.className = 'checkbox-label'; label.style.cssText = 'font-size:.85rem;padding:4px 0;display:flex;align-items:center;gap:6px'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.value = name; if (selected.includes(name)) cb.checked = true; label.appendChild(cb); label.appendChild(document.createTextNode(name)); checkboxes.appendChild(label); }); updateGroupFields(); document.getElementById('groupModal').classList.remove('hidden'); } function closeGroupModal() { document.getElementById('groupModal').classList.add('hidden'); } document.getElementById('addGroupBtn').addEventListener('click', () => openGroupModal(null)); document.getElementById('closeGroupModal').addEventListener('click', closeGroupModal); document.getElementById('cancelGroupBtn').addEventListener('click', closeGroupModal); document.getElementById('groupModalBackdrop').addEventListener('click', closeGroupModal); document.getElementById('saveGroupBtn').addEventListener('click', () => { const selectedProxies = []; document.querySelectorAll('#groupProxyCheckboxes input[type="checkbox"]:checked').forEach(cb => selectedProxies.push(cb.value)); const type = document.getElementById('groupType').value; const hasURL = ['url-test', 'fallback', 'load-balance'].includes(type); const group = { name: document.getElementById('groupName').value.trim(), type, proxies: selectedProxies, 'include-all': gCheck('groupIncludeAll'), 'disable-udp': gCheck('groupDisableUDP'), }; if (hasURL) { group.url = gVal('groupURL').trim(); group.interval = gNum('groupInterval', 300); group.timeout = gNum('groupTimeout', 5000); group.lazy = gCheck('groupLazy'); const maxFailed = gNum('groupMaxFailed', 0); if (maxFailed) group['max-failed-times'] = maxFailed; } if (type === 'url-test') { const tol = gNum('groupTolerance', 0); if (tol) group.tolerance = tol; } if (type === 'load-balance') { group.strategy = gVal('groupLBStrategy') || 'round-robin'; } const filter = gVal('groupFilter').trim(); if (filter) group.filter = filter; const exFilter = gVal('groupExcludeFilter').trim(); if (exFilter) group['exclude-filter'] = exFilter; const exType = gVal('groupExcludeType').trim(); if (exType) group['exclude-type'] = exType; const expStatus = gVal('groupExpectedStatus').trim(); if (expStatus) group['expected-status'] = expStatus; if (!group.name) { showToast('Имя группы обязательно', 'error'); return; } const groups = getGroups(); if (editGroupName) { const idx = groups.findIndex(g => g.name === editGroupName); if (idx >= 0) groups[idx] = group; } else { if (groups.some(g => g.name === group.name)) { showToast('Группа с таким именем уже существует', 'error'); return; } groups.push(group); } PS.config['proxy-groups'] = groups; closeGroupModal(); renderAll(); saveFullConfig(false); }); document.getElementById('groupList').addEventListener('click', e => { const editBtn = e.target.closest('[data-edit-group]'); const delBtn = e.target.closest('[data-delete-group]'); if (editBtn) { const group = getGroups().find(g => g.name === editBtn.dataset.editGroup); if (group) openGroupModal(group); } else if (delBtn) { const name = delBtn.dataset.deleteGroup; if (confirm(`Удалить группу "${name}"?`)) { PS.config['proxy-groups'] = getGroups().filter(g => g.name !== name); PS.config.rules = getRules().map(r => r.endsWith(',' + name) ? r.replace(',' + name, ',DIRECT') : r); renderAll(); saveFullConfig(false); } } }); // ════════════════════════════════════════════════════════════ // RULE MODAL // ════════════════════════════════════════════════════════════ const NO_RESOLVE_TYPES = ['IP-CIDR', 'IP-CIDR6', 'IP-SUFFIX', 'IP-ASN', 'GEOIP', 'SRC-IP-CIDR', 'SRC-IP-SUFFIX', 'SRC-IP-ASN', 'SRC-GEOIP']; function updateRuleFields() { const type = document.getElementById('ruleType').value; const valueDiv = document.getElementById('ruleValueField'); const noResolveDiv = document.getElementById('ruleNoResolveDiv'); valueDiv.classList.toggle('hidden', type === 'MATCH'); noResolveDiv.classList.toggle('hidden', !NO_RESOLVE_TYPES.includes(type)); const sel = document.getElementById('ruleProxy'); const cur = sel.value; sel.innerHTML = ''; getGroups().forEach(g => { const opt = document.createElement('option'); opt.value = g.name; opt.textContent = g.name; sel.appendChild(opt); }); getProxies().filter(p => p.type !== 'direct').forEach(p => { const opt = document.createElement('option'); opt.value = p.name; opt.textContent = p.name; sel.appendChild(opt); }); if ([...sel.options].some(o => o.value === cur)) sel.value = cur; } document.getElementById('ruleType').addEventListener('change', updateRuleFields); function openRuleModal() { document.getElementById('ruleType').value = 'DOMAIN-SUFFIX'; document.getElementById('ruleValue').value = ''; document.getElementById('ruleNoResolve').checked = true; updateRuleFields(); document.getElementById('ruleModal').classList.remove('hidden'); } function closeRuleModal() { document.getElementById('ruleModal').classList.add('hidden'); } document.getElementById('addRuleBtn').addEventListener('click', openRuleModal); document.getElementById('addBlockBtn').addEventListener('click', () => { openRuleModal(); document.getElementById('ruleType').value = 'DOMAIN-KEYWORD'; document.getElementById('ruleProxy').value = 'REJECT'; updateRuleFields(); }); document.getElementById('closeRuleModal').addEventListener('click', closeRuleModal); document.getElementById('cancelRuleBtn').addEventListener('click', closeRuleModal); document.getElementById('ruleModalBackdrop').addEventListener('click', closeRuleModal); document.getElementById('saveRuleBtn').addEventListener('click', () => { const type = document.getElementById('ruleType').value; const value = gVal('ruleValue').trim(); const proxy = gVal('ruleProxy'); const noResolve = gCheck('ruleNoResolve'); if (type !== 'MATCH' && !value) { showToast('Введите значение правила', 'error'); return; } let rule; if (type === 'MATCH') { rule = `MATCH,${proxy}`; } else { rule = `${type},${value},${proxy}`; if (NO_RESOLVE_TYPES.includes(type) && noResolve) rule += ',no-resolve'; } const rules = getRules(); rules.push(rule); PS.config.rules = rules; closeRuleModal(); renderAll(); saveFullConfig(false); }); document.getElementById('rulesList').addEventListener('click', e => { const delBtn = e.target.closest('[data-delete-rule]'); if (delBtn) { const idx = parseInt(delBtn.dataset.deleteRule); if (!isNaN(idx)) { const rules = getRules(); rules.splice(idx, 1); PS.config.rules = rules; renderAll(); saveFullConfig(false); } } }); // ════════════════════════════════════════════════════════════ // PROXY PROVIDERS // ════════════════════════════════════════════════════════════ let editPPName = null; function openPPModal(provider) { editPPName = provider ? provider.name : null; document.getElementById('ppModalTitle').textContent = provider ? 'Редактировать провайдер' : 'Добавить провайдер прокси'; document.getElementById('ppEditName').value = provider ? provider.name : ''; document.getElementById('ppName').value = provider ? provider.name : ''; document.getElementById('ppType').value = provider ? (provider.type || 'http') : 'http'; document.getElementById('ppURL').value = provider ? (provider.url || '') : ''; document.getElementById('ppPath').value = provider ? (provider.path || '') : ''; document.getElementById('ppInterval').value = provider ? (provider.interval || 3600) : 3600; document.getElementById('ppProxy').value = provider ? (provider.proxy || '') : ''; const hc = (provider && provider['health-check']) || {}; document.getElementById('ppHCEnable').checked = hc.enable !== false; document.getElementById('ppHCURL').value = hc.url || 'https://www.gstatic.com/generate_204'; document.getElementById('ppHCInterval').value = hc.interval || 300; document.getElementById('ppHCTimeout').value = hc.timeout || 5000; document.getElementById('ppHCLazy').checked = hc.lazy !== false; document.getElementById('ppFilter').value = provider ? (provider.filter || '') : ''; document.getElementById('ppExcludeFilter').value = provider ? (provider['exclude-filter'] || '') : ''; document.getElementById('ppExcludeType').value = provider ? (provider['exclude-type'] || '') : ''; const ov = (provider && provider.override) || {}; document.getElementById('ppOverridePrefix').value = ov['additional-prefix'] || ''; document.getElementById('ppOverrideSuffix').value = ov['additional-suffix'] || ''; document.getElementById('ppOverrideSkipCert').checked = !!ov['skip-cert-verify']; document.getElementById('ppOverrideUDP').checked = !!ov.udp; document.getElementById('ppOverrideIPVersion').value = ov['ip-version'] || ''; updatePPTypeFields(); document.getElementById('proxyProviderModal').classList.remove('hidden'); } function updatePPTypeFields() { const type = document.getElementById('ppType').value; const urlRow = document.getElementById('pp-url-row'); const intRow = document.getElementById('pp-interval-row'); if (urlRow) urlRow.classList.toggle('hidden', type === 'file' || type === 'inline'); if (intRow) intRow.classList.toggle('hidden', type === 'inline'); } document.getElementById('ppType').addEventListener('change', updatePPTypeFields); function closePPModal() { document.getElementById('proxyProviderModal').classList.add('hidden'); } document.getElementById('addProxyProviderBtn').addEventListener('click', () => openPPModal(null)); document.getElementById('closePPModal').addEventListener('click', closePPModal); document.getElementById('cancelPPBtn').addEventListener('click', closePPModal); document.getElementById('ppModalBackdrop').addEventListener('click', closePPModal); document.getElementById('savePPBtn').addEventListener('click', () => { const name = gVal('ppName').trim(); if (!name) { showToast('Имя провайдера обязательно', 'error'); return; } const provider = { type: gVal('ppType'), }; const url = gVal('ppURL').trim(); if (url) provider.url = url; const path = gVal('ppPath').trim(); if (path) provider.path = path; const interval = gNum('ppInterval', 0); if (interval) provider.interval = interval; const proxy = gVal('ppProxy').trim(); if (proxy) provider.proxy = proxy; if (gCheck('ppHCEnable')) { provider['health-check'] = { enable: true, url: gVal('ppHCURL') || 'https://www.gstatic.com/generate_204', interval: gNum('ppHCInterval', 300), timeout: gNum('ppHCTimeout', 5000), lazy: gCheck('ppHCLazy'), }; } const filter = gVal('ppFilter').trim(); if (filter) provider.filter = filter; const exFilter = gVal('ppExcludeFilter').trim(); if (exFilter) provider['exclude-filter'] = exFilter; const exType = gVal('ppExcludeType').trim(); if (exType) provider['exclude-type'] = exType; const override = {}; const prefix = gVal('ppOverridePrefix').trim(); if (prefix) override['additional-prefix'] = prefix; const suffix = gVal('ppOverrideSuffix').trim(); if (suffix) override['additional-suffix'] = suffix; if (gCheck('ppOverrideSkipCert')) override['skip-cert-verify'] = true; if (gCheck('ppOverrideUDP')) override.udp = true; const ipVer = gVal('ppOverrideIPVersion'); if (ipVer) override['ip-version'] = ipVer; if (Object.keys(override).length) provider.override = override; if (!PS.config['proxy-providers']) PS.config['proxy-providers'] = {}; if (editPPName && editPPName !== name) { delete PS.config['proxy-providers'][editPPName]; } PS.config['proxy-providers'][name] = provider; closePPModal(); renderAll(); saveFullConfig(false); }); document.getElementById('proxyProviderList').addEventListener('click', e => { const editBtn = e.target.closest('[data-edit-pp]'); const delBtn = e.target.closest('[data-delete-pp]'); if (editBtn) { const name = editBtn.dataset.editPp; const pp = PS.config['proxy-providers'] && PS.config['proxy-providers'][name]; if (pp) openPPModal({ name, ...pp }); } else if (delBtn) { const name = delBtn.dataset.deletePp; if (confirm(`Удалить провайдер "${name}"?`)) { if (PS.config['proxy-providers']) delete PS.config['proxy-providers'][name]; renderAll(); saveFullConfig(false); } } }); // ════════════════════════════════════════════════════════════ // RULE PROVIDERS // ════════════════════════════════════════════════════════════ let editRPName = null; function openRPModal(provider) { editRPName = provider ? provider.name : null; document.getElementById('rpModalTitle').textContent = provider ? 'Редактировать провайдер правил' : 'Добавить провайдер правил'; document.getElementById('rpEditName').value = provider ? provider.name : ''; document.getElementById('rpName').value = provider ? provider.name : ''; document.getElementById('rpType').value = provider ? (provider.type || 'http') : 'http'; document.getElementById('rpBehavior').value = provider ? (provider.behavior || 'domain') : 'domain'; document.getElementById('rpFormat').value = provider ? (provider.format || 'yaml') : 'yaml'; document.getElementById('rpURL').value = provider ? (provider.url || '') : ''; document.getElementById('rpPath').value = provider ? (provider.path || '') : ''; document.getElementById('rpInterval').value = provider ? (provider.interval || 600) : 600; document.getElementById('rpProxy').value = provider ? (provider.proxy || '') : ''; updateRPTypeFields(); document.getElementById('ruleProviderModal').classList.remove('hidden'); } function updateRPTypeFields() { const type = document.getElementById('rpType').value; const urlRow = document.getElementById('rp-url-row'); const intRow = document.getElementById('rp-interval-row'); if (urlRow) urlRow.classList.toggle('hidden', type !== 'http'); if (intRow) intRow.classList.toggle('hidden', type === 'inline'); } document.getElementById('rpType').addEventListener('change', updateRPTypeFields); function closeRPModal() { document.getElementById('ruleProviderModal').classList.add('hidden'); } document.getElementById('addRuleProviderBtn').addEventListener('click', () => openRPModal(null)); document.getElementById('closeRPModal').addEventListener('click', closeRPModal); document.getElementById('cancelRPBtn').addEventListener('click', closeRPModal); document.getElementById('rpModalBackdrop').addEventListener('click', closeRPModal); document.getElementById('saveRPBtn').addEventListener('click', () => { const name = gVal('rpName').trim(); if (!name) { showToast('Имя провайдера обязательно', 'error'); return; } const provider = { type: gVal('rpType'), behavior: gVal('rpBehavior') || 'domain', format: gVal('rpFormat') || 'yaml', }; const url = gVal('rpURL').trim(); if (url) provider.url = url; const path = gVal('rpPath').trim(); if (path) provider.path = path; const interval = gNum('rpInterval', 0); if (interval) provider.interval = interval; const proxy = gVal('rpProxy').trim(); if (proxy) provider.proxy = proxy; if (!PS.config['rule-providers']) PS.config['rule-providers'] = {}; if (editRPName && editRPName !== name) { delete PS.config['rule-providers'][editRPName]; } PS.config['rule-providers'][name] = provider; closeRPModal(); renderAll(); saveFullConfig(false); }); document.getElementById('ruleProviderList').addEventListener('click', e => { const editBtn = e.target.closest('[data-edit-rp]'); const delBtn = e.target.closest('[data-delete-rp]'); if (editBtn) { const name = editBtn.dataset.editRp; const rp = PS.config['rule-providers'] && PS.config['rule-providers'][name]; if (rp) openRPModal({ name, ...rp }); } else if (delBtn) { const name = delBtn.dataset.deleteRp; if (confirm(`Удалить провайдер "${name}"?`)) { if (PS.config['rule-providers']) delete PS.config['rule-providers'][name]; renderAll(); saveFullConfig(false); } } }); // ════════════════════════════════════════════════════════════ // CORE UPLOAD // ════════════════════════════════════════════════════════════ document.getElementById('uploadCoreForm').addEventListener('submit', async e => { e.preventDefault(); const fileInput = document.getElementById('coreFile'); if (!fileInput.files[0]) { showToast('Выберите файл', 'error'); return; } const fd = new FormData(); fd.append('core', fileInput.files[0]); try { const result = await fetch('/api/mihomo/upload-core', { method: 'POST', body: fd }); const json = await result.json(); if (!json.success) throw new Error(json.error || 'Upload failed'); showToast('Ядро загружено: ' + (json.data.arch || json.data.path), 'success'); await loadStatus(); } catch (e) { showToast('Ошибка: ' + e.message, 'error'); } }); // ════════════════════════════════════════════════════════════ // YAML EDITOR // ════════════════════════════════════════════════════════════ document.getElementById('yamlLoadBtn').addEventListener('click', async () => { try { const res = await fetch('/api/mihomo/config.yaml'); if (res.status === 404) { document.getElementById('yamlEditor').value = '# Config not found.'; return; } document.getElementById('yamlEditor').value = await res.text(); showToast('Конфиг загружен', 'info'); } catch (e) { showToast('Ошибка: ' + e.message, 'error'); } }); document.getElementById('yamlSaveBtn').addEventListener('click', async () => { const content = document.getElementById('yamlEditor').value; try { const res = await fetch('/api/mihomo/config.yaml', { method: 'PUT', headers: { 'Content-Type': 'text/yaml' }, body: content, }); const json = await res.json(); if (!json.success) throw new Error(json.error || 'Save failed'); showToast('Конфиг сохранён', 'success'); await loadConfig(); } catch (e) { showToast('Ошибка: ' + e.message, 'error'); } }); // ════════════════════════════════════════════════════════════ // LOGS // ════════════════════════════════════════════════════════════ let logPollTimer = null; let lastLogCount = 0; async function fetchLogs() { try { const lines = await get('/api/mihomo/logs'); const el = document.getElementById('logOutput'); if (lines.length !== lastLogCount) { lastLogCount = lines.length; el.textContent = lines.join('\n'); el.scrollTop = el.scrollHeight; } } catch (e) { /* ignore */ } } function startLogPoll() { if (logPollTimer) return; fetchLogs(); logPollTimer = setInterval(fetchLogs, 500); } function stopLogPoll() { if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; } } document.querySelectorAll('.ptab').forEach(btn => { btn.addEventListener('click', () => { if (btn.dataset.tab === 'logs') { lastLogCount = 0; startLogPoll(); } else stopLogPoll(); }); }); document.getElementById('clearLogsBtn').addEventListener('click', () => { document.getElementById('logOutput').textContent = ''; lastLogCount = 0; }); // ════════════════════════════════════════════════════════════ // DASHBOARD (LIVE MIHOMO API) // ════════════════════════════════════════════════════════════ const DA = { ws: null, trafficUp: 0, trafficDown: 0, totalUp: 0, totalDown: 0, memory: 0, connections: [], proxies: {}, groups: {}, version: '', mode: '', running: false, }; function mihomoAPI(path, opts) { return fetch('/api/mihomo/api/' + path.replace(/^\/+/, ''), opts).then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }); } function mihomoPut(path, body) { return mihomoAPI(path, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } function mihomoDel(path) { return mihomoAPI(path, { method: 'DELETE' }); } function formatBytes(b) { if (b < 1024) return b.toFixed(0) + ' B'; if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'; if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB'; return (b / 1073741824).toFixed(2) + ' GB'; } function formatBytesPerSec(b) { return formatBytes(b) + '/s'; } function closeDashWS() { if (DA.ws) { try { DA.ws.close(); } catch(e) {} DA.ws = null; } } function openDashWS() { closeDashWS(); const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const url = proto + '//' + location.host + '/api/mihomo/ws/traffic'; try { const ws = new WebSocket(url); ws.onmessage = (e) => { try { const d = JSON.parse(e.data); DA.trafficUp = d.up || 0; DA.trafficDown = d.down || 0; } catch(err) {} }; ws.onerror = () => {}; ws.onclose = () => { DA.ws = null; }; DA.ws = ws; } catch(e) {} } let dashTrafficInterval = null; let dashConnsInterval = null; async function loadDashInitial() { try { const vdata = await mihomoAPI('version'); DA.version = vdata.version || '?'; } catch(e) { DA.version = '?'; } try { const cfg = await mihomoAPI('configs'); DA.mode = cfg.mode || 'rule'; renderDashMode(); } catch(e) {} await loadDashProxies(); await loadDashConnections(); renderDash(); } async function loadDashProxies() { try { const data = await mihomoAPI('proxies'); const all = data.proxies || data || {}; DA.proxies = {}; DA.groups = {}; for (const [name, p] of Object.entries(all)) { if ((p.all && p.all.length > 0) || p.type === 'Selector' || p.type === 'URLTest' || p.type === 'Fallback' || p.type === 'LoadBalance' || p.type === 'Relay') { DA.groups[name] = p; } else { DA.proxies[name] = p; } } } catch(e) { console.error('dash proxies load', e); } } async function loadDashConnections() { try { const data = await mihomoAPI('connections'); DA.connections = Object.values(data.connections || {}); renderDashConnections(); } catch(e) { DA.connections = []; } } async function dashPingProxy(name) { try { await mihomoAPI('proxies/' + encodeURIComponent(name) + '/delay?timeout=5000&url=https://www.gstatic.com/generate_204'); await loadDashProxies(); renderDashGroups(); } catch(e) { showToast('Пинг ' + name + ': ошибка', 'error'); } } async function dashPingAll() { showToast('Пинг всех прокси...', 'info'); const names = Object.keys(DA.proxies); let done = 0; for (const name of names) { mihomoAPI('proxies/' + encodeURIComponent(name) + '/delay?timeout=5000&url=https://www.gstatic.com/generate_204') .then(() => { done++; }) .catch(() => { done++; }) .finally(() => { if (done === names.length) { loadDashProxies().then(() => renderDashGroups()); showToast('Пинг завершён', 'success'); } }); } } async function dashSwitchProxy(groupName, proxyName) { try { await mihomoPut('proxies/' + encodeURIComponent(groupName), { name: proxyName }); await loadDashProxies(); renderDashGroups(); showToast(groupName + ' → ' + proxyName, 'success'); } catch(e) { showToast('Ошибка переключения: ' + e.message, 'error'); } } async function dashSetMode(mode) { try { await mihomoPut('configs', { mode }); DA.mode = mode; renderDashMode(); showToast('Режим: ' + mode, 'success'); } catch(e) { showToast('Ошибка смены режима: ' + e.message, 'error'); } } async function dashCloseConnection(id) { try { await mihomoDel('connections/' + encodeURIComponent(id)); await loadDashConnections(); } catch(e) {} } async function dashCloseAllConnections() { try { await mihomoDel('connections'); DA.connections = []; renderDashConnections(); showToast('Все соединения закрыты', 'success'); } catch(e) {} } function renderDashMode() { const seg = document.getElementById('dashModeSwitch'); if (!seg) return; seg.querySelectorAll('.seg-btn').forEach(b => { b.classList.toggle('active', b.dataset.mode === DA.mode); }); } document.addEventListener('click', (e) => { const btn = e.target.closest('#dashModeSwitch .seg-btn'); if (btn) dashSetMode(btn.dataset.mode); }); function renderDash() { document.getElementById('dashUp').textContent = formatBytesPerSec(DA.trafficUp); document.getElementById('dashDown').textContent = formatBytesPerSec(DA.trafficDown); document.getElementById('dashUpTotal').textContent = formatBytes(DA.totalUp); document.getElementById('dashDownTotal').textContent = formatBytes(DA.totalDown); document.getElementById('dashMem').textContent = formatBytes(DA.memory); document.getElementById('dashVersion').textContent = DA.version; renderDashGroups(); renderDashConnections(); } function renderDashGroups() { const list = document.getElementById('dashGroupList'); if (!list) return; list.innerHTML = ''; const groupEntries = Object.entries(DA.groups); if (groupEntries.length === 0) { list.innerHTML = '
Нет групп прокси. Добавьте группы во вкладке «Группы».
'; return; } groupEntries.sort((a, b) => { const order = ['Selector', 'URLTest', 'Fallback', 'LoadBalance', 'Relay']; return (order.indexOf(a[1].type) || 99) - (order.indexOf(b[1].type) || 99); }); for (const [name, g] of groupEntries) { const card = document.createElement('div'); card.className = 'dash-group-card'; const members = (g.all || []).map(m => { const p = DA.proxies[m] || DA.groups[m] || {}; const delay = p.history && p.history.length ? p.history[p.history.length - 1].delay : undefined; const dead = delay === undefined || delay === 0; let delayText = '—'; let delayClass = 'dash-delay-unknown'; if (!dead) { delayText = delay + 'ms'; delayClass = delay < 200 ? 'dash-delay-good' : delay < 500 ? 'dash-delay-medium' : 'dash-delay-slow'; } const isActive = g.now === m; const type = p.type || ''; return `
${esc(m)} ${esc(type)} ${delayText}
`; }).join(''); card.innerHTML = `
${esc(name)} ${esc(g.type || '')}
${members || '
Нет нод
'}
`; list.appendChild(card); } } document.addEventListener('click', (e) => { const proxyItem = e.target.closest('.dash-proxy-item'); if (proxyItem) { const groupName = proxyItem.dataset.group; const proxyName = proxyItem.dataset.proxy; dashSwitchProxy(groupName, proxyName); } const pingBtn = e.target.closest('.dash-ping-group-btn'); if (pingBtn) { const groupName = pingBtn.dataset.group; const group = DA.groups[groupName]; if (group && group.all) { for (const name of group.all) { mihomoAPI('proxies/' + encodeURIComponent(name) + '/delay?timeout=5000&url=https://www.gstatic.com/generate_204').catch(() => {}); } showToast('Пинг группы ' + groupName + '...', 'info'); setTimeout(() => loadDashProxies().then(() => renderDashGroups()), 3000); } } if (e.target.closest('#dashPingAllBtn')) { dashPingAll(); } if (e.target.closest('#dashCloseAllConnsBtn')) { dashCloseAllConnections(); } const closeConnBtn = e.target.closest('.dash-conn-close'); if (closeConnBtn) { dashCloseConnection(closeConnBtn.dataset.id); } }); function renderDashConnections() { const tbody = document.getElementById('dashConnBody'); const countEl = document.getElementById('dashConnCount'); if (!tbody) return; if (countEl) countEl.textContent = DA.connections.length; tbody.innerHTML = ''; const conns = DA.connections.slice(0, 100); for (const c of conns) { const meta = c.metadata || {}; const host = (meta.host || meta.destinationIP || '') + (meta.destinationPort ? ':' + meta.destinationPort : ''); const chain = (c.chains || []).reverse().join(' → ') || '—'; const tr = document.createElement('tr'); tr.innerHTML = ` ${esc(host.length > 40 ? host.slice(0, 40) + '...' : host)} ${esc(meta.network || '')} ${esc(chain.length > 30 ? chain.slice(0, 30) + '...' : chain)} ${formatBytes(c.upload || 0)} ${formatBytes(c.download || 0)} `; tbody.appendChild(tr); } if (conns.length === 0) { tbody.innerHTML = 'Нет активных соединений'; } } function startDashPolling() { stopDashPolling(); loadDashInitial(); openDashWS(); dashTrafficInterval = setInterval(() => { DA.totalUp += DA.trafficUp; DA.totalDown += DA.trafficDown; document.getElementById('dashUp').textContent = formatBytesPerSec(DA.trafficUp); document.getElementById('dashDown').textContent = formatBytesPerSec(DA.trafficDown); document.getElementById('dashUpTotal').textContent = formatBytes(DA.totalUp); document.getElementById('dashDownTotal').textContent = formatBytes(DA.totalDown); document.getElementById('dashMem').textContent = formatBytes(DA.memory); }, 1000); dashConnsInterval = setInterval(loadDashConnections, 3000); } function stopDashPolling() { if (dashTrafficInterval) { clearInterval(dashTrafficInterval); dashTrafficInterval = null; } if (dashConnsInterval) { clearInterval(dashConnsInterval); dashConnsInterval = null; } closeDashWS(); } function updateDashVisibility() { const online = document.getElementById('dashOnline'); const offline = document.getElementById('dashOffline'); if (!online || !offline) return; if (PS.status && PS.status.running) { online.classList.remove('hidden'); offline.classList.add('hidden'); } else { online.classList.add('hidden'); offline.classList.remove('hidden'); stopDashPolling(); } } // ════════════════════════════════════════════════════════════ // INIT // ════════════════════════════════════════════════════════════ (async () => { try { await loadStatus(); } catch (e) { console.error('status', e); } try { await loadConfig(); } catch (e) { console.error('config', e); } updateDashVisibility(); if (PS.status && PS.status.running) startDashPolling(); })();