first commit

This commit is contained in:
2026-04-13 09:46:02 +03:00
commit 7eaa9750b0
33 changed files with 7357 additions and 0 deletions

957
public/proxy.js Normal file
View File

@@ -0,0 +1,957 @@
'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');
})();