957 lines
35 KiB
JavaScript
957 lines
35 KiB
JavaScript
'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');
|
||
})(); |