Files
alpine-router/public/proxy.js

2271 lines
91 KiB
JavaScript
Raw Normal View History

2026-04-13 09:46:02 +03:00
'use strict';
const PS = {
status: null,
config: null,
};
2026-04-13 18:56:13 +03:00
// ─── API helpers ───
2026-04-13 09:46:02 +03:00
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);
}
2026-04-13 18:56:13 +03:00
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);
});
2026-04-13 09:46:02 +03:00
}
2026-04-13 18:56:13 +03:00
function getSegBtn(id) {
const active = document.querySelector(`#${id} .seg-btn.active`);
return active ? active.dataset.mode : '';
2026-04-13 09:46:02 +03:00
}
2026-04-13 18:56:13 +03:00
// 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 ───
2026-04-13 09:46:02 +03:00
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 : []; }
2026-04-13 18:56:13 +03:00
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; }
2026-04-13 09:46:02 +03:00
2026-04-13 18:56:13 +03:00
// ─── Load / Save ───
2026-04-13 09:46:02 +03:00
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) {
2026-04-13 18:56:13 +03:00
const el = document.getElementById('yamlPreview');
if (el) el.value = await res.text();
2026-04-13 09:46:02 +03:00
}
} catch (e) { /* ignore */ }
}
2026-04-13 18:56:13 +03:00
// ─── Status rendering ───
2026-04-13 09:46:02 +03:00
function renderStatus() {
const s = PS.status;
if (!s) return;
const text = document.getElementById('statusText');
const headerBadge = document.getElementById('statusBadge');
2026-04-13 18:56:13 +03:00
const toggle = document.getElementById('serviceActive');
const toggleText = document.getElementById('serviceToggleText');
2026-04-13 09:46:02 +03:00
const restartBtn = document.getElementById('restartBtn');
const coreInfo = document.getElementById('coreInfo');
if (s.running) {
2026-04-13 18:56:13 +03:00
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;
2026-04-13 09:46:02 +03:00
} else {
2026-04-13 18:56:13 +03:00
text.className = 'svc-badge stopped'; text.textContent = 'Остановлен';
headerBadge.className = 'svc-badge stopped'; headerBadge.textContent = 'Остановлен';
toggle.checked = false; toggleText.textContent = 'Остановлен'; restartBtn.disabled = true;
2026-04-13 09:46:02 +03:00
}
2026-04-13 18:56:13 +03:00
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 : '—';
2026-04-13 09:46:02 +03:00
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');
}
2026-04-13 18:56:13 +03:00
// ─── Render all ───
2026-04-13 09:46:02 +03:00
function renderAll() {
renderProxies();
renderGroups();
renderRules();
2026-04-13 18:56:13 +03:00
renderProxyProviders();
renderRuleProviders();
2026-04-13 09:46:02 +03:00
fillSettings();
refreshYAMLPreview();
}
2026-04-13 18:56:13 +03:00
// ─── Render proxies ───
2026-04-13 09:46:02 +03:00
function renderProxies() {
const list = document.getElementById('proxyList');
const empty = document.getElementById('proxyEmpty');
const proxies = getProxies();
list.innerHTML = '';
2026-04-13 18:56:13 +03:00
if (proxies.length === 0) { empty.classList.remove('hidden'); return; }
2026-04-13 09:46:02 +03:00
empty.classList.add('hidden');
proxies.forEach(p => {
const card = document.createElement('div');
card.className = 'proxy-card';
2026-04-13 18:56:13 +03:00
const server = p.type === 'direct' ? '' :
p.type === 'wireguard' ? (p.ip || '—') :
`<div class="info-row"><span class="info-label">Сервер</span><span class="info-val mono">${esc(p.server || '—')}</span></div>
<div class="info-row"><span class="info-label">Порт</span><span class="info-val mono">${p.port || ''}</span></div>`;
const tags = [
p.udp ? '<span class="tag-active">UDP</span>' : '',
p.tls ? '<span class="tag-active">TLS</span>' : '',
p.network ? `<span class="tag-active">${esc(p.network)}</span>` : '',
].filter(Boolean).join('');
2026-04-13 09:46:02 +03:00
card.innerHTML = `
<div class="proxy-card-header">
<div style="display:flex;align-items:center;gap:8px">
<span class="proxy-name">${esc(p.name)}</span>
<span class="tag-active">${esc(typeLabel(p.type))}</span>
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-ghost btn-sm" data-edit-proxy="${esc(p.name)}">Изменить</button>
<button class="btn btn-danger btn-sm" data-delete-proxy="${esc(p.name)}">Удалить</button>
</div>
</div>
2026-04-13 18:56:13 +03:00
<div class="proxy-card-info">${server}${tags ? `<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:4px">${tags}</div>` : ''}</div>`;
2026-04-13 09:46:02 +03:00
list.appendChild(card);
});
}
2026-04-13 18:56:13 +03:00
// ─── Render groups ───
2026-04-13 09:46:02 +03:00
function renderGroups() {
const list = document.getElementById('groupList');
const empty = document.getElementById('groupEmpty');
const groups = getGroups();
list.innerHTML = '';
2026-04-13 18:56:13 +03:00
if (groups.length === 0) { empty.classList.remove('hidden'); return; }
2026-04-13 09:46:02 +03:00
empty.classList.add('hidden');
groups.forEach(g => {
const card = document.createElement('div');
card.className = 'proxy-card';
2026-04-13 18:56:13 +03:00
const pl = (g.proxies || []).slice(0, 5).map(p => esc(p)).join(', ');
2026-04-13 09:46:02 +03:00
const more = (g.proxies || []).length > 5 ? ` +${(g.proxies || []).length - 5}` : '';
card.innerHTML = `
<div class="proxy-card-header">
<div style="display:flex;align-items:center;gap:8px">
<span class="proxy-name">${esc(g.name)}</span>
<span class="tag-gw">${esc(groupTypeLabel(g.type))}</span>
${g['include-all'] ? '<span class="tag-active">Все прокси</span>' : ''}
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-ghost btn-sm" data-edit-group="${esc(g.name)}">Изменить</button>
<button class="btn btn-danger btn-sm" data-delete-group="${esc(g.name)}">Удалить</button>
</div>
</div>
<div class="proxy-card-info">
2026-04-13 18:56:13 +03:00
<div class="info-row"><span class="info-label">Узлы</span><span class="info-val">${pl || ''}${more}</span></div>
2026-04-13 09:46:02 +03:00
${g.url ? `<div class="info-row"><span class="info-label">URL</span><span class="info-val mono" style="font-size:.75rem">${esc(g.url)}</span></div>` : ''}
${g.interval ? `<div class="info-row"><span class="info-label">Интервал</span><span class="info-val">${g.interval}с</span></div>` : ''}
2026-04-13 18:56:13 +03:00
</div>`;
2026-04-13 09:46:02 +03:00
list.appendChild(card);
});
}
2026-04-13 18:56:13 +03:00
// ─── Render rules ───
2026-04-13 09:46:02 +03:00
function renderRules() {
const list = document.getElementById('rulesList');
const rules = getRules();
list.innerHTML = '';
2026-04-13 18:56:13 +03:00
if (rules.length === 0) { list.innerHTML = '<div class="empty-state">Нет правил маршрутизации</div>'; return; }
2026-04-13 09:46:02 +03:00
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 = `
2026-04-15 11:38:26 +03:00
<span class="rule-num">${i + 1}</span>
2026-04-13 09:46:02 +03:00
<div class="rule-info">
<span class="rule-type">${esc(type)}</span>
2026-04-15 11:38:26 +03:00
<span class="rule-value">${esc(value || (type === 'MATCH' ? '—' : ''))}</span>
2026-04-13 09:46:02 +03:00
</div>
2026-04-15 11:38:26 +03:00
<div class="rule-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</div>
<span class="rule-target">${esc(target)}</span>
${flags ? `<span class="rule-flags">${esc(flags)}</span>` : ''}
<button class="btn-icon btn-icon-danger rule-del" data-delete-rule="${i}" title="Удалить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>`;
2026-04-13 09:46:02 +03:00
list.appendChild(el);
});
}
2026-04-13 18:56:13 +03:00
// ─── 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 = `
<div class="proxy-card-header">
<div style="display:flex;align-items:center;gap:8px">
<span class="proxy-name">${esc(p.name)}</span>
<span class="tag-active">${esc(p.type || 'http')}</span>
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-ghost btn-sm" data-edit-pp="${esc(p.name)}">Изменить</button>
<button class="btn btn-danger btn-sm" data-delete-pp="${esc(p.name)}">Удалить</button>
</div>
</div>
<div class="proxy-card-info">
${p.url ? `<div class="info-row"><span class="info-label">URL</span><span class="info-val mono" style="font-size:.75rem">${esc(p.url)}</span></div>` : ''}
${p.interval ? `<div class="info-row"><span class="info-label">Интервал</span><span class="info-val">${p.interval}с</span></div>` : ''}
${p.filter ? `<div class="info-row"><span class="info-label">Filter</span><span class="info-val mono">${esc(p.filter)}</span></div>` : ''}
</div>`;
list.appendChild(card);
});
}
2026-04-13 09:46:02 +03:00
2026-04-13 18:56:13 +03:00
// ─── 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 = `
<div class="proxy-card-header">
<div style="display:flex;align-items:center;gap:8px">
<span class="proxy-name">${esc(p.name)}</span>
<span class="tag-active">${esc(p.type || 'http')}</span>
<span class="tag-gw">${esc(p.behavior || 'classical')}</span>
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-ghost btn-sm" data-edit-rp="${esc(p.name)}">Изменить</button>
<button class="btn btn-danger btn-sm" data-delete-rp="${esc(p.name)}">Удалить</button>
</div>
</div>
<div class="proxy-card-info">
${p.url ? `<div class="info-row"><span class="info-label">URL</span><span class="info-val mono" style="font-size:.75rem">${esc(p.url)}</span></div>` : ''}
${p.format ? `<div class="info-row"><span class="info-label">Format</span><span class="info-val">${esc(p.format)}</span></div>` : ''}
</div>`;
list.appendChild(card);
});
}
2026-04-13 09:46:02 +03:00
2026-04-13 18:56:13 +03:00
// ─── 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');
2026-04-13 09:46:02 +03:00
setSegBtn('dnsModeSwitch', dns['enhanced-mode'] || 'redir-host');
2026-04-13 18:56:13 +03:00
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);
2026-04-13 09:46:02 +03:00
}
2026-04-13 18:56:13 +03:00
function setVal(id, val, isCheck) {
const el = document.getElementById(id);
if (!el) return;
if (isCheck) el.checked = !!val;
else el.value = val == null ? '' : val;
2026-04-13 09:46:02 +03:00
}
2026-04-13 18:56:13 +03:00
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);
2026-04-13 09:46:02 +03:00
}
2026-04-13 18:56:13 +03:00
// ─── 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'),
};
2026-04-13 09:46:02 +03:00
}
// ─── 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');
2026-04-13 18:56:13 +03:00
if (btn.dataset.tab === 'dashboard') {
updateDashVisibility();
if (PS.status && PS.status.running) startDashPolling();
} else {
stopDashPolling();
}
2026-04-13 09:46:02 +03:00
});
});
// ─── Core control ───
2026-04-13 18:56:13 +03:00
document.getElementById('serviceActive').addEventListener('change', async (e) => {
const action = e.target.checked ? 'start' : 'stop';
2026-04-13 09:46:02 +03:00
try {
2026-04-13 18:56:13 +03:00
await post('/api/mihomo/' + action, null);
showToast('Mihomo ' + (action === 'start' ? 'запущен' : 'остановлен'), 'success');
2026-04-13 09:46:02 +03:00
await loadStatus();
2026-04-13 18:56:13 +03:00
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');
2026-04-13 09:46:02 +03:00
await loadStatus();
2026-04-13 18:56:13 +03:00
updateDashVisibility();
2026-04-13 09:46:02 +03:00
}
});
document.getElementById('restartBtn').addEventListener('click', async () => {
try {
await post('/api/mihomo/restart', null);
showToast('Mihomo перезапущен', 'success');
await loadStatus();
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
});
2026-04-13 18:56:13 +03:00
// ─── Settings ───
2026-04-13 09:46:02 +03:00
document.getElementById('settingsForm').addEventListener('submit', e => {
e.preventDefault();
applySettingsToConfig();
saveFullConfig(false);
});
document.getElementById('saveAndRestartBtn').addEventListener('click', () => {
applySettingsToConfig();
saveFullConfig(true);
});
2026-04-13 18:56:13 +03:00
// ════════════════════════════════════════════════════════════
// 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'); }
2026-04-13 09:46:02 +03:00
function updateProxyFields() {
const type = document.getElementById('proxyType').value;
2026-04-13 18:56:13 +03:00
// 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
2026-04-13 09:46:02 +03:00
switch (type) {
2026-04-13 18:56:13 +03:00
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;
2026-04-13 09:46:02 +03:00
case 'http':
case 'socks5':
2026-04-13 18:56:13 +03:00
showEl('pf-http-auth');
// headers only for http
const hrow = document.getElementById('pf-http-headers-row');
if (hrow) hrow.classList.toggle('hidden', type !== 'http');
2026-04-13 09:46:02 +03:00
break;
}
2026-04-13 18:56:13 +03:00
// 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;
}
2026-04-13 09:46:02 +03:00
}
document.getElementById('proxyType').addEventListener('change', updateProxyFields);
2026-04-13 18:56:13 +03:00
document.getElementById('proxyNetwork').addEventListener('change', updateTransportOpts);
2026-04-13 09:46:02 +03:00
2026-04-13 18:56:13 +03:00
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 ───
2026-04-13 09:46:02 +03:00
let editProxyName = null;
function openProxyModal(proxy) {
editProxyName = proxy ? proxy.name : null;
document.getElementById('proxyModalTitle').textContent = proxy ? 'Редактировать прокси' : 'Добавить прокси';
document.getElementById('proxyEditName').value = proxy ? proxy.name : '';
2026-04-13 18:56:13 +03:00
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 = '';
}
2026-04-13 09:46:02 +03:00
updateProxyFields();
2026-04-13 18:56:13 +03:00
// Trigger plugin-related visibility
document.getElementById('ssPlugin').dispatchEvent(new Event('change'));
2026-04-13 09:46:02 +03:00
document.getElementById('proxyModal').classList.remove('hidden');
}
2026-04-13 18:56:13 +03:00
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;
}
2026-04-13 09:46:02 +03:00
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;
2026-04-13 18:56:13 +03:00
const name = document.getElementById('proxyName').value.trim();
2026-04-13 09:46:02 +03:00
2026-04-13 18:56:13 +03:00
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;
2026-04-13 09:46:02 +03:00
}
2026-04-13 18:56:13 +03:00
// 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
2026-04-13 09:46:02 +03:00
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 => {
2026-04-13 18:56:13 +03:00
if (g.proxies) g.proxies = g.proxies.map(pn => pn === oldName ? proxy.name : pn);
2026-04-13 09:46:02 +03:00
});
}
}
} 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) {
2026-04-13 18:56:13 +03:00
const proxy = getProxies().find(p => p.name === editBtn.dataset.editProxy);
2026-04-13 09:46:02 +03:00
if (proxy) openProxyModal(proxy);
} else if (delBtn) {
const name = delBtn.dataset.deleteProxy;
if (confirm(`Удалить прокси "${name}"?`)) {
2026-04-13 18:56:13 +03:00
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);
2026-04-13 09:46:02 +03:00
}
}
});
2026-04-13 18:56:13 +03:00
// ════════════════════════════════════════════════════════════
// GROUP MODAL
// ════════════════════════════════════════════════════════════
2026-04-13 09:46:02 +03:00
function updateGroupFields() {
const type = document.getElementById('groupType').value;
const urlField = document.getElementById('groupURLField');
2026-04-13 18:56:13 +03:00
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');
2026-04-13 09:46:02 +03:00
}
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;
2026-04-13 18:56:13 +03:00
document.getElementById('groupTimeout').value = group ? (group.timeout || 5000) : 5000;
2026-04-13 09:46:02 +03:00
document.getElementById('groupTolerance').value = group ? (group.tolerance || 50) : 50;
2026-04-13 18:56:13 +03:00
document.getElementById('groupMaxFailed').value = group ? (group['max-failed-times'] || 5) : 5;
document.getElementById('groupLazy').checked = group ? (group.lazy !== false) : true;
2026-04-13 09:46:02 +03:00
document.getElementById('groupIncludeAll').checked = group ? (group['include-all'] || false) : false;
document.getElementById('groupFilter').value = group ? (group.filter || '') : '';
2026-04-13 18:56:13 +03:00
document.getElementById('groupExcludeFilter').value = group ? (group['exclude-filter'] || '') : '';
document.getElementById('groupExcludeType').value = group ? (group['exclude-type'] || '') : '';
document.getElementById('groupExpectedStatus').value = group ? (group['expected-status'] || '') : '';
2026-04-13 09:46:02 +03:00
document.getElementById('groupLBStrategy').value = group ? (group.strategy || 'round-robin') : 'round-robin';
2026-04-13 18:56:13 +03:00
document.getElementById('groupDisableUDP').checked = group ? (group['disable-udp'] || false) : false;
2026-04-13 09:46:02 +03:00
const checkboxes = document.getElementById('groupProxyCheckboxes');
checkboxes.innerHTML = '';
const selected = group ? (group.proxies || []) : [];
2026-04-13 18:56:13 +03:00
const allItems = ['DIRECT', 'REJECT', ...getProxies().map(p => p.name), ...getGroups().filter(g => !group || g.name !== group.name).map(g => g.name)];
allItems.forEach(name => {
2026-04-13 09:46:02 +03:00
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');
2026-04-13 18:56:13 +03:00
cb.type = 'checkbox'; cb.value = name;
2026-04-13 09:46:02 +03:00
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');
}
2026-04-13 18:56:13 +03:00
function closeGroupModal() { document.getElementById('groupModal').classList.add('hidden'); }
2026-04-13 09:46:02 +03:00
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 = [];
2026-04-13 18:56:13 +03:00
document.querySelectorAll('#groupProxyCheckboxes input[type="checkbox"]:checked').forEach(cb => selectedProxies.push(cb.value));
2026-04-13 09:46:02 +03:00
2026-04-13 18:56:13 +03:00
const type = document.getElementById('groupType').value;
const hasURL = ['url-test', 'fallback', 'load-balance'].includes(type);
2026-04-13 09:46:02 +03:00
const group = {
name: document.getElementById('groupName').value.trim(),
2026-04-13 18:56:13 +03:00
type,
2026-04-13 09:46:02 +03:00
proxies: selectedProxies,
2026-04-13 18:56:13 +03:00
'include-all': gCheck('groupIncludeAll'),
'disable-udp': gCheck('groupDisableUDP'),
2026-04-13 09:46:02 +03:00
};
2026-04-13 18:56:13 +03:00
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;
2026-04-13 09:46:02 +03:00
}
2026-04-13 18:56:13 +03:00
if (type === 'url-test') {
const tol = gNum('groupTolerance', 0);
if (tol) group.tolerance = tol;
}
if (type === 'load-balance') {
group.strategy = gVal('groupLBStrategy') || 'round-robin';
2026-04-13 09:46:02 +03:00
}
2026-04-13 18:56:13 +03:00
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; }
2026-04-13 09:46:02 +03:00
const groups = getGroups();
if (editGroupName) {
const idx = groups.findIndex(g => g.name === editGroupName);
2026-04-13 18:56:13 +03:00
if (idx >= 0) groups[idx] = group;
2026-04-13 09:46:02 +03:00
} else {
2026-04-13 18:56:13 +03:00
if (groups.some(g => g.name === group.name)) { showToast('Группа с таким именем уже существует', 'error'); return; }
2026-04-13 09:46:02 +03:00
groups.push(group);
}
PS.config['proxy-groups'] = groups;
2026-04-13 18:56:13 +03:00
closeGroupModal(); renderAll(); saveFullConfig(false);
2026-04-13 09:46:02 +03:00
});
document.getElementById('groupList').addEventListener('click', e => {
const editBtn = e.target.closest('[data-edit-group]');
const delBtn = e.target.closest('[data-delete-group]');
if (editBtn) {
2026-04-13 18:56:13 +03:00
const group = getGroups().find(g => g.name === editBtn.dataset.editGroup);
2026-04-13 09:46:02 +03:00
if (group) openGroupModal(group);
} else if (delBtn) {
const name = delBtn.dataset.deleteGroup;
if (confirm(`Удалить группу "${name}"?`)) {
2026-04-13 18:56:13 +03:00
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);
2026-04-13 09:46:02 +03:00
}
}
});
2026-04-13 18:56:13 +03:00
// ════════════════════════════════════════════════════════════
// 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'];
2026-04-13 09:46:02 +03:00
function updateRuleFields() {
const type = document.getElementById('ruleType').value;
const valueDiv = document.getElementById('ruleValueField');
const noResolveDiv = document.getElementById('ruleNoResolveDiv');
2026-04-13 18:56:13 +03:00
valueDiv.classList.toggle('hidden', type === 'MATCH');
noResolveDiv.classList.toggle('hidden', !NO_RESOLVE_TYPES.includes(type));
2026-04-13 09:46:02 +03:00
const sel = document.getElementById('ruleProxy');
2026-04-13 18:56:13 +03:00
const cur = sel.value;
sel.innerHTML = '<option value="DIRECT">DIRECT</option><option value="REJECT">REJECT</option><option value="REJECT-DROP">REJECT-DROP</option>';
2026-04-13 09:46:02 +03:00
getGroups().forEach(g => {
const opt = document.createElement('option');
2026-04-13 18:56:13 +03:00
opt.value = g.name; opt.textContent = g.name;
2026-04-13 09:46:02 +03:00
sel.appendChild(opt);
});
2026-04-13 18:56:13 +03:00
getProxies().filter(p => p.type !== 'direct').forEach(p => {
const opt = document.createElement('option');
opt.value = p.name; opt.textContent = p.name;
sel.appendChild(opt);
2026-04-13 09:46:02 +03:00
});
2026-04-13 18:56:13 +03:00
if ([...sel.options].some(o => o.value === cur)) sel.value = cur;
2026-04-13 09:46:02 +03:00
}
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');
}
2026-04-13 18:56:13 +03:00
function closeRuleModal() { document.getElementById('ruleModal').classList.add('hidden'); }
2026-04-13 09:46:02 +03:00
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;
2026-04-13 18:56:13 +03:00
const value = gVal('ruleValue').trim();
const proxy = gVal('ruleProxy');
const noResolve = gCheck('ruleNoResolve');
2026-04-13 09:46:02 +03:00
2026-04-13 18:56:13 +03:00
if (type !== 'MATCH' && !value) { showToast('Введите значение правила', 'error'); return; }
2026-04-13 09:46:02 +03:00
let rule;
if (type === 'MATCH') {
rule = `MATCH,${proxy}`;
} else {
rule = `${type},${value},${proxy}`;
2026-04-13 18:56:13 +03:00
if (NO_RESOLVE_TYPES.includes(type) && noResolve) rule += ',no-resolve';
2026-04-13 09:46:02 +03:00
}
const rules = getRules();
rules.push(rule);
PS.config.rules = rules;
2026-04-13 18:56:13 +03:00
closeRuleModal(); renderAll(); saveFullConfig(false);
2026-04-13 09:46:02 +03:00
});
document.getElementById('rulesList').addEventListener('click', e => {
const delBtn = e.target.closest('[data-delete-rule]');
if (delBtn) {
const idx = parseInt(delBtn.dataset.deleteRule);
2026-04-13 18:56:13 +03:00
if (!isNaN(idx)) {
const rules = getRules();
rules.splice(idx, 1);
PS.config.rules = rules;
renderAll(); saveFullConfig(false);
}
2026-04-13 09:46:02 +03:00
}
});
2026-04-13 18:56:13 +03:00
// ════════════════════════════════════════════════════════════
// 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
// ════════════════════════════════════════════════════════════
2026-04-13 09:46:02 +03:00
document.getElementById('uploadCoreForm').addEventListener('submit', async e => {
e.preventDefault();
const fileInput = document.getElementById('coreFile');
2026-04-13 18:56:13 +03:00
if (!fileInput.files[0]) { showToast('Выберите файл', 'error'); return; }
2026-04-13 09:46:02 +03:00
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();
2026-04-13 18:56:13 +03:00
if (!json.success) throw new Error(json.error || 'Upload failed');
2026-04-13 09:46:02 +03:00
showToast('Ядро загружено: ' + (json.data.arch || json.data.path), 'success');
await loadStatus();
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
});
2026-04-13 18:56:13 +03:00
// ════════════════════════════════════════════════════════════
// YAML EDITOR
// ════════════════════════════════════════════════════════════
2026-04-13 09:46:02 +03:00
document.getElementById('yamlLoadBtn').addEventListener('click', async () => {
try {
const res = await fetch('/api/mihomo/config.yaml');
2026-04-13 18:56:13 +03:00
if (res.status === 404) { document.getElementById('yamlEditor').value = '# Config not found.'; return; }
document.getElementById('yamlEditor').value = await res.text();
2026-04-13 09:46:02 +03:00
showToast('Конфиг загружен', 'info');
2026-04-13 18:56:13 +03:00
} catch (e) { showToast('Ошибка: ' + e.message, 'error'); }
2026-04-13 09:46:02 +03:00
});
document.getElementById('yamlSaveBtn').addEventListener('click', async () => {
const content = document.getElementById('yamlEditor').value;
try {
const res = await fetch('/api/mihomo/config.yaml', {
2026-04-13 18:56:13 +03:00
method: 'PUT', headers: { 'Content-Type': 'text/yaml' }, body: content,
2026-04-13 09:46:02 +03:00
});
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Save failed');
showToast('Конфиг сохранён', 'success');
await loadConfig();
2026-04-13 18:56:13 +03:00
} catch (e) { showToast('Ошибка: ' + e.message, 'error'); }
2026-04-13 09:46:02 +03:00
});
2026-04-13 18:56:13 +03:00
// ════════════════════════════════════════════════════════════
// LOGS
// ════════════════════════════════════════════════════════════
2026-04-13 09:46:02 +03:00
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() {
2026-04-13 18:56:13 +03:00
if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; }
2026-04-13 09:46:02 +03:00
}
document.querySelectorAll('.ptab').forEach(btn => {
btn.addEventListener('click', () => {
2026-04-13 18:56:13 +03:00
if (btn.dataset.tab === 'logs') { lastLogCount = 0; startLogPoll(); }
else stopLogPoll();
2026-04-13 09:46:02 +03:00
});
});
document.getElementById('clearLogsBtn').addEventListener('click', () => {
document.getElementById('logOutput').textContent = '';
lastLogCount = 0;
});
2026-04-13 18:56:13 +03:00
// ════════════════════════════════════════════════════════════
// DASHBOARD (LIVE MIHOMO API)
// ════════════════════════════════════════════════════════════
const DA = {
ws: null,
2026-04-15 11:38:26 +03:00
memWs: null,
2026-04-13 18:56:13 +03:00
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;
}
2026-04-15 11:38:26 +03:00
if (DA.memWs) {
try { DA.memWs.close(); } catch(e) {}
DA.memWs = null;
}
2026-04-13 18:56:13 +03:00
}
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) {}
2026-04-15 11:38:26 +03:00
const memUrl = proto + '//' + location.host + '/api/mihomo/ws/memory';
try {
const ws = new WebSocket(memUrl);
ws.onmessage = (e) => {
try {
const d = JSON.parse(e.data);
DA.memory = d.inuse || 0;
} catch(err) {}
};
ws.onerror = () => {};
ws.onclose = () => { DA.memWs = null; };
DA.memWs = ws;
} catch(e) {}
2026-04-13 18:56:13 +03:00
}
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 = '<div class="empty-state">Нет групп прокси. Добавьте группы во вкладке «Группы».</div>';
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 `<div class="dash-proxy-item${isActive ? ' dash-proxy-active' : ''}" data-group="${esc(name)}" data-proxy="${esc(m)}">
<span class="dash-proxy-name">${esc(m)}</span>
<span class="dash-proxy-type">${esc(type)}</span>
<span class="dash-delay ${delayClass}">${delayText}</span>
</div>`;
}).join('');
card.innerHTML = `
<div class="dash-group-header">
<div class="dash-group-title">
<span class="dash-group-name">${esc(name)}</span>
<span class="tag-gw">${esc(g.type || '')}</span>
</div>
<button class="btn btn-ghost btn-sm dash-ping-group-btn" data-group="${esc(name)}">Пинг</button>
</div>
<div class="dash-proxy-list">${members || '<div class="empty-state" style="padding:8px">Нет нод</div>'}</div>`;
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 = `
<td class="dash-conn-host" title="${esc(host)}">${esc(host.length > 40 ? host.slice(0, 40) + '...' : host)}</td>
<td>${esc(meta.network || '')}</td>
<td class="dash-conn-chain" title="${esc(chain)}">${esc(chain.length > 30 ? chain.slice(0, 30) + '...' : chain)}</td>
<td class="dash-conn-traffic">${formatBytes(c.upload || 0)}</td>
<td class="dash-conn-traffic">${formatBytes(c.download || 0)}</td>
<td><button class="btn-icon dash-conn-close" data-id="${esc(c.id || '')}" title="Закрыть"></button></td>`;
tbody.appendChild(tr);
}
if (conns.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state" style="padding:12px">Нет активных соединений</td></tr>';
}
}
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
// ════════════════════════════════════════════════════════════
2026-04-13 09:46:02 +03:00
(async () => {
2026-04-13 18:56:13 +03:00
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();
})();