Files
alpine-router/public/proxy.js
2026-04-13 09:46:02 +03:00

957 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const PS = {
status: null,
config: null,
};
async function api(method, path, body) {
const opts = {
method,
headers: body ? (body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) : {},
};
if (body && !(body instanceof FormData)) {
opts.body = JSON.stringify(body);
} else if (body instanceof FormData) {
opts.body = body;
}
const res = await fetch(path, opts);
const json = await res.json();
if (!res.ok || !json.success) {
throw new Error(json.error || `HTTP ${res.status}`);
}
return json.data;
}
const get = (path) => api('GET', path);
const post = (path, body) => api('POST', path, body);
const put = (path, body) => api('PUT', path, body);
function showToast(msg, type = 'info') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = `toast ${type}`;
t.classList.remove('hidden');
clearTimeout(t._timer);
t._timer = setTimeout(() => t.classList.add('hidden'), 3500);
}
function typeLabel(t) {
const m = { ss: 'Shadowsocks', vmess: 'VMess', vless: 'VLESS', trojan: 'Trojan', hysteria2: 'Hysteria2', http: 'HTTP', socks5: 'SOCKS5', direct: 'DIRECT' };
return m[t] || t;
}
function groupTypeLabel(t) {
const m = { select: 'Выбор', 'url-test': 'Автотест', fallback: 'Резерв', 'load-balance': 'Балансировка' };
return m[t] || t;
}
function getProxies() { return PS.config && Array.isArray(PS.config.proxies) ? PS.config.proxies : []; }
function getGroups() { return PS.config && Array.isArray(PS.config['proxy-groups']) ? PS.config['proxy-groups'] : []; }
function getRules() { return PS.config && Array.isArray(PS.config.rules) ? PS.config.rules : []; }
function getGeneral() { return PS.config && PS.config.general ? PS.config.general : {}; }
function getTProxy() { return PS.config && PS.config.tproxy ? PS.config.tproxy : {}; }
function getDNS() { return PS.config && PS.config.dns ? PS.config.dns : {}; }
async function loadStatus() {
try {
PS.status = await get('/api/mihomo/status');
renderStatus();
} catch (e) {
console.error('load status', e);
}
}
async function loadConfig() {
try {
PS.config = await get('/api/mihomo/config');
renderAll();
} catch (e) {
console.error('load config', e);
PS.config = {};
renderAll();
showToast('Ошибка загрузки конфига: ' + e.message, 'error');
}
document.getElementById('loading').classList.add('hidden');
}
async function saveFullConfig(restart) {
try {
await put('/api/mihomo/config', PS.config);
showToast('Конфиг сохранён', 'success');
refreshYAMLPreview();
if (restart) {
try {
await post('/api/mihomo/restart', null);
showToast('Mihomo перезапущен', 'success');
} catch (e) {
showToast('Перезапуск не удался: ' + e.message, 'error');
}
await loadStatus();
}
} catch (e) {
showToast('Ошибка сохранения: ' + e.message, 'error');
}
}
async function refreshYAMLPreview() {
try {
const res = await fetch('/api/mihomo/config.yaml');
if (res.ok) {
document.getElementById('yamlPreview').value = await res.text();
}
} catch (e) { /* ignore */ }
}
function renderStatus() {
const s = PS.status;
if (!s) return;
const text = document.getElementById('statusText');
const headerBadge = document.getElementById('statusBadge');
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const restartBtn = document.getElementById('restartBtn');
const coreInfo = document.getElementById('coreInfo');
if (s.running) {
text.className = 'svc-badge running';
text.textContent = 'Запущен (PID ' + (s.pid || '?') + ')';
headerBadge.className = 'svc-badge running';
headerBadge.textContent = 'Запущен';
startBtn.disabled = true;
stopBtn.disabled = false;
restartBtn.disabled = false;
} else {
text.className = 'svc-badge stopped';
text.textContent = 'Остановлен';
headerBadge.className = 'svc-badge stopped';
headerBadge.textContent = 'Остановлен';
startBtn.disabled = false;
stopBtn.disabled = true;
restartBtn.disabled = true;
}
document.getElementById('corePath').textContent = s.core_path || '—';
document.getElementById('coreExists').textContent = s.core_exists ? 'Да' : 'Нет';
document.getElementById('corePid').textContent = s.running && s.pid ? s.pid : '—';
if (!s.core_exists) {
coreInfo.classList.remove('hidden');
document.getElementById('coreInfoMsg').textContent = 'Ядро Mihomo не найдено. Загрузите бинарный файл в раздел «Ядро».';
} else {
coreInfo.classList.add('hidden');
}
document.getElementById('statusBar').classList.remove('hidden');
document.getElementById('loading').classList.add('hidden');
}
function renderAll() {
renderProxies();
renderGroups();
renderRules();
fillSettings();
refreshYAMLPreview();
}
function renderProxies() {
const list = document.getElementById('proxyList');
const empty = document.getElementById('proxyEmpty');
const proxies = getProxies();
list.innerHTML = '';
if (proxies.length === 0) {
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
proxies.forEach(p => {
const card = document.createElement('div');
card.className = 'proxy-card';
card.innerHTML = `
<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">
${p.type !== 'direct' ? `<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>` : ''}
${p.udp ? '<span class="tag-active" style="margin-left:0">UDP</span>' : ''}
${p.tls ? '<span class="tag-active" style="margin-left:4px">TLS</span>' : ''}
</div>
`;
list.appendChild(card);
});
}
function renderGroups() {
const list = document.getElementById('groupList');
const empty = document.getElementById('groupEmpty');
const groups = getGroups();
list.innerHTML = '';
if (groups.length === 0) {
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
groups.forEach(g => {
const card = document.createElement('div');
card.className = 'proxy-card';
const proxyList = (g.proxies || []).slice(0, 5).map(p => esc(p)).join(', ');
const more = (g.proxies || []).length > 5 ? ` +${(g.proxies || []).length - 5}` : '';
card.innerHTML = `
<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">${proxyList}${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);
});
}
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 = `
<div class="rule-info">
<span class="rule-type">${esc(type)}</span>
<span class="rule-value">${esc(value || (type === 'MATCH' ? '*' : ''))}</span>
<span class="rule-target">${esc(target)}</span>
${flags ? `<span class="rule-flags">${esc(flags)}</span>` : ''}
</div>
<div style="display:flex;gap:4px">
<button class="btn btn-danger btn-sm" data-delete-rule="${i}">✕</button>
</div>
`;
list.appendChild(el);
});
}
function fillSettings() {
if (!PS.config) {
document.getElementById('loading').classList.add('hidden');
return;
}
const g = getGeneral();
const tp = getTProxy();
const dns = getDNS();
document.getElementById('mixedPort').value = g['mixed-port'] || 7890;
document.getElementById('allowLan').checked = g['allow-lan'] !== false;
document.getElementById('ipv6').checked = g.ipv6 !== false;
document.getElementById('tcpConcurrent').checked = g['tcp-concurrent'] !== false;
document.getElementById('externalController').value = g['external-controller'] || '0.0.0.0:9090';
document.getElementById('secret').value = g.secret || '';
setSegBtn('modeSwitch', g.mode || 'rule');
document.getElementById('tproxyEnabled').checked = tp.enabled || false;
document.getElementById('tproxyPort').value = tp.port || 7894;
document.getElementById('dnsEnabled').checked = dns.enable !== false;
document.getElementById('dnsListen').value = dns.listen || '0.0.0.0:1053';
setSegBtn('dnsModeSwitch', dns['enhanced-mode'] || 'redir-host');
document.getElementById('dnsNameserver').value = (dns.nameserver || []).join('\n');
document.getElementById('dnsFallback').value = (dns.fallback || []).join('\n');
}
function setSegBtn(id, mode) {
document.querySelectorAll(`#${id} .seg-btn`).forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
}
function getSegBtn(id) {
const active = document.querySelector(`#${id} .seg-btn.active`);
return active ? active.dataset.mode : '';
}
function esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ─── Tab switching ───
document.querySelectorAll('.ptab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.ptab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.ptab-content').forEach(c => c.classList.add('hidden'));
btn.classList.add('active');
document.getElementById('tab-' + btn.dataset.tab).classList.remove('hidden');
});
});
// ─── Core control ───
document.getElementById('startBtn').addEventListener('click', async () => {
try {
await post('/api/mihomo/start', null);
showToast('Mihomo запущен', 'success');
await loadStatus();
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
});
document.getElementById('stopBtn').addEventListener('click', async () => {
try {
await post('/api/mihomo/stop', null);
showToast('Mihomo остановлен', 'success');
await loadStatus();
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
});
document.getElementById('restartBtn').addEventListener('click', async () => {
try {
await post('/api/mihomo/restart', null);
showToast('Mihomo перезапущен', 'success');
await loadStatus();
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
});
// ─── Config mode switches ───
document.getElementById('modeSwitch').addEventListener('click', e => {
const btn = e.target.closest('.seg-btn');
if (btn) setSegBtn('modeSwitch', btn.dataset.mode);
});
document.getElementById('dnsModeSwitch').addEventListener('click', e => {
const btn = e.target.closest('.seg-btn');
if (btn) setSegBtn('dnsModeSwitch', btn.dataset.mode);
});
// ─── Settings save ───
function applySettingsToConfig() {
if (!PS.config) PS.config = {};
PS.config['mixed-port'] = parseInt(document.getElementById('mixedPort').value) || 7890;
PS.config['allow-lan'] = document.getElementById('allowLan').checked;
PS.config['bind-address'] = '*';
PS.config.mode = getSegBtn('modeSwitch');
PS.config['log-level'] = 'info';
PS.config.ipv6 = document.getElementById('ipv6').checked;
PS.config['external-controller'] = document.getElementById('externalController').value;
PS.config.secret = document.getElementById('secret').value || '';
PS.config['tcp-concurrent'] = document.getElementById('tcpConcurrent').checked;
PS.config['find-process-mode'] = 'off';
const tpEnabled = document.getElementById('tproxyEnabled').checked;
if (tpEnabled) {
PS.config['tproxy-port'] = parseInt(document.getElementById('tproxyPort').value) || 7894;
} else {
delete PS.config['tproxy-port'];
}
PS.config.dns = {
enable: document.getElementById('dnsEnabled').checked,
ipv6: document.getElementById('ipv6').checked,
listen: document.getElementById('dnsListen').value,
'enhanced-mode': getSegBtn('dnsModeSwitch'),
'fake-ip-range': '198.18.0.1/16',
'fake-ip-filter': ['*.lan', '*.local', '+.market.xiaomi.com'],
'default-nameserver': ['223.5.5.5', '119.29.29.29'],
nameserver: document.getElementById('dnsNameserver').value.split('\n').map(s => s.trim()).filter(Boolean),
fallback: document.getElementById('dnsFallback').value.split('\n').map(s => s.trim()).filter(Boolean),
};
PS.config.profile = { 'store-selected': true, 'store-fake-ip': true };
}
document.getElementById('settingsForm').addEventListener('submit', e => {
e.preventDefault();
applySettingsToConfig();
saveFullConfig(false);
});
document.getElementById('saveAndRestartBtn').addEventListener('click', () => {
applySettingsToConfig();
saveFullConfig(true);
});
// ─── Proxy Modal ───
function updateProxyFields() {
const type = document.getElementById('proxyType').value;
const serverFields = document.getElementById('proxyServerFields');
const authFields = document.getElementById('proxyAuthFields');
const cipherField = document.getElementById('proxyCipherField');
const uuidField = document.getElementById('proxyUUIDField');
const trojanPassField = document.getElementById('proxyTrojanPassField');
const hysteria2Fields = document.getElementById('proxyHysteria2Fields');
const tlsField = document.getElementById('proxyTLSField');
const vlessFlowField = document.getElementById('proxyVlessFlowField');
const networkField = document.getElementById('proxyNetworkField');
serverFields.classList.remove('hidden');
authFields.classList.add('hidden');
cipherField.classList.add('hidden');
uuidField.classList.add('hidden');
trojanPassField.classList.add('hidden');
hysteria2Fields.classList.add('hidden');
tlsField.classList.add('hidden');
vlessFlowField.classList.add('hidden');
networkField.classList.add('hidden');
switch (type) {
case 'ss':
cipherField.classList.remove('hidden');
tlsField.classList.remove('hidden');
document.querySelector('#proxyPassword').closest('.form-row').classList.remove('hidden');
break;
case 'vmess':
uuidField.classList.remove('hidden');
cipherField.classList.remove('hidden');
tlsField.classList.remove('hidden');
networkField.classList.remove('hidden');
document.getElementById('proxyCipher').innerHTML = '<option value="auto">auto</option><option value="none">none</option><option value="zero">zero</option><option value="aes-128-gcm">aes-128-gcm</option><option value="chacha20-poly1305">chacha20-poly1305</option>';
break;
case 'vless':
uuidField.classList.remove('hidden');
tlsField.classList.remove('hidden');
vlessFlowField.classList.remove('hidden');
networkField.classList.remove('hidden');
break;
case 'trojan':
trojanPassField.classList.remove('hidden');
tlsField.classList.remove('hidden');
networkField.classList.remove('hidden');
break;
case 'hysteria2':
hysteria2Fields.classList.remove('hidden');
tlsField.classList.add('hidden');
document.querySelector('#proxyPassword').closest('.form-row').classList.remove('hidden');
serverFields.classList.remove('hidden');
break;
case 'http':
authFields.classList.remove('hidden');
tlsField.classList.remove('hidden');
break;
case 'socks5':
authFields.classList.remove('hidden');
tlsField.classList.remove('hidden');
break;
case 'direct':
serverFields.classList.add('hidden');
break;
}
}
document.getElementById('proxyType').addEventListener('change', updateProxyFields);
let editProxyName = null;
function openProxyModal(proxy) {
editProxyName = proxy ? proxy.name : null;
document.getElementById('proxyModalTitle').textContent = proxy ? 'Редактировать прокси' : 'Добавить прокси';
document.getElementById('proxyEditName').value = proxy ? proxy.name : '';
document.getElementById('proxyName').value = proxy ? proxy.name : '';
document.getElementById('proxyType').value = proxy ? proxy.type : 'ss';
document.getElementById('proxyServer').value = proxy ? (proxy.server || '') : '';
document.getElementById('proxyPort').value = proxy ? (proxy.port || '') : '';
document.getElementById('proxyUDP').checked = proxy ? (proxy.udp !== false) : true;
document.getElementById('proxyUsername').value = proxy ? (proxy.username || '') : '';
document.getElementById('proxyPassword').value = proxy ? (proxy.password || '') : '';
document.getElementById('proxyTLS').checked = proxy ? (proxy.tls || false) : false;
document.getElementById('proxySNI').value = proxy ? (proxy.servername || '') : '';
document.getElementById('proxySkipCertVerify').checked = proxy ? (proxy['skip-cert-verify'] || false) : false;
document.getElementById('proxyUUID').value = proxy ? (proxy.uuid || '') : '';
document.getElementById('proxyCipher').value = proxy ? (proxy.cipher || 'auto') : 'auto';
document.getElementById('proxyFlow').value = proxy ? (proxy.flow || '') : '';
document.getElementById('proxyNetwork').value = proxy ? (proxy.network || '') : '';
document.getElementById('proxyTrojanPass').value = proxy ? (proxy.password || '') : '';
document.getElementById('proxyObfs').value = proxy ? (proxy.obfs || '') : '';
document.getElementById('proxyObfsPass').value = proxy ? (proxy['obfs-password'] || '') : '';
updateProxyFields();
document.getElementById('proxyModal').classList.remove('hidden');
}
function closeProxyModal() {
document.getElementById('proxyModal').classList.add('hidden');
}
document.getElementById('addProxyBtn').addEventListener('click', () => openProxyModal(null));
document.getElementById('closeProxyModal').addEventListener('click', closeProxyModal);
document.getElementById('cancelProxyBtn').addEventListener('click', closeProxyModal);
document.getElementById('proxyModalBackdrop').addEventListener('click', closeProxyModal);
document.getElementById('saveProxyBtn').addEventListener('click', () => {
const type = document.getElementById('proxyType').value;
const proxy = {
name: document.getElementById('proxyName').value.trim(),
type: type,
server: document.getElementById('proxyServer').value.trim(),
port: parseInt(document.getElementById('proxyPort').value) || 443,
udp: document.getElementById('proxyUDP').checked,
tls: document.getElementById('proxyTLS').checked,
servername: document.getElementById('proxySNI').value.trim(),
'skip-cert-verify': document.getElementById('proxySkipCertVerify').checked,
network: document.getElementById('proxyNetwork').value || '',
};
if (type === 'ss') {
proxy.cipher = document.getElementById('proxyCipher').value;
proxy.password = document.getElementById('proxyPassword').value;
} else if (type === 'vmess' || type === 'vless') {
proxy.uuid = document.getElementById('proxyUUID').value.trim();
if (type === 'vless') proxy.flow = document.getElementById('proxyFlow').value.trim();
proxy.cipher = document.getElementById('proxyCipher').value;
} else if (type === 'trojan') {
proxy.password = document.getElementById('proxyTrojanPass').value;
} else if (type === 'hysteria2') {
proxy.password = document.getElementById('proxyPassword').value;
proxy.obfs = document.getElementById('proxyObfs').value.trim();
proxy['obfs-password'] = document.getElementById('proxyObfsPass').value.trim();
} else if (type === 'http' || type === 'socks5') {
proxy.username = document.getElementById('proxyUsername').value.trim();
proxy.password = document.getElementById('proxyPassword').value;
} else if (type === 'direct') {
delete proxy.server;
delete proxy.port;
}
if (!proxy.name) {
showToast('Имя прокси обязательно', 'error');
return;
}
const proxies = getProxies();
if (editProxyName) {
const idx = proxies.findIndex(p => p.name === editProxyName);
if (idx >= 0) {
const oldName = proxies[idx].name;
proxies[idx] = proxy;
if (proxy.name !== oldName) {
getGroups().forEach(g => {
if (g.proxies) {
g.proxies = g.proxies.map(pn => pn === oldName ? proxy.name : pn);
}
});
}
}
} else {
if (proxies.some(p => p.name === proxy.name)) {
showToast('Прокси с таким именем уже существует', 'error');
return;
}
proxies.push(proxy);
}
PS.config.proxies = proxies;
closeProxyModal();
renderAll();
saveFullConfig(false);
});
// Proxy list delegated events
document.getElementById('proxyList').addEventListener('click', e => {
const editBtn = e.target.closest('[data-edit-proxy]');
const delBtn = e.target.closest('[data-delete-proxy]');
if (editBtn) {
const name = editBtn.dataset.editProxy;
const proxy = getProxies().find(p => p.name === name);
if (proxy) openProxyModal(proxy);
} else if (delBtn) {
const name = delBtn.dataset.deleteProxy;
if (confirm(`Удалить прокси "${name}"?`)) {
const proxies = getProxies();
const idx = proxies.findIndex(p => p.name === name);
if (idx >= 0) {
proxies.splice(idx, 1);
PS.config.proxies = proxies;
getGroups().forEach(g => {
if (g.proxies) {
g.proxies = g.proxies.filter(pn => pn !== name);
}
});
renderAll();
saveFullConfig(false);
}
}
}
});
// ─── Group Modal ───
function updateGroupFields() {
const type = document.getElementById('groupType').value;
const urlField = document.getElementById('groupURLField');
const lbField = document.getElementById('groupLBStrategy');
if (type === 'url-test' || type === 'fallback' || type === 'load-balance') {
urlField.classList.remove('hidden');
} else {
urlField.classList.add('hidden');
}
if (type === 'load-balance') {
lbField.classList.remove('hidden');
} else {
lbField.classList.add('hidden');
}
}
document.getElementById('groupType').addEventListener('change', updateGroupFields);
let editGroupName = null;
function openGroupModal(group) {
editGroupName = group ? group.name : null;
document.getElementById('groupEditName').value = group ? group.name : '';
document.getElementById('groupModalTitle').textContent = group ? 'Редактировать группу' : 'Добавить группу';
document.getElementById('groupName').value = group ? group.name : '';
document.getElementById('groupType').value = group ? group.type : 'select';
document.getElementById('groupURL').value = group ? (group.url || 'https://www.gstatic.com/generate_204') : 'https://www.gstatic.com/generate_204';
document.getElementById('groupInterval').value = group ? (group.interval || 300) : 300;
document.getElementById('groupTolerance').value = group ? (group.tolerance || 50) : 50;
document.getElementById('groupIncludeAll').checked = group ? (group['include-all'] || false) : false;
document.getElementById('groupFilter').value = group ? (group.filter || '') : '';
document.getElementById('groupLBStrategy').value = group ? (group.strategy || 'round-robin') : 'round-robin';
const checkboxes = document.getElementById('groupProxyCheckboxes');
checkboxes.innerHTML = '';
const builtin = ['DIRECT'];
const allProxies = [...builtin, ...getProxies().map(p => p.name)];
const selected = group ? (group.proxies || []) : [];
allProxies.forEach(name => {
const label = document.createElement('label');
label.className = 'checkbox-label';
label.style.cssText = 'font-size:.85rem;padding:4px 0;display:flex;align-items:center;gap:6px';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = name;
if (selected.includes(name)) cb.checked = true;
label.appendChild(cb);
label.appendChild(document.createTextNode(name));
checkboxes.appendChild(label);
});
updateGroupFields();
document.getElementById('groupModal').classList.remove('hidden');
}
function closeGroupModal() {
document.getElementById('groupModal').classList.add('hidden');
}
document.getElementById('addGroupBtn').addEventListener('click', () => openGroupModal(null));
document.getElementById('closeGroupModal').addEventListener('click', closeGroupModal);
document.getElementById('cancelGroupBtn').addEventListener('click', closeGroupModal);
document.getElementById('groupModalBackdrop').addEventListener('click', closeGroupModal);
document.getElementById('saveGroupBtn').addEventListener('click', () => {
const selectedProxies = [];
document.querySelectorAll('#groupProxyCheckboxes input[type="checkbox"]:checked').forEach(cb => {
selectedProxies.push(cb.value);
});
const group = {
name: document.getElementById('groupName').value.trim(),
type: document.getElementById('groupType').value,
proxies: selectedProxies,
url: document.getElementById('groupURLField').classList.contains('hidden') ? '' : document.getElementById('groupURL').value.trim(),
interval: parseInt(document.getElementById('groupInterval').value) || 300,
tolerance: parseInt(document.getElementById('groupTolerance').value) || 0,
'include-all': document.getElementById('groupIncludeAll').checked,
filter: document.getElementById('groupFilter').value.trim(),
};
if (group.type === 'load-balance') {
group.strategy = document.getElementById('groupLBStrategy').value;
}
if (!group.name) {
showToast('Имя группы обязательно', 'error');
return;
}
const groups = getGroups();
if (editGroupName) {
const idx = groups.findIndex(g => g.name === editGroupName);
if (idx >= 0) {
groups[idx] = group;
}
} else {
if (groups.some(g => g.name === group.name)) {
showToast('Группа с таким именем уже существует', 'error');
return;
}
groups.push(group);
}
PS.config['proxy-groups'] = groups;
closeGroupModal();
renderAll();
saveFullConfig(false);
});
// Group list events
document.getElementById('groupList').addEventListener('click', e => {
const editBtn = e.target.closest('[data-edit-group]');
const delBtn = e.target.closest('[data-delete-group]');
if (editBtn) {
const name = editBtn.dataset.editGroup;
const group = getGroups().find(g => g.name === name);
if (group) openGroupModal(group);
} else if (delBtn) {
const name = delBtn.dataset.deleteGroup;
if (confirm(`Удалить группу "${name}"?`)) {
const groups = getGroups();
const idx = groups.findIndex(g => g.name === name);
if (idx >= 0) {
groups.splice(idx, 1);
PS.config['proxy-groups'] = groups;
const rules = getRules();
PS.config.rules = rules.map(r => {
if (r.endsWith(',' + name)) {
return r.replace(',' + name, ',DIRECT');
}
return r;
});
renderAll();
saveFullConfig(false);
}
}
}
});
// ─── Rule Modal ───
function updateRuleFields() {
const type = document.getElementById('ruleType').value;
const valueDiv = document.getElementById('ruleValueField');
const noResolveDiv = document.getElementById('ruleNoResolveDiv');
valueDiv.classList.remove('hidden');
noResolveDiv.classList.add('hidden');
if (type === 'MATCH') {
valueDiv.classList.add('hidden');
} else if (type === 'IP-CIDR' || type === 'IP-CIDR6' || type === 'SRC-IP-CIDR' || type === 'GEOIP') {
noResolveDiv.classList.remove('hidden');
}
const sel = document.getElementById('ruleProxy');
sel.innerHTML = '<option value="DIRECT">DIRECT</option><option value="REJECT">REJECT</option>';
getGroups().forEach(g => {
const opt = document.createElement('option');
opt.value = g.name;
opt.textContent = '📋 ' + g.name;
sel.appendChild(opt);
});
getProxies().forEach(p => {
if (p.type !== 'direct') {
const opt = document.createElement('option');
opt.value = p.name;
opt.textContent = '🔗 ' + p.name;
sel.appendChild(opt);
}
});
}
document.getElementById('ruleType').addEventListener('change', updateRuleFields);
function openRuleModal() {
document.getElementById('ruleModalTitle').textContent = 'Добавить правило';
document.getElementById('ruleType').value = 'DOMAIN-SUFFIX';
document.getElementById('ruleValue').value = '';
document.getElementById('ruleProxy').value = 'DIRECT';
document.getElementById('ruleNoResolve').checked = true;
updateRuleFields();
document.getElementById('ruleModal').classList.remove('hidden');
}
function closeRuleModal() {
document.getElementById('ruleModal').classList.add('hidden');
}
document.getElementById('addRuleBtn').addEventListener('click', openRuleModal);
document.getElementById('addBlockBtn').addEventListener('click', () => {
openRuleModal();
document.getElementById('ruleType').value = 'DOMAIN-KEYWORD';
document.getElementById('ruleProxy').value = 'REJECT';
updateRuleFields();
});
document.getElementById('closeRuleModal').addEventListener('click', closeRuleModal);
document.getElementById('cancelRuleBtn').addEventListener('click', closeRuleModal);
document.getElementById('ruleModalBackdrop').addEventListener('click', closeRuleModal);
document.getElementById('saveRuleBtn').addEventListener('click', () => {
const type = document.getElementById('ruleType').value;
const value = document.getElementById('ruleValue').value.trim();
const proxy = document.getElementById('ruleProxy').value;
const noResolve = document.getElementById('ruleNoResolve').checked;
if (type !== 'MATCH' && !value) {
showToast('Введите значение правила', 'error');
return;
}
let rule;
if (type === 'MATCH') {
rule = `MATCH,${proxy}`;
} else {
rule = `${type},${value},${proxy}`;
const needsNoResolve = ['IP-CIDR', 'IP-CIDR6', 'SRC-IP-CIDR', 'GEOIP'].includes(type);
if (needsNoResolve && noResolve) {
rule += ',no-resolve';
}
}
const rules = getRules();
rules.push(rule);
PS.config.rules = rules;
closeRuleModal();
renderAll();
saveFullConfig(false);
});
// Rule delete delegated events
document.getElementById('rulesList').addEventListener('click', e => {
const delBtn = e.target.closest('[data-delete-rule]');
if (delBtn) {
const idx = parseInt(delBtn.dataset.deleteRule);
if (isNaN(idx)) return;
const rules = getRules();
rules.splice(idx, 1);
PS.config.rules = rules;
renderAll();
saveFullConfig(false);
}
});
// ─── Core upload ───
document.getElementById('uploadCoreForm').addEventListener('submit', async e => {
e.preventDefault();
const fileInput = document.getElementById('coreFile');
if (!fileInput.files[0]) {
showToast('Выберите файл', 'error');
return;
}
const fd = new FormData();
fd.append('core', fileInput.files[0]);
try {
const result = await fetch('/api/mihomo/upload-core', { method: 'POST', body: fd });
const json = await result.json();
if (!json.success) {
throw new Error(json.error || 'Upload failed');
}
showToast('Ядро загружено: ' + (json.data.arch || json.data.path), 'success');
await loadStatus();
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
});
// ─── YAML editor ───
document.getElementById('yamlLoadBtn').addEventListener('click', async () => {
try {
const res = await fetch('/api/mihomo/config.yaml');
if (res.status === 404) {
document.getElementById('yamlEditor').value = '# Config not found.';
return;
}
const text = await res.text();
document.getElementById('yamlEditor').value = text;
showToast('Конфиг загружен', 'info');
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
});
document.getElementById('yamlSaveBtn').addEventListener('click', async () => {
const content = document.getElementById('yamlEditor').value;
try {
const res = await fetch('/api/mihomo/config.yaml', {
method: 'PUT',
headers: { 'Content-Type': 'text/yaml' },
body: content,
});
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Save failed');
showToast('Конфиг сохранён', 'success');
await loadConfig();
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
});
// ─── Logs polling ───
let logPollTimer = null;
let lastLogCount = 0;
async function fetchLogs() {
try {
const lines = await get('/api/mihomo/logs');
const el = document.getElementById('logOutput');
if (lines.length !== lastLogCount) {
lastLogCount = lines.length;
el.textContent = lines.join('\n');
el.scrollTop = el.scrollHeight;
}
} catch (e) { /* ignore */ }
}
function startLogPoll() {
if (logPollTimer) return;
fetchLogs();
logPollTimer = setInterval(fetchLogs, 500);
}
function stopLogPoll() {
if (logPollTimer) {
clearInterval(logPollTimer);
logPollTimer = null;
}
}
document.querySelectorAll('.ptab').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.tab === 'logs') {
lastLogCount = 0;
startLogPoll();
} else {
stopLogPoll();
}
});
});
document.getElementById('clearLogsBtn').addEventListener('click', () => {
document.getElementById('logOutput').textContent = '';
lastLogCount = 0;
});
// ─── Init ───
(async () => {
try { await loadStatus(); } catch(e) { console.error('status', e); }
try { await loadConfig(); } catch(e) { console.error('config', e); }
document.getElementById('loading').classList.add('hidden');
})();