'use strict'; const PS = { status: null, config: null, }; 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 typeLabel(t) { const m = { ss: 'Shadowsocks', vmess: 'VMess', vless: 'VLESS', trojan: 'Trojan', hysteria2: 'Hysteria2', http: 'HTTP', socks5: 'SOCKS5', direct: 'DIRECT' }; return m[t] || t; } function groupTypeLabel(t) { const m = { select: 'Выбор', 'url-test': 'Автотест', fallback: 'Резерв', 'load-balance': 'Балансировка' }; return m[t] || t; } 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 getGeneral() { return PS.config && PS.config.general ? PS.config.general : {}; } function getTProxy() { return PS.config && PS.config.tproxy ? PS.config.tproxy : {}; } function getDNS() { return PS.config && PS.config.dns ? PS.config.dns : {}; } 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) { document.getElementById('yamlPreview').value = await res.text(); } } catch (e) { /* ignore */ } } function renderStatus() { const s = PS.status; if (!s) return; const text = document.getElementById('statusText'); const headerBadge = document.getElementById('statusBadge'); const startBtn = document.getElementById('startBtn'); const stopBtn = document.getElementById('stopBtn'); 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 = 'Запущен'; startBtn.disabled = true; stopBtn.disabled = false; restartBtn.disabled = false; } else { text.className = 'svc-badge stopped'; text.textContent = 'Остановлен'; headerBadge.className = 'svc-badge stopped'; headerBadge.textContent = 'Остановлен'; startBtn.disabled = false; stopBtn.disabled = true; restartBtn.disabled = true; } document.getElementById('corePath').textContent = s.core_path || '—'; document.getElementById('coreExists').textContent = s.core_exists ? 'Да' : 'Нет'; document.getElementById('corePid').textContent = s.running && s.pid ? s.pid : '—'; if (!s.core_exists) { coreInfo.classList.remove('hidden'); document.getElementById('coreInfoMsg').textContent = 'Ядро Mihomo не найдено. Загрузите бинарный файл в раздел «Ядро».'; } else { coreInfo.classList.add('hidden'); } document.getElementById('statusBar').classList.remove('hidden'); document.getElementById('loading').classList.add('hidden'); } function renderAll() { renderProxies(); renderGroups(); renderRules(); fillSettings(); refreshYAMLPreview(); } 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'; card.innerHTML = `
${esc(p.name)} ${esc(typeLabel(p.type))}
${p.type !== 'direct' ? `
Сервер${esc(p.server || '—')}
Порт${p.port || '—'}
` : ''} ${p.udp ? 'UDP' : ''} ${p.tls ? 'TLS' : ''}
`; list.appendChild(card); }); } 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 proxyList = (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'] ? 'Все прокси' : ''}
Узлы${proxyList}${more || '—'}
${g.url ? `
URL${esc(g.url)}
` : ''} ${g.interval ? `
Интервал${g.interval}с
` : ''}
`; list.appendChild(card); }); } 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); }); } function fillSettings() { if (!PS.config) { document.getElementById('loading').classList.add('hidden'); return; } const g = getGeneral(); const tp = getTProxy(); const dns = getDNS(); document.getElementById('mixedPort').value = g['mixed-port'] || 7890; document.getElementById('allowLan').checked = g['allow-lan'] !== false; document.getElementById('ipv6').checked = g.ipv6 !== false; document.getElementById('tcpConcurrent').checked = g['tcp-concurrent'] !== false; document.getElementById('externalController').value = g['external-controller'] || '0.0.0.0:9090'; document.getElementById('secret').value = g.secret || ''; setSegBtn('modeSwitch', g.mode || 'rule'); document.getElementById('tproxyEnabled').checked = tp.enabled || false; document.getElementById('tproxyPort').value = tp.port || 7894; document.getElementById('dnsEnabled').checked = dns.enable !== false; document.getElementById('dnsListen').value = dns.listen || '0.0.0.0:1053'; setSegBtn('dnsModeSwitch', dns['enhanced-mode'] || 'redir-host'); document.getElementById('dnsNameserver').value = (dns.nameserver || []).join('\n'); document.getElementById('dnsFallback').value = (dns.fallback || []).join('\n'); } 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 : ''; } function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } // ─── 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'); }); }); // ─── Core control ─── document.getElementById('startBtn').addEventListener('click', async () => { try { await post('/api/mihomo/start', null); showToast('Mihomo запущен', 'success'); await loadStatus(); } catch (e) { showToast('Ошибка: ' + e.message, 'error'); } }); document.getElementById('stopBtn').addEventListener('click', async () => { try { await post('/api/mihomo/stop', null); showToast('Mihomo остановлен', 'success'); await loadStatus(); } catch (e) { showToast('Ошибка: ' + e.message, 'error'); } }); document.getElementById('restartBtn').addEventListener('click', async () => { try { await post('/api/mihomo/restart', null); showToast('Mihomo перезапущен', 'success'); await loadStatus(); } catch (e) { showToast('Ошибка: ' + e.message, 'error'); } }); // ─── Config mode switches ─── document.getElementById('modeSwitch').addEventListener('click', e => { const btn = e.target.closest('.seg-btn'); if (btn) setSegBtn('modeSwitch', btn.dataset.mode); }); document.getElementById('dnsModeSwitch').addEventListener('click', e => { const btn = e.target.closest('.seg-btn'); if (btn) setSegBtn('dnsModeSwitch', btn.dataset.mode); }); // ─── Settings save ─── function applySettingsToConfig() { if (!PS.config) PS.config = {}; PS.config['mixed-port'] = parseInt(document.getElementById('mixedPort').value) || 7890; PS.config['allow-lan'] = document.getElementById('allowLan').checked; PS.config['bind-address'] = '*'; PS.config.mode = getSegBtn('modeSwitch'); PS.config['log-level'] = 'info'; PS.config.ipv6 = document.getElementById('ipv6').checked; PS.config['external-controller'] = document.getElementById('externalController').value; PS.config.secret = document.getElementById('secret').value || ''; PS.config['tcp-concurrent'] = document.getElementById('tcpConcurrent').checked; PS.config['find-process-mode'] = 'off'; const tpEnabled = document.getElementById('tproxyEnabled').checked; if (tpEnabled) { PS.config['tproxy-port'] = parseInt(document.getElementById('tproxyPort').value) || 7894; } else { delete PS.config['tproxy-port']; } PS.config.dns = { enable: document.getElementById('dnsEnabled').checked, ipv6: document.getElementById('ipv6').checked, listen: document.getElementById('dnsListen').value, 'enhanced-mode': getSegBtn('dnsModeSwitch'), 'fake-ip-range': '198.18.0.1/16', 'fake-ip-filter': ['*.lan', '*.local', '+.market.xiaomi.com'], 'default-nameserver': ['223.5.5.5', '119.29.29.29'], nameserver: document.getElementById('dnsNameserver').value.split('\n').map(s => s.trim()).filter(Boolean), fallback: document.getElementById('dnsFallback').value.split('\n').map(s => s.trim()).filter(Boolean), }; PS.config.profile = { 'store-selected': true, 'store-fake-ip': true }; } document.getElementById('settingsForm').addEventListener('submit', e => { e.preventDefault(); applySettingsToConfig(); saveFullConfig(false); }); document.getElementById('saveAndRestartBtn').addEventListener('click', () => { applySettingsToConfig(); saveFullConfig(true); }); // ─── Proxy Modal ─── function updateProxyFields() { const type = document.getElementById('proxyType').value; const serverFields = document.getElementById('proxyServerFields'); const authFields = document.getElementById('proxyAuthFields'); const cipherField = document.getElementById('proxyCipherField'); const uuidField = document.getElementById('proxyUUIDField'); const trojanPassField = document.getElementById('proxyTrojanPassField'); const hysteria2Fields = document.getElementById('proxyHysteria2Fields'); const tlsField = document.getElementById('proxyTLSField'); const vlessFlowField = document.getElementById('proxyVlessFlowField'); const networkField = document.getElementById('proxyNetworkField'); serverFields.classList.remove('hidden'); authFields.classList.add('hidden'); cipherField.classList.add('hidden'); uuidField.classList.add('hidden'); trojanPassField.classList.add('hidden'); hysteria2Fields.classList.add('hidden'); tlsField.classList.add('hidden'); vlessFlowField.classList.add('hidden'); networkField.classList.add('hidden'); switch (type) { case 'ss': cipherField.classList.remove('hidden'); tlsField.classList.remove('hidden'); document.querySelector('#proxyPassword').closest('.form-row').classList.remove('hidden'); break; case 'vmess': uuidField.classList.remove('hidden'); cipherField.classList.remove('hidden'); tlsField.classList.remove('hidden'); networkField.classList.remove('hidden'); document.getElementById('proxyCipher').innerHTML = ''; break; case 'vless': uuidField.classList.remove('hidden'); tlsField.classList.remove('hidden'); vlessFlowField.classList.remove('hidden'); networkField.classList.remove('hidden'); break; case 'trojan': trojanPassField.classList.remove('hidden'); tlsField.classList.remove('hidden'); networkField.classList.remove('hidden'); break; case 'hysteria2': hysteria2Fields.classList.remove('hidden'); tlsField.classList.add('hidden'); document.querySelector('#proxyPassword').closest('.form-row').classList.remove('hidden'); serverFields.classList.remove('hidden'); break; case 'http': authFields.classList.remove('hidden'); tlsField.classList.remove('hidden'); break; case 'socks5': authFields.classList.remove('hidden'); tlsField.classList.remove('hidden'); break; case 'direct': serverFields.classList.add('hidden'); break; } } document.getElementById('proxyType').addEventListener('change', updateProxyFields); let editProxyName = null; function openProxyModal(proxy) { editProxyName = proxy ? proxy.name : null; document.getElementById('proxyModalTitle').textContent = proxy ? 'Редактировать прокси' : 'Добавить прокси'; document.getElementById('proxyEditName').value = proxy ? proxy.name : ''; document.getElementById('proxyName').value = proxy ? proxy.name : ''; document.getElementById('proxyType').value = proxy ? proxy.type : 'ss'; document.getElementById('proxyServer').value = proxy ? (proxy.server || '') : ''; document.getElementById('proxyPort').value = proxy ? (proxy.port || '') : ''; document.getElementById('proxyUDP').checked = proxy ? (proxy.udp !== false) : true; document.getElementById('proxyUsername').value = proxy ? (proxy.username || '') : ''; document.getElementById('proxyPassword').value = proxy ? (proxy.password || '') : ''; document.getElementById('proxyTLS').checked = proxy ? (proxy.tls || false) : false; document.getElementById('proxySNI').value = proxy ? (proxy.servername || '') : ''; document.getElementById('proxySkipCertVerify').checked = proxy ? (proxy['skip-cert-verify'] || false) : false; document.getElementById('proxyUUID').value = proxy ? (proxy.uuid || '') : ''; document.getElementById('proxyCipher').value = proxy ? (proxy.cipher || 'auto') : 'auto'; document.getElementById('proxyFlow').value = proxy ? (proxy.flow || '') : ''; document.getElementById('proxyNetwork').value = proxy ? (proxy.network || '') : ''; document.getElementById('proxyTrojanPass').value = proxy ? (proxy.password || '') : ''; document.getElementById('proxyObfs').value = proxy ? (proxy.obfs || '') : ''; document.getElementById('proxyObfsPass').value = proxy ? (proxy['obfs-password'] || '') : ''; updateProxyFields(); document.getElementById('proxyModal').classList.remove('hidden'); } 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 proxy = { name: document.getElementById('proxyName').value.trim(), type: type, server: document.getElementById('proxyServer').value.trim(), port: parseInt(document.getElementById('proxyPort').value) || 443, udp: document.getElementById('proxyUDP').checked, tls: document.getElementById('proxyTLS').checked, servername: document.getElementById('proxySNI').value.trim(), 'skip-cert-verify': document.getElementById('proxySkipCertVerify').checked, network: document.getElementById('proxyNetwork').value || '', }; if (type === 'ss') { proxy.cipher = document.getElementById('proxyCipher').value; proxy.password = document.getElementById('proxyPassword').value; } else if (type === 'vmess' || type === 'vless') { proxy.uuid = document.getElementById('proxyUUID').value.trim(); if (type === 'vless') proxy.flow = document.getElementById('proxyFlow').value.trim(); proxy.cipher = document.getElementById('proxyCipher').value; } else if (type === 'trojan') { proxy.password = document.getElementById('proxyTrojanPass').value; } else if (type === 'hysteria2') { proxy.password = document.getElementById('proxyPassword').value; proxy.obfs = document.getElementById('proxyObfs').value.trim(); proxy['obfs-password'] = document.getElementById('proxyObfsPass').value.trim(); } else if (type === 'http' || type === 'socks5') { proxy.username = document.getElementById('proxyUsername').value.trim(); proxy.password = document.getElementById('proxyPassword').value; } else if (type === 'direct') { delete proxy.server; delete proxy.port; } if (!proxy.name) { showToast('Имя прокси обязательно', 'error'); return; } 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 name = editBtn.dataset.editProxy; const proxy = getProxies().find(p => p.name === name); if (proxy) openProxyModal(proxy); } else if (delBtn) { const name = delBtn.dataset.deleteProxy; if (confirm(`Удалить прокси "${name}"?`)) { const proxies = getProxies(); const idx = proxies.findIndex(p => p.name === name); if (idx >= 0) { proxies.splice(idx, 1); PS.config.proxies = proxies; 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('groupLBStrategy'); if (type === 'url-test' || type === 'fallback' || type === 'load-balance') { urlField.classList.remove('hidden'); } else { urlField.classList.add('hidden'); } if (type === 'load-balance') { lbField.classList.remove('hidden'); } else { lbField.classList.add('hidden'); } } 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('groupTolerance').value = group ? (group.tolerance || 50) : 50; document.getElementById('groupIncludeAll').checked = group ? (group['include-all'] || false) : false; document.getElementById('groupFilter').value = group ? (group.filter || '') : ''; document.getElementById('groupLBStrategy').value = group ? (group.strategy || 'round-robin') : 'round-robin'; const checkboxes = document.getElementById('groupProxyCheckboxes'); checkboxes.innerHTML = ''; const builtin = ['DIRECT']; const allProxies = [...builtin, ...getProxies().map(p => p.name)]; const selected = group ? (group.proxies || []) : []; allProxies.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 group = { name: document.getElementById('groupName').value.trim(), type: document.getElementById('groupType').value, proxies: selectedProxies, url: document.getElementById('groupURLField').classList.contains('hidden') ? '' : document.getElementById('groupURL').value.trim(), interval: parseInt(document.getElementById('groupInterval').value) || 300, tolerance: parseInt(document.getElementById('groupTolerance').value) || 0, 'include-all': document.getElementById('groupIncludeAll').checked, filter: document.getElementById('groupFilter').value.trim(), }; if (group.type === 'load-balance') { group.strategy = document.getElementById('groupLBStrategy').value; } 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); }); // Group list events 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 name = editBtn.dataset.editGroup; const group = getGroups().find(g => g.name === name); if (group) openGroupModal(group); } else if (delBtn) { const name = delBtn.dataset.deleteGroup; if (confirm(`Удалить группу "${name}"?`)) { const groups = getGroups(); const idx = groups.findIndex(g => g.name === name); if (idx >= 0) { groups.splice(idx, 1); PS.config['proxy-groups'] = groups; const rules = getRules(); PS.config.rules = rules.map(r => { if (r.endsWith(',' + name)) { return r.replace(',' + name, ',DIRECT'); } return r; }); renderAll(); saveFullConfig(false); } } } }); // ─── Rule Modal ─── function updateRuleFields() { const type = document.getElementById('ruleType').value; const valueDiv = document.getElementById('ruleValueField'); const noResolveDiv = document.getElementById('ruleNoResolveDiv'); valueDiv.classList.remove('hidden'); noResolveDiv.classList.add('hidden'); if (type === 'MATCH') { valueDiv.classList.add('hidden'); } else if (type === 'IP-CIDR' || type === 'IP-CIDR6' || type === 'SRC-IP-CIDR' || type === 'GEOIP') { noResolveDiv.classList.remove('hidden'); } const sel = document.getElementById('ruleProxy'); sel.innerHTML = ''; getGroups().forEach(g => { const opt = document.createElement('option'); opt.value = g.name; opt.textContent = '📋 ' + g.name; sel.appendChild(opt); }); getProxies().forEach(p => { if (p.type !== 'direct') { const opt = document.createElement('option'); opt.value = p.name; opt.textContent = '🔗 ' + p.name; sel.appendChild(opt); } }); } document.getElementById('ruleType').addEventListener('change', updateRuleFields); function openRuleModal() { document.getElementById('ruleModalTitle').textContent = 'Добавить правило'; document.getElementById('ruleType').value = 'DOMAIN-SUFFIX'; document.getElementById('ruleValue').value = ''; document.getElementById('ruleProxy').value = 'DIRECT'; 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 = document.getElementById('ruleValue').value.trim(); const proxy = document.getElementById('ruleProxy').value; const noResolve = document.getElementById('ruleNoResolve').checked; if (type !== 'MATCH' && !value) { showToast('Введите значение правила', 'error'); return; } let rule; if (type === 'MATCH') { rule = `MATCH,${proxy}`; } else { rule = `${type},${value},${proxy}`; const needsNoResolve = ['IP-CIDR', 'IP-CIDR6', 'SRC-IP-CIDR', 'GEOIP'].includes(type); if (needsNoResolve && noResolve) { rule += ',no-resolve'; } } const rules = getRules(); rules.push(rule); PS.config.rules = rules; closeRuleModal(); renderAll(); saveFullConfig(false); }); // Rule delete delegated events 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)) return; const rules = getRules(); rules.splice(idx, 1); PS.config.rules = rules; 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; } const text = await res.text(); document.getElementById('yamlEditor').value = 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 polling ─── 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; }); // ─── Init ─── (async () => { try { await loadStatus(); } catch(e) { console.error('status', e); } try { await loadConfig(); } catch(e) { console.error('config', e); } document.getElementById('loading').classList.add('hidden'); })();