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