first commit
This commit is contained in:
388
public/app.js
Normal file
388
public/app.js
Normal file
@@ -0,0 +1,388 @@
|
||||
'use strict';
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const state = {
|
||||
interfaces: [], // latest data from /api/interfaces
|
||||
pending: [], // interface names with pending config
|
||||
configModal: null, // name of interface being configured
|
||||
nat: null, // {installed, interfaces} from /api/nat
|
||||
};
|
||||
|
||||
// ── API helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
};
|
||||
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 del = (path) => api('DELETE', path);
|
||||
|
||||
// ── Format helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (n === undefined || n === null) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
let v = Number(n);
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function stateClass(s) {
|
||||
if (s === 'up') return 'up';
|
||||
if (s === 'down') return 'down';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function modeLabel(m) {
|
||||
return { dhcp: 'DHCP', static: 'Static', loopback: 'Loopback', manual: 'Manual' }[m] || (m || '?');
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderAll() {
|
||||
const grid = document.getElementById('ifaceGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
state.interfaces.forEach(iface => {
|
||||
grid.appendChild(buildCard(iface));
|
||||
});
|
||||
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
grid.classList.remove('hidden');
|
||||
|
||||
renderPendingBanner();
|
||||
}
|
||||
|
||||
function buildCard(iface) {
|
||||
const hasPending = state.pending.includes(iface.name);
|
||||
const sc = stateClass(iface.state);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'iface-card' + (hasPending ? ' has-pending' : '');
|
||||
card.dataset.name = iface.name;
|
||||
|
||||
const ipv6lines = (iface.ipv6 || []).map(a =>
|
||||
`<div class="info-row"><span class="info-label">IPv6</span><span class="info-val">${a}</span></div>`
|
||||
).join('');
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-header">
|
||||
<div class="card-name">
|
||||
<span class="state-dot ${sc}"></span>
|
||||
<span>${iface.name}</span>
|
||||
${hasPending ? '<span class="pending-badge">несохранено</span>' : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<span class="mode-badge ${iface.mode || 'unknown'}">${modeLabel(iface.mode)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Статус</span>
|
||||
<span class="info-val">${iface.state || 'unknown'}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">IPv4</span>
|
||||
<span class="info-val">${iface.ipv4
|
||||
? iface.ipv4 + (iface.ipv4_mask ? ' / ' + iface.ipv4_mask : '')
|
||||
: '<span class="none">—</span>'}</span>
|
||||
</div>
|
||||
${ipv6lines || `<div class="info-row"><span class="info-label">IPv6</span><span class="info-val none">—</span></div>`}
|
||||
<div class="info-row">
|
||||
<span class="info-label">Шлюз</span>
|
||||
<span class="info-val">${iface.gateway || '<span class="none">—</span>'}</span>
|
||||
</div>
|
||||
|
||||
<div class="traffic-row">
|
||||
<div class="traffic-item">
|
||||
<span class="traffic-label">RX</span>
|
||||
<span class="traffic-val">${fmtBytes(iface.rx_bytes)}</span>
|
||||
</div>
|
||||
<div class="traffic-item">
|
||||
<span class="traffic-label">TX</span>
|
||||
<span class="traffic-val">${fmtBytes(iface.tx_bytes)}</span>
|
||||
</div>
|
||||
<div class="traffic-item">
|
||||
<span class="traffic-label">Пакеты</span>
|
||||
<span class="traffic-val">${iface.rx_packets ?? 0} / ${iface.tx_packets ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-success btn-sm" data-action="up" data-iface="${iface.name}">ON</button>
|
||||
<button class="btn btn-danger btn-sm" data-action="down" data-iface="${iface.name}">OFF</button>
|
||||
<button class="btn btn-ghost btn-sm" data-action="restart" data-iface="${iface.name}">RESTART</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="config" data-iface="${iface.name}" style="margin-left:auto">CONFIG</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderPendingBanner() {
|
||||
const banner = document.getElementById('pendingBanner');
|
||||
const list = document.getElementById('pendingList');
|
||||
if (state.pending.length > 0) {
|
||||
list.textContent = state.pending.join(', ');
|
||||
banner.classList.remove('hidden');
|
||||
} else {
|
||||
banner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data loading ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
const [ifaces, pending] = await Promise.all([
|
||||
get('/api/interfaces'),
|
||||
get('/api/pending'),
|
||||
]);
|
||||
state.interfaces = ifaces || [];
|
||||
state.pending = pending || [];
|
||||
renderAll();
|
||||
} catch (e) {
|
||||
showToast('Ошибка загрузки: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Interface actions ─────────────────────────────────────────────────────────
|
||||
|
||||
async function doAction(name, action) {
|
||||
const btn = document.querySelector(`[data-action="${action}"][data-iface="${name}"]`);
|
||||
if (btn) { btn.disabled = true; btn.textContent = '...'; }
|
||||
|
||||
try {
|
||||
await post(`/api/interfaces/${name}/${action}`);
|
||||
showToast(`${name}: ${action} выполнено`, 'success');
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
showToast(`${name} ${action}: ${e.message}`, 'error');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config modal ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function openConfig(name) {
|
||||
state.configModal = name;
|
||||
document.getElementById('modalTitle').textContent = `Настройка: ${name}`;
|
||||
|
||||
try {
|
||||
const [{ config, pending }, natData] = await Promise.all([
|
||||
get(`/api/config/${name}`),
|
||||
get('/api/nat').catch(() => null),
|
||||
]);
|
||||
if (natData) state.nat = natData;
|
||||
fillForm(config, pending, name);
|
||||
document.getElementById('modal').classList.remove('hidden');
|
||||
} catch (e) {
|
||||
showToast('Ошибка загрузки конфига: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function fillForm(cfg, pending, name) {
|
||||
document.getElementById('cfgAuto').checked = !!cfg.auto;
|
||||
document.getElementById('cfgAddress').value = cfg.address || '';
|
||||
document.getElementById('cfgNetmask').value = cfg.netmask || '';
|
||||
document.getElementById('cfgGateway').value = cfg.gateway || '';
|
||||
document.getElementById('cfgDNS').value = (cfg.dns || []).join(' ');
|
||||
|
||||
const mode = cfg.mode === 'static' ? 'static' : 'dhcp';
|
||||
setMode(mode);
|
||||
|
||||
// Mark pending visually
|
||||
const title = document.getElementById('modalTitle');
|
||||
if (pending) {
|
||||
title.textContent = `Настройка: ${state.configModal} (несохранённые изменения)`;
|
||||
}
|
||||
|
||||
// NAT section — show for all non-loopback interfaces
|
||||
const natSection = document.getElementById('natSection');
|
||||
const natNotInstalled = document.getElementById('natNotInstalled');
|
||||
const cfgNAT = document.getElementById('cfgNAT');
|
||||
|
||||
if (cfg.mode === 'loopback' || name === 'lo') {
|
||||
natSection.classList.add('hidden');
|
||||
} else {
|
||||
natSection.classList.remove('hidden');
|
||||
const natInstalled = state.nat?.installed !== false;
|
||||
cfgNAT.disabled = !natInstalled;
|
||||
natNotInstalled.classList.toggle('hidden', natInstalled);
|
||||
cfgNAT.checked = !!(state.nat?.interfaces || []).includes(name);
|
||||
}
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
document.querySelectorAll('.seg-btn').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.mode === mode);
|
||||
});
|
||||
document.getElementById('staticFields').classList.toggle('hidden', mode !== 'static');
|
||||
}
|
||||
|
||||
function currentMode() {
|
||||
return document.querySelector('.seg-btn.active')?.dataset.mode ?? 'dhcp';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modal').classList.add('hidden');
|
||||
document.getElementById('configForm').reset();
|
||||
state.configModal = null;
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const name = state.configModal;
|
||||
if (!name) return;
|
||||
|
||||
const mode = currentMode();
|
||||
const cfg = {
|
||||
name,
|
||||
auto: document.getElementById('cfgAuto').checked,
|
||||
mode,
|
||||
address: document.getElementById('cfgAddress').value.trim(),
|
||||
netmask: document.getElementById('cfgNetmask').value.trim(),
|
||||
gateway: document.getElementById('cfgGateway').value.trim(),
|
||||
dns: document.getElementById('cfgDNS').value.trim().split(/\s+/).filter(Boolean),
|
||||
extra: {},
|
||||
};
|
||||
|
||||
// Basic validation for static
|
||||
if (mode === 'static' && !cfg.address) {
|
||||
showToast('Укажите IP-адрес', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await post(`/api/config/${name}`, cfg);
|
||||
|
||||
// Save NAT setting if section is visible
|
||||
const natSection = document.getElementById('natSection');
|
||||
if (!natSection.classList.contains('hidden') && state.nat?.installed !== false) {
|
||||
const natEnabled = document.getElementById('cfgNAT').checked;
|
||||
const current = state.nat?.interfaces || [];
|
||||
const updated = natEnabled
|
||||
? [...new Set([...current, name])]
|
||||
: current.filter(i => i !== name);
|
||||
await post('/api/nat', { interfaces: updated });
|
||||
if (state.nat) state.nat.interfaces = updated;
|
||||
}
|
||||
|
||||
showToast(`${name}: настройки сохранены (ожидают применения)`, 'info');
|
||||
closeModal();
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
showToast('Ошибка сохранения: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Apply / discard ───────────────────────────────────────────────────────────
|
||||
|
||||
async function applyAll() {
|
||||
const btn = document.getElementById('applyBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Применяю...';
|
||||
try {
|
||||
await post('/api/apply');
|
||||
showToast('Настройки применены', 'success');
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
showToast('Ошибка применения: ' + e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Применить';
|
||||
}
|
||||
}
|
||||
|
||||
async function discardAll() {
|
||||
for (const name of [...state.pending]) {
|
||||
try { await del(`/api/config/${name}`); } catch (_) {}
|
||||
}
|
||||
state.pending = [];
|
||||
renderPendingBanner();
|
||||
renderAll();
|
||||
showToast('Изменения отменены', 'info');
|
||||
await loadAll();
|
||||
}
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let toastTimer;
|
||||
function showToast(msg, type = 'info') {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = `toast ${type}`;
|
||||
t.classList.remove('hidden');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
|
||||
}
|
||||
|
||||
// ── Event wiring ──────────────────────────────────────────────────────────────
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', loadAll);
|
||||
|
||||
document.getElementById('applyBtn').addEventListener('click', applyAll);
|
||||
document.getElementById('discardAllBtn').addEventListener('click', discardAll);
|
||||
|
||||
// Card action buttons (delegated)
|
||||
document.getElementById('ifaceGrid').addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
const { action, iface } = btn.dataset;
|
||||
if (action === 'config') {
|
||||
openConfig(iface);
|
||||
} else {
|
||||
doAction(iface, action);
|
||||
}
|
||||
});
|
||||
|
||||
// Modal close
|
||||
document.getElementById('closeModal').addEventListener('click', closeModal);
|
||||
document.getElementById('cancelConfigBtn').addEventListener('click', closeModal);
|
||||
document.getElementById('modalBackdrop').addEventListener('click', closeModal);
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
|
||||
// Mode switcher
|
||||
document.getElementById('modeSwitch').addEventListener('click', e => {
|
||||
const btn = e.target.closest('.seg-btn');
|
||||
if (btn) setMode(btn.dataset.mode);
|
||||
});
|
||||
|
||||
// Save config
|
||||
document.getElementById('saveConfigBtn').addEventListener('click', saveConfig);
|
||||
document.getElementById('configForm').addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
saveConfig();
|
||||
});
|
||||
|
||||
// Auto-refresh every 10 seconds
|
||||
setInterval(loadAll, 10000);
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
(async () => {
|
||||
// Try to get hostname
|
||||
try {
|
||||
const res = await fetch('/api/interfaces');
|
||||
// hostname from Location header or just skip
|
||||
} catch (_) {}
|
||||
|
||||
await loadAll();
|
||||
})();
|
||||
161
public/clients.html
Normal file
161
public/clients.html
Normal file
@@ -0,0 +1,161 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Клиенты — AlpineRouter</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<h1>AlpineRouter</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="btn btn-ghost" id="refreshBtn" title="Обновить">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
</svg>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tab-nav">
|
||||
<a href="/" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
Интерфейсы
|
||||
</a>
|
||||
<a href="/dhcp.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
DHCP сервер
|
||||
</a>
|
||||
<a href="/clients.html" class="tab-link active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75M21 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
</svg>
|
||||
Клиенты
|
||||
</a>
|
||||
<a href="/proxy.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
Прокси
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<main class="clients-main">
|
||||
|
||||
<div class="clients-toolbar">
|
||||
<div class="clients-summary" id="clientsSummary"></div>
|
||||
<input type="search" id="clientsSearch" class="clients-search" placeholder="Поиск по хосту, IP, MAC…">
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Загрузка...</span>
|
||||
</div>
|
||||
|
||||
<div id="emptyState" class="empty-state hidden">
|
||||
Нет подключённых устройств
|
||||
</div>
|
||||
|
||||
<div id="clientsTableWrap" class="clients-table-wrap hidden">
|
||||
<table class="clients-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Хост</th>
|
||||
<th>IP-адрес</th>
|
||||
<th>MAC-адрес</th>
|
||||
<th>Интерфейс</th>
|
||||
<th>Тип</th>
|
||||
<th class="col-tx">↑ Отправлено</th>
|
||||
<th class="col-rx">↓ Получено</th>
|
||||
<th>Активность</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="clientsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<div id="clientModal" class="modal hidden">
|
||||
<div class="modal-backdrop" id="modalBackdrop"></div>
|
||||
<div class="modal-box">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">Устройство</h2>
|
||||
<button class="btn-icon" id="modalClose" title="Закрыть">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="clientForm" autocomplete="off">
|
||||
<div class="form-row">
|
||||
<label>Имя устройства</label>
|
||||
<input type="text" id="modalHostname" placeholder="Например: Ноутбук Анны">
|
||||
</div>
|
||||
|
||||
<hr class="form-divider">
|
||||
|
||||
<div class="form-row">
|
||||
<label>IP-адрес</label>
|
||||
<div class="ip-info-row">
|
||||
<span class="form-val mono" id="modalIP">—</span>
|
||||
<span class="ip-current-label" id="modalIPCurrent"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="modalStaticIP">Фиксированный IP</label>
|
||||
<input type="text" id="modalStaticIP" placeholder="Например: 192.168.1.100">
|
||||
<span class="form-hint" id="modalStaticHint">Оставьте пустым для динамического назначения через DHCP</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>MAC-адрес</label>
|
||||
<span class="form-val mono" id="modalMAC">—</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Интерфейс</label>
|
||||
<span class="form-val" id="modalIface">—</span>
|
||||
</div>
|
||||
|
||||
<hr class="form-divider">
|
||||
|
||||
<div class="form-row" style="flex-direction:row; align-items:center; justify-content:space-between;">
|
||||
<div>
|
||||
<div style="font-weight:600;">Доступ в интернет</div>
|
||||
<div style="font-size:.8rem;color:var(--muted);margin-top:2px;" id="modalBlockHint">Отключите, чтобы запретить устройству выход в интернет</div>
|
||||
</div>
|
||||
<label class="toggle-label" id="modalBlockToggle">
|
||||
<input type="checkbox" id="modalBlocked">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer" style="padding:18px 0 0;">
|
||||
<button type="button" class="btn btn-ghost" id="modalCancel">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary" id="modalSave">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<script src="clients.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
314
public/clients.js
Normal file
314
public/clients.js
Normal file
@@ -0,0 +1,314 @@
|
||||
'use strict';
|
||||
|
||||
let allClients = [];
|
||||
let searchQuery = '';
|
||||
let editingClient = null;
|
||||
|
||||
async function loadClients() {
|
||||
try {
|
||||
const res = await fetch('/api/clients');
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
allClients = json.data || [];
|
||||
render();
|
||||
} catch (e) {
|
||||
showToast('Ошибка загрузки: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const ONLINE_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
function isOnline(c) {
|
||||
if (c.last_active) {
|
||||
return (Date.now() - c.last_active * 1000) < ONLINE_WINDOW_MS;
|
||||
}
|
||||
return c.online;
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '—';
|
||||
const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'];
|
||||
let i = 0, v = n;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function fmtLease(expiresUnix) {
|
||||
if (!expiresUnix) return null;
|
||||
const secs = expiresUnix - Math.floor(Date.now() / 1000);
|
||||
if (secs <= 0) return { text: 'истекла', cls: 'lease-expired' };
|
||||
if (secs < 3600) return { text: `${Math.floor(secs / 60)} мин`, cls: 'lease-soon' };
|
||||
if (secs < 86400) return { text: `${Math.floor(secs / 3600)} ч`, cls: '' };
|
||||
return { text: `${Math.floor(secs / 86400)} д ${Math.floor((secs % 86400) / 3600)} ч`, cls: '' };
|
||||
}
|
||||
|
||||
function fmtLastActive(c) {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (c.last_active) {
|
||||
const ago = nowSec - c.last_active;
|
||||
if (ago < 10) return { text: 'только что', cls: 'active-now' };
|
||||
if (ago < 60) return { text: `${ago} с назад`, cls: 'active-now' };
|
||||
if (ago < 3600) return { text: `${Math.floor(ago / 60)} мин назад`, cls: ago < ONLINE_WINDOW_MS / 1000 ? 'active-now' : '' };
|
||||
if (ago < 86400) return { text: `${Math.floor(ago / 3600)} ч назад`, cls: '' };
|
||||
return { text: `${Math.floor(ago / 86400)} д назад`, cls: '' };
|
||||
}
|
||||
|
||||
if (c.is_dhcp && c.lease_expires) {
|
||||
const lease = fmtLease(c.lease_expires);
|
||||
return lease
|
||||
? { text: `аренда: ${lease.text}`, cls: lease.cls }
|
||||
: { text: '—', cls: '' };
|
||||
}
|
||||
|
||||
return { text: '—', cls: '' };
|
||||
}
|
||||
|
||||
function matchesSearch(c, q) {
|
||||
if (!q) return true;
|
||||
const l = q.toLowerCase();
|
||||
return (
|
||||
(c.hostname || '').toLowerCase().includes(l) ||
|
||||
(c.ip || '').includes(l) ||
|
||||
(c.mac || '').toLowerCase().includes(l) ||
|
||||
(c.interface || '').toLowerCase().includes(l)
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const loading = document.getElementById('loading');
|
||||
const wrap = document.getElementById('clientsTableWrap');
|
||||
const empty = document.getElementById('emptyState');
|
||||
const body = document.getElementById('clientsBody');
|
||||
const summary = document.getElementById('clientsSummary');
|
||||
|
||||
loading.classList.add('hidden');
|
||||
|
||||
const onlineCount = allClients.filter(c => isOnline(c)).length;
|
||||
const dhcpCount = allClients.filter(c => c.is_dhcp).length;
|
||||
const blockedCount = allClients.filter(c => c.blocked).length;
|
||||
|
||||
summary.innerHTML =
|
||||
`<span class="cl-stat"><span class="state-dot up"></span>${onlineCount} онлайн</span>` +
|
||||
`<span class="cl-stat cl-stat--muted">${allClients.length} всего</span>` +
|
||||
`<span class="cl-stat cl-stat--muted">${dhcpCount} по DHCP</span>` +
|
||||
(blockedCount > 0 ? `<span class="cl-stat cl-stat--blocked">${blockedCount} заблокировано</span>` : '');
|
||||
|
||||
const filtered = allClients.filter(c => matchesSearch(c, searchQuery));
|
||||
|
||||
if (filtered.length === 0) {
|
||||
wrap.classList.add('hidden');
|
||||
empty.classList.remove('hidden');
|
||||
empty.textContent = allClients.length === 0
|
||||
? 'Нет подключённых устройств'
|
||||
: `Ничего не найдено по запросу «${searchQuery}»`;
|
||||
return;
|
||||
}
|
||||
|
||||
empty.classList.add('hidden');
|
||||
wrap.classList.remove('hidden');
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const ao = isOnline(a), bo = isOnline(b);
|
||||
if (ao !== bo) return ao ? -1 : 1;
|
||||
return ipToNum(a.ip) - ipToNum(b.ip);
|
||||
});
|
||||
|
||||
body.innerHTML = '';
|
||||
sorted.forEach(c => body.appendChild(buildRow(c)));
|
||||
}
|
||||
|
||||
function buildRow(c) {
|
||||
const online = isOnline(c);
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'client-row';
|
||||
if (!online) tr.classList.add('row-offline');
|
||||
if (c.blocked) tr.classList.add('row-blocked');
|
||||
|
||||
const activity = fmtLastActive(c);
|
||||
|
||||
const typeCell = c.is_dhcp
|
||||
? '<span class="client-badge dhcp">DHCP</span>'
|
||||
: '<span class="client-badge arp">ARP</span>';
|
||||
|
||||
const hostname = c.hostname
|
||||
? `<span class="client-host">${escHtml(c.hostname)}</span>`
|
||||
: '<span class="none">—</span>';
|
||||
|
||||
const txHtml = c.tx_bytes
|
||||
? `<span class="traffic-num">${fmtBytes(c.tx_bytes)}</span>`
|
||||
: '<span class="none">—</span>';
|
||||
|
||||
const rxHtml = c.rx_bytes
|
||||
? `<span class="traffic-num">${fmtBytes(c.rx_bytes)}</span>`
|
||||
: '<span class="none">—</span>';
|
||||
|
||||
const actHtml = activity.text !== '—'
|
||||
? `<span class="activity-val ${activity.cls}">${activity.text}</span>`
|
||||
: '<span class="none">—</span>';
|
||||
|
||||
const ipDisplay = c.static_ip
|
||||
? `<span class="mono">${escHtml(c.static_ip)}</span> <span class="client-badge static-badge">фикс.</span>`
|
||||
: `<span class="mono">${escHtml(c.ip)}</span>`;
|
||||
|
||||
const blockedBadge = c.blocked
|
||||
? ' <span class="client-badge blocked-badge">ЗАБЛОКИРОВАН</span>'
|
||||
: '';
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="col-status">
|
||||
<span class="state-dot ${online ? 'up' : 'down'}"
|
||||
title="${online ? 'онлайн' : 'офлайн'}"></span>
|
||||
</td>
|
||||
<td class="col-host">${hostname}${blockedBadge}</td>
|
||||
<td class="col-ip">${ipDisplay}</td>
|
||||
<td class="col-mac"><span class="mono muted">${escHtml(c.mac || '—')}</span></td>
|
||||
<td class="col-iface">${escHtml(c.interface || '—')}</td>
|
||||
<td class="col-type">${typeCell}</td>
|
||||
<td class="col-tx">${txHtml}</td>
|
||||
<td class="col-rx">${rxHtml}</td>
|
||||
<td class="col-activity">${actHtml}</td>
|
||||
`;
|
||||
|
||||
tr.addEventListener('click', () => openModal(c));
|
||||
return tr;
|
||||
}
|
||||
|
||||
function openModal(c) {
|
||||
editingClient = { ...c };
|
||||
const modal = document.getElementById('clientModal');
|
||||
|
||||
document.getElementById('modalTitle').textContent = c.hostname || c.ip || 'Устройство';
|
||||
document.getElementById('modalHostname').value = c.hostname || '';
|
||||
document.getElementById('modalHostname').placeholder = c.hostname ? '' : (c.mac || c.ip);
|
||||
|
||||
const currentIP = c.static_ip || c.ip || '—';
|
||||
document.getElementById('modalIP').textContent = currentIP;
|
||||
const currentLabel = document.getElementById('modalIPCurrent');
|
||||
if (c.static_ip) {
|
||||
currentLabel.textContent = '(фиксированный)';
|
||||
currentLabel.style.display = '';
|
||||
} else if (c.ip) {
|
||||
currentLabel.textContent = '(DHCP: ' + c.ip + ')';
|
||||
currentLabel.style.display = '';
|
||||
} else {
|
||||
currentLabel.textContent = '';
|
||||
currentLabel.style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('modalStaticIP').value = c.static_ip || '';
|
||||
document.getElementById('modalMAC').textContent = c.mac || '—';
|
||||
document.getElementById('modalIface').textContent = c.interface || '—';
|
||||
|
||||
const blocked = document.getElementById('modalBlocked');
|
||||
blocked.checked = !c.blocked;
|
||||
updateBlockedToggle(c.blocked);
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
document.getElementById('modalHostname').focus();
|
||||
}
|
||||
|
||||
function updateBlockedToggle(isBlocked) {
|
||||
const hint = document.getElementById('modalBlockHint');
|
||||
const toggle = document.getElementById('modalBlockToggle');
|
||||
const toggleContainer = document.getElementById('modalBlocked');
|
||||
|
||||
if (isBlocked) {
|
||||
hint.textContent = 'Доступ в интернет заблокирован';
|
||||
hint.style.color = 'var(--danger)';
|
||||
toggleContainer.checked = false;
|
||||
toggle.classList.add('toggle-blocked');
|
||||
} else {
|
||||
hint.textContent = 'Отключите, чтобы запретить устройству выход в интернет';
|
||||
hint.style.color = '';
|
||||
toggleContainer.checked = true;
|
||||
toggle.classList.remove('toggle-blocked');
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('clientModal').classList.add('hidden');
|
||||
editingClient = null;
|
||||
}
|
||||
|
||||
async function saveClient() {
|
||||
if (!editingClient) return;
|
||||
|
||||
const mac = editingClient.mac;
|
||||
if (!mac) {
|
||||
showToast('У устройства нет MAC-адреса', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const hostname = document.getElementById('modalHostname').value.trim();
|
||||
const isBlocked = !document.getElementById('modalBlocked').checked;
|
||||
const staticIP = document.getElementById('modalStaticIP').value.trim();
|
||||
|
||||
const btn = document.getElementById('modalSave');
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/clients/update/' + encodeURIComponent(mac), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hostname, blocked: isBlocked, static_ip: staticIP })
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
showToast('Настройки сохранены', 'success');
|
||||
closeModal();
|
||||
loadClients();
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function ipToNum(ip) {
|
||||
return (ip || '').split('.').reduce((acc, o) => acc * 256 + (+o || 0), 0);
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
let toastTimer;
|
||||
function showToast(msg, type = 'info') {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = `toast ${type}`;
|
||||
t.classList.remove('hidden');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
|
||||
}
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', loadClients);
|
||||
|
||||
document.getElementById('clientsSearch').addEventListener('input', e => {
|
||||
searchQuery = e.target.value.trim();
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById('modalBackdrop').addEventListener('click', closeModal);
|
||||
document.getElementById('modalClose').addEventListener('click', closeModal);
|
||||
document.getElementById('modalCancel').addEventListener('click', closeModal);
|
||||
document.getElementById('clientForm').addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
saveClient();
|
||||
});
|
||||
|
||||
document.getElementById('modalBlocked').addEventListener('change', () => {
|
||||
if (!editingClient) return;
|
||||
const isBlocked = !document.getElementById('modalBlocked').checked;
|
||||
updateBlockedToggle(isBlocked);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
|
||||
setInterval(loadClients, 10000);
|
||||
|
||||
loadClients();
|
||||
179
public/dhcp.html
Normal file
179
public/dhcp.html
Normal file
@@ -0,0 +1,179 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DHCP сервер — AlpineRouter</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<h1>AlpineRouter</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="btn btn-ghost" id="refreshBtn" title="Обновить">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
</svg>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tab-nav">
|
||||
<a href="/" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
Интерфейсы
|
||||
</a>
|
||||
<a href="/dhcp.html" class="tab-link active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
DHCP сервер
|
||||
</a>
|
||||
<a href="/clients.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75M21 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
</svg>
|
||||
Клиенты
|
||||
</a>
|
||||
<a href="/proxy.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
Прокси
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<main class="dhcp-main">
|
||||
|
||||
<!-- Not-installed warning -->
|
||||
<div id="notInstalledBanner" class="alert alert-error hidden">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong>Пакет dnsmasq не установлен.</strong>
|
||||
Для работы DHCP-сервера выполните на роутере:
|
||||
<code>apk add dnsmasq</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service status bar -->
|
||||
<div class="dhcp-status-bar" id="statusBar">
|
||||
<div class="status-info">
|
||||
<span class="status-label">DHCP сервер (dhcpd)</span>
|
||||
<span id="svcStatus" class="svc-badge stopped">остановлен</span>
|
||||
</div>
|
||||
<div class="status-actions">
|
||||
<label class="toggle-label" id="enableToggleWrap" title="Включить/выключить DHCP сервер">
|
||||
<span>Включить сервер</span>
|
||||
<input type="checkbox" id="enableToggle">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<button class="btn btn-primary" id="applyBtn" disabled>Применить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pools section -->
|
||||
<section class="dhcp-section">
|
||||
<div class="section-header">
|
||||
<h2>Пулы адресов</h2>
|
||||
<p class="section-desc">
|
||||
Каждый пул привязан к одному интерфейсу. Интерфейсы со шлюзом (WAN/uplink)
|
||||
недоступны для DHCP — они помечены <span class="tag-gw">WAN</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="poolsLoading" class="loading" style="height:120px">
|
||||
<div class="spinner"></div>
|
||||
<span>Загрузка...</span>
|
||||
</div>
|
||||
|
||||
<div id="poolsGrid" class="pools-grid hidden"></div>
|
||||
|
||||
<div id="noIfaces" class="empty-state hidden">
|
||||
Нет подходящих интерфейсов (с IP-адресом и без шлюза)
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Pool edit modal -->
|
||||
<div id="poolModal" class="modal hidden" role="dialog" aria-modal="true">
|
||||
<div class="modal-backdrop" id="poolModalBackdrop"></div>
|
||||
<div class="modal-box">
|
||||
<div class="modal-header">
|
||||
<h2 id="poolModalTitle">Настройка пула</h2>
|
||||
<button class="btn-icon" id="closePoolModal" title="Закрыть">✕</button>
|
||||
</div>
|
||||
|
||||
<form id="poolForm" autocomplete="off">
|
||||
<input type="hidden" id="pIface">
|
||||
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="pEnabled">
|
||||
<span>Пул активен</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Подсеть</label>
|
||||
<div class="inline-pair">
|
||||
<input type="text" id="pSubnet" placeholder="172.16.54.0" class="font-mono">
|
||||
<span class="pair-sep">маска</span>
|
||||
<input type="text" id="pNetmask" placeholder="255.255.255.0" class="font-mono">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Диапазон адресов</label>
|
||||
<div class="inline-pair">
|
||||
<input type="text" id="pRangeStart" placeholder="172.16.54.100" class="font-mono">
|
||||
<span class="pair-sep">—</span>
|
||||
<input type="text" id="pRangeEnd" placeholder="172.16.54.200" class="font-mono">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="pRouter">Шлюз (option routers)</label>
|
||||
<input type="text" id="pRouter" placeholder="172.16.54.1" class="font-mono">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="pDNS">DNS-серверы (через пробел)</label>
|
||||
<input type="text" id="pDNS" placeholder="8.8.8.8 8.8.4.4" class="font-mono">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="pLease">Время аренды (секунды)</label>
|
||||
<input type="number" id="pLease" placeholder="86400" min="60" max="604800">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" id="cancelPoolBtn">Отмена</button>
|
||||
<button class="btn btn-primary" id="savePoolBtn">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<script src="dhcp.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
344
public/dhcp.js
Normal file
344
public/dhcp.js
Normal file
@@ -0,0 +1,344 @@
|
||||
'use strict';
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const state = {
|
||||
installed: false,
|
||||
running: false,
|
||||
config: { enabled: false, pools: [] },
|
||||
ifaces: [], // all non-lo interfaces from the API
|
||||
dirty: false,
|
||||
editIface: null, // interface name being edited in modal
|
||||
};
|
||||
|
||||
// ── API ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function api(method, path, body) {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtLease(sec) {
|
||||
if (!sec || sec <= 0) return '24 ч';
|
||||
if (sec < 3600) return `${sec} с`;
|
||||
if (sec < 86400) return `${(sec/3600).toFixed(0)} ч`;
|
||||
return `${(sec/86400).toFixed(0)} д`;
|
||||
}
|
||||
|
||||
function poolForIface(name) {
|
||||
return state.config.pools.find(p => p.interface === name) || null;
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function render() {
|
||||
// Not-installed banner
|
||||
document.getElementById('notInstalledBanner').classList.toggle('hidden', state.installed);
|
||||
|
||||
// Status bar
|
||||
const svcBadge = document.getElementById('svcStatus');
|
||||
svcBadge.textContent = state.running ? 'работает' : 'остановлен';
|
||||
svcBadge.className = `svc-badge ${state.running ? 'running' : 'stopped'}`;
|
||||
|
||||
document.getElementById('enableToggle').checked = state.config.enabled;
|
||||
document.getElementById('applyBtn').disabled = !state.installed || !state.dirty;
|
||||
|
||||
// Pools grid
|
||||
const grid = document.getElementById('poolsGrid');
|
||||
const loading = document.getElementById('poolsLoading');
|
||||
const noIface = document.getElementById('noIfaces');
|
||||
|
||||
loading.classList.add('hidden');
|
||||
|
||||
// interfaces eligible for DHCP: no gateway
|
||||
const eligible = state.ifaces.filter(i => !i.has_gateway);
|
||||
|
||||
if (eligible.length === 0) {
|
||||
grid.classList.add('hidden');
|
||||
noIface.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
noIface.classList.add('hidden');
|
||||
grid.classList.remove('hidden');
|
||||
|
||||
grid.innerHTML = '';
|
||||
|
||||
// Show ALL interfaces (both eligible and WAN), so the user sees the full picture
|
||||
state.ifaces.forEach(iface => {
|
||||
grid.appendChild(buildPoolCard(iface));
|
||||
});
|
||||
}
|
||||
|
||||
function buildPoolCard(iface) {
|
||||
const pool = poolForIface(iface.name);
|
||||
const isWAN = iface.has_gateway;
|
||||
const enabled = pool?.enabled ?? false;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'pool-card' + (isWAN ? ' pool-card--wan' : '') + (enabled ? ' pool-card--active' : '');
|
||||
card.innerHTML = `
|
||||
<div class="pool-header">
|
||||
<div class="pool-iface">
|
||||
<span class="state-dot ${iface.ipv4 ? 'up' : 'unknown'}"></span>
|
||||
<span class="pool-iface-name">${iface.name}</span>
|
||||
${isWAN ? '<span class="tag-gw">WAN</span>' : ''}
|
||||
${(!isWAN && enabled) ? '<span class="tag-active">DHCP активен</span>' : ''}
|
||||
</div>
|
||||
${!isWAN ? `<button class="btn btn-primary btn-sm" data-edit="${iface.name}">
|
||||
${pool ? '⚙ Изменить' : '+ Добавить пул'}
|
||||
</button>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="pool-info">
|
||||
${iface.ipv4
|
||||
? `<div class="info-row"><span class="info-label">IP</span>
|
||||
<span class="info-val">${iface.ipv4}${iface.ipv4_mask ? ' / ' + iface.ipv4_mask : ''}</span></div>`
|
||||
: `<div class="info-row"><span class="info-val none">нет IP-адреса</span></div>`}
|
||||
|
||||
${isWAN
|
||||
? `<div class="info-row"><span class="info-val muted">Интерфейс имеет шлюз — DHCP не раздаётся</span></div>`
|
||||
: pool
|
||||
? `
|
||||
<div class="info-row">
|
||||
<span class="info-label">Подсеть</span>
|
||||
<span class="info-val">${pool.subnet} / ${pool.netmask || '?'}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Диапазон</span>
|
||||
<span class="info-val">${pool.range_start || '—'} — ${pool.range_end || '—'}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Шлюз</span>
|
||||
<span class="info-val">${pool.router || '—'}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">DNS</span>
|
||||
<span class="info-val">${(pool.dns || []).join(', ') || '—'}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Аренда</span>
|
||||
<span class="info-val">${fmtLease(pool.lease_time)}</span>
|
||||
</div>
|
||||
`
|
||||
: `<div class="info-row"><span class="info-val muted">Пул не настроен</span></div>`
|
||||
}
|
||||
</div>
|
||||
${(!isWAN && pool) ? `
|
||||
<div class="pool-footer">
|
||||
<label class="checkbox-label" style="font-size:.83rem">
|
||||
<input type="checkbox" class="pool-enabled-chk" data-iface="${iface.name}" ${enabled ? 'checked' : ''}>
|
||||
<span>Активен</span>
|
||||
</label>
|
||||
<button class="btn btn-danger btn-sm" data-remove="${iface.name}" style="margin-left:auto">Удалить пул</button>
|
||||
</div>` : ''}
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── Load data ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
const [status, data] = await Promise.all([
|
||||
api('GET', '/api/dhcp/status'),
|
||||
api('GET', '/api/dhcp/config'),
|
||||
]);
|
||||
state.installed = status.installed;
|
||||
state.running = status.running;
|
||||
state.config = data.config || { enabled: false, pools: [] };
|
||||
state.ifaces = data.interfaces || [];
|
||||
state.dirty = false;
|
||||
render();
|
||||
} catch (e) {
|
||||
showToast('Ошибка загрузки: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Apply ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function saveConfig() {
|
||||
try {
|
||||
await api('POST', '/api/dhcp/config', state.config);
|
||||
state.dirty = false;
|
||||
render();
|
||||
} catch (e) {
|
||||
showToast('Ошибка сохранения: ' + e.message, 'error');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyConfig() {
|
||||
const btn = document.getElementById('applyBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Применяю...';
|
||||
try {
|
||||
await saveConfig();
|
||||
await api('POST', '/api/dhcp/apply');
|
||||
showToast('DHCP конфигурация применена', 'success');
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
showToast('Ошибка применения: ' + e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Применить';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pool modal ────────────────────────────────────────────────────────────────
|
||||
|
||||
function openPoolModal(ifaceName) {
|
||||
state.editIface = ifaceName;
|
||||
document.getElementById('poolModalTitle').textContent = `Пул для ${ifaceName}`;
|
||||
document.getElementById('pIface').value = ifaceName;
|
||||
|
||||
const existing = poolForIface(ifaceName);
|
||||
const iface = state.ifaces.find(i => i.name === ifaceName);
|
||||
|
||||
// Defaults: auto-fill subnet/netmask/router from interface IP if possible
|
||||
if (existing) {
|
||||
document.getElementById('pEnabled').checked = existing.enabled;
|
||||
document.getElementById('pSubnet').value = existing.subnet || '';
|
||||
document.getElementById('pNetmask').value = existing.netmask || '';
|
||||
document.getElementById('pRangeStart').value = existing.range_start || '';
|
||||
document.getElementById('pRangeEnd').value = existing.range_end || '';
|
||||
document.getElementById('pRouter').value = existing.router || '';
|
||||
document.getElementById('pDNS').value = (existing.dns || []).join(' ');
|
||||
document.getElementById('pLease').value = existing.lease_time || 86400;
|
||||
} else {
|
||||
document.getElementById('poolForm').reset();
|
||||
document.getElementById('pEnabled').checked = true;
|
||||
// auto-suggest subnet and router from interface address
|
||||
if (iface?.ipv4) {
|
||||
const parts = iface.ipv4.split('.');
|
||||
const subnet = parts.slice(0, 3).join('.') + '.0';
|
||||
document.getElementById('pSubnet').value = subnet;
|
||||
document.getElementById('pRouter').value = iface.ipv4;
|
||||
const rangeBase = parts.slice(0, 3).join('.');
|
||||
document.getElementById('pRangeStart').value = rangeBase + '.100';
|
||||
document.getElementById('pRangeEnd').value = rangeBase + '.200';
|
||||
}
|
||||
if (iface?.ipv4_mask) {
|
||||
document.getElementById('pNetmask').value = iface.ipv4_mask;
|
||||
}
|
||||
document.getElementById('pLease').value = 86400;
|
||||
}
|
||||
|
||||
document.getElementById('poolModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closePoolModal() {
|
||||
document.getElementById('poolModal').classList.add('hidden');
|
||||
state.editIface = null;
|
||||
}
|
||||
|
||||
function savePool() {
|
||||
const ifaceName = document.getElementById('pIface').value;
|
||||
if (!ifaceName) return;
|
||||
|
||||
const pool = {
|
||||
interface: ifaceName,
|
||||
enabled: document.getElementById('pEnabled').checked,
|
||||
subnet: document.getElementById('pSubnet').value.trim(),
|
||||
netmask: document.getElementById('pNetmask').value.trim(),
|
||||
range_start: document.getElementById('pRangeStart').value.trim(),
|
||||
range_end: document.getElementById('pRangeEnd').value.trim(),
|
||||
router: document.getElementById('pRouter').value.trim(),
|
||||
dns: document.getElementById('pDNS').value.trim().split(/\s+/).filter(Boolean),
|
||||
lease_time: parseInt(document.getElementById('pLease').value, 10) || 86400,
|
||||
};
|
||||
|
||||
if (!pool.subnet || !pool.netmask) {
|
||||
showToast('Укажите подсеть и маску', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// upsert
|
||||
const idx = state.config.pools.findIndex(p => p.interface === ifaceName);
|
||||
if (idx >= 0) {
|
||||
state.config.pools[idx] = pool;
|
||||
} else {
|
||||
state.config.pools.push(pool);
|
||||
}
|
||||
|
||||
state.dirty = true;
|
||||
closePoolModal();
|
||||
render();
|
||||
showToast('Пул обновлён. Нажмите «Применить» чтобы активировать.', 'info');
|
||||
}
|
||||
|
||||
function removePool(ifaceName) {
|
||||
state.config.pools = state.config.pools.filter(p => p.interface !== ifaceName);
|
||||
state.dirty = true;
|
||||
render();
|
||||
showToast('Пул удалён. Нажмите «Применить».', 'info');
|
||||
}
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let toastTimer;
|
||||
function showToast(msg, type = 'info') {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = `toast ${type}`;
|
||||
t.classList.remove('hidden');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
|
||||
}
|
||||
|
||||
// ── Event wiring ──────────────────────────────────────────────────────────────
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', loadAll);
|
||||
|
||||
document.getElementById('enableToggle').addEventListener('change', e => {
|
||||
state.config.enabled = e.target.checked;
|
||||
state.dirty = true;
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById('applyBtn').addEventListener('click', applyConfig);
|
||||
|
||||
// Delegated: edit/remove pool buttons + enabled checkboxes
|
||||
document.getElementById('poolsGrid').addEventListener('click', e => {
|
||||
const editBtn = e.target.closest('[data-edit]');
|
||||
const removeBtn = e.target.closest('[data-remove]');
|
||||
if (editBtn) openPoolModal(editBtn.dataset.edit);
|
||||
if (removeBtn) removePool(removeBtn.dataset.remove);
|
||||
});
|
||||
|
||||
document.getElementById('poolsGrid').addEventListener('change', e => {
|
||||
const chk = e.target.closest('.pool-enabled-chk');
|
||||
if (!chk) return;
|
||||
const ifaceName = chk.dataset.iface;
|
||||
const pool = poolForIface(ifaceName);
|
||||
if (pool) {
|
||||
pool.enabled = chk.checked;
|
||||
state.dirty = true;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
// Pool modal
|
||||
document.getElementById('closePoolModal').addEventListener('click', closePoolModal);
|
||||
document.getElementById('cancelPoolBtn').addEventListener('click', closePoolModal);
|
||||
document.getElementById('poolModalBackdrop').addEventListener('click', closePoolModal);
|
||||
document.getElementById('savePoolBtn').addEventListener('click', savePool);
|
||||
document.getElementById('poolForm').addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
savePool();
|
||||
});
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closePoolModal();
|
||||
});
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
loadAll();
|
||||
151
public/index.html
Normal file
151
public/index.html
Normal file
@@ -0,0 +1,151 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AlpineRouter</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<h1>AlpineRouter</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span id="hostname" class="hostname"></span>
|
||||
<button class="btn btn-ghost" id="refreshBtn" title="Обновить">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
</svg>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="pendingBanner" class="pending-banner hidden">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/>
|
||||
</svg>
|
||||
<span>Есть несохранённые изменения: <strong id="pendingList"></strong></span>
|
||||
<div class="banner-actions">
|
||||
<button class="btn btn-success" id="applyBtn">Применить</button>
|
||||
<button class="btn btn-ghost" id="discardAllBtn">Отменить всё</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="tab-nav">
|
||||
<a href="/" class="tab-link active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
Интерфейсы
|
||||
</a>
|
||||
<a href="/dhcp.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
DHCP сервер
|
||||
</a>
|
||||
<a href="/clients.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75M21 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
</svg>
|
||||
Клиенты
|
||||
</a>
|
||||
<a href="/proxy.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
Прокси
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<div id="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Загрузка...</span>
|
||||
</div>
|
||||
<div id="ifaceGrid" class="iface-grid hidden"></div>
|
||||
</main>
|
||||
|
||||
<!-- Config Modal -->
|
||||
<div id="modal" class="modal hidden" role="dialog" aria-modal="true">
|
||||
<div class="modal-backdrop" id="modalBackdrop"></div>
|
||||
<div class="modal-box">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">Настройка интерфейса</h2>
|
||||
<button class="btn-icon" id="closeModal" title="Закрыть">✕</button>
|
||||
</div>
|
||||
|
||||
<form id="configForm" autocomplete="off">
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="cfgAuto">
|
||||
<span>Автозапуск (auto)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Режим</label>
|
||||
<div class="segmented" id="modeSwitch">
|
||||
<button type="button" class="seg-btn active" data-mode="dhcp">DHCP</button>
|
||||
<button type="button" class="seg-btn" data-mode="static">Статический</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="staticFields" class="hidden">
|
||||
<div class="form-row">
|
||||
<label for="cfgAddress">IP-адрес</label>
|
||||
<input type="text" id="cfgAddress" placeholder="192.168.1.100" pattern="[\d\.]+">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="cfgNetmask">Маска сети</label>
|
||||
<input type="text" id="cfgNetmask" placeholder="255.255.255.0">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="cfgGateway">Шлюз</label>
|
||||
<input type="text" id="cfgGateway" placeholder="192.168.1.1">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="cfgDNS">DNS (через пробел)</label>
|
||||
<input type="text" id="cfgDNS" placeholder="8.8.8.8 8.8.4.4">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="natSection" class="hidden">
|
||||
<div class="form-divider"></div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="cfgNAT">
|
||||
<span>NAT / Masquerade — выход клиентов в интернет</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="natNotInstalled" class="form-hint hidden">
|
||||
⚠ nftables не установлен — выполните: <code>apk add nftables</code>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" id="cancelConfigBtn">Отмена</button>
|
||||
<button class="btn btn-primary" id="saveConfigBtn">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification toast -->
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
534
public/proxy.html
Normal file
534
public/proxy.html
Normal file
@@ -0,0 +1,534 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AlpineRouter — Прокси</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<h1>AlpineRouter</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span id="statusBadge" class="svc-badge stopped">Остановлен</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tab-nav">
|
||||
<a href="/" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
Интерфейсы
|
||||
</a>
|
||||
<a href="/dhcp.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
DHCP сервер
|
||||
</a>
|
||||
<a href="/clients.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75M21 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
</svg>
|
||||
Клиенты
|
||||
</a>
|
||||
<a href="/proxy.html" class="tab-link active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
Прокси
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<main class="proxy-main">
|
||||
<div id="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Загрузка...</span>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div id="statusBar" class="dhcp-status-bar hidden">
|
||||
<div class="status-info">
|
||||
<span class="status-label">Mihomo</span>
|
||||
<span id="statusText" class="svc-badge stopped">Остановлен</span>
|
||||
</div>
|
||||
<div class="status-actions">
|
||||
<button class="btn btn-success btn-sm" id="startBtn">Запустить</button>
|
||||
<button class="btn btn-danger btn-sm" id="stopBtn" disabled>Остановить</button>
|
||||
<button class="btn btn-ghost btn-sm" id="restartBtn" disabled>Перезапуск</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Core Info -->
|
||||
<div id="coreInfo" class="alert alert-error hidden" style="margin-top:16px">
|
||||
<span id="coreInfoMsg">Ядро Mihomo не найдено. Загрузите бинарный файл в раздел «Ядро».</span>
|
||||
</div>
|
||||
|
||||
<!-- Tabs inside proxy page -->
|
||||
<div class="proxy-tabs">
|
||||
<button class="ptab active" data-tab="proxies">Прокси</button>
|
||||
<button class="ptab" data-tab="groups">Группы</button>
|
||||
<button class="ptab" data-tab="rules">Правила</button>
|
||||
<button class="ptab" data-tab="settings">Настройки</button>
|
||||
<button class="ptab" data-tab="logs">Логи</button>
|
||||
<button class="ptab" data-tab="core">Ядро</button>
|
||||
</div>
|
||||
|
||||
<!-- Proxies Tab -->
|
||||
<div id="tab-proxies" class="ptab-content">
|
||||
<div class="section-header" style="margin-bottom:16px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<h2>Прокси-ноды</h2>
|
||||
<div class="section-desc">Добавьте прокси-серверы для маршрутизации трафика</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="addProxyBtn">+ Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="proxyList" class="proxy-list"></div>
|
||||
<div id="proxyEmpty" class="empty-state hidden">Нет прокси-нод. Нажмите «Добавить» для создания.</div>
|
||||
</div>
|
||||
|
||||
<!-- Groups Tab -->
|
||||
<div id="tab-groups" class="ptab-content hidden">
|
||||
<div class="section-header" style="margin-bottom:16px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<h2>Группы прокси</h2>
|
||||
<div class="section-desc">Балансировщики, селекторы и URL-тесты</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="addGroupBtn">+ Добавить группу</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="groupList" class="proxy-list"></div>
|
||||
<div id="groupEmpty" class="empty-state hidden">Нет групп. Нажмите «Добавить группу» для создания.</div>
|
||||
</div>
|
||||
|
||||
<!-- Rules Tab -->
|
||||
<div id="tab-rules" class="ptab-content hidden">
|
||||
<div class="section-header" style="margin-bottom:16px">
|
||||
<h2>Правила маршрутизации</h2>
|
||||
<div class="section-desc">Определите, какой трафик куда направляется. Правила apply сверху вниз.</div>
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom:12px">
|
||||
<button class="btn btn-primary btn-sm" id="addRuleBtn">+ Добавить правило</button>
|
||||
<button class="btn btn-ghost btn-sm" id="addBlockBtn" style="margin-left:8px">+ Блокировка домена</button>
|
||||
</div>
|
||||
<div id="rulesList" class="rules-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div id="tab-settings" class="ptab-content hidden">
|
||||
<div class="section-header" style="margin-bottom:16px">
|
||||
<h2>Настройки Mihomo</h2>
|
||||
</div>
|
||||
<form id="settingsForm" class="proxy-form">
|
||||
<div class="form-row">
|
||||
<label>Режим</label>
|
||||
<div class="segmented" id="modeSwitch">
|
||||
<button type="button" class="seg-btn active" data-mode="rule">Правила</button>
|
||||
<button type="button" class="seg-btn" data-mode="global">Глобальный</button>
|
||||
<button type="button" class="seg-btn" data-mode="direct">Прямой</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="mixedPort">Mixed Port (HTTP+SOCKS)</label>
|
||||
<input type="number" id="mixedPort" value="7890">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="allowLan" checked>
|
||||
<span>Разрешить LAN</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="ipv6" checked>
|
||||
<span>IPv6</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="tcpConcurrent" checked>
|
||||
<span>TCP Concurrency</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-divider"></div>
|
||||
<h3 style="color:var(--text);font-size:.95rem;margin-bottom:12px">TProxy (прозрачный прокси)</h3>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="tproxyEnabled">
|
||||
<span>Включить TProxy</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="tproxyPort">TProxy порт</label>
|
||||
<input type="number" id="tproxyPort" value="7894">
|
||||
</div>
|
||||
<div class="form-divider"></div>
|
||||
<h3 style="color:var(--text);font-size:.95rem;margin-bottom:12px">DNS</h3>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="dnsEnabled" checked>
|
||||
<span>DNS сервер</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="dnsListen">DNS адрес</label>
|
||||
<input type="text" id="dnsListen" value="0.0.0.0:1053">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="dnsMode">DNS режим</label>
|
||||
<div class="segmented" id="dnsModeSwitch">
|
||||
<button type="button" class="seg-btn active" data-mode="redir-host">redir-host</button>
|
||||
<button type="button" class="seg-btn" data-mode="fake-ip">fake-ip</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="dnsNameserver">DNS серверы (по строке)</label>
|
||||
<textarea id="dnsNameserver" rows="3" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.85rem;font-family:'JetBrains Mono','Fira Code',monospace;width:100%;resize:vertical">https://doh.pub/dns-query
|
||||
https://dns.alidns.com/dns-query</textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="dnsFallback">Fallback DNS (по строке)</label>
|
||||
<textarea id="dnsFallback" rows="2" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.85rem;font-family:'JetBrains Mono','Fira Code',monospace;width:100%;resize:vertical">tls://8.8.8.8:853
|
||||
tls://1.1.1.1:853</textarea>
|
||||
</div>
|
||||
<div class="form-divider"></div>
|
||||
<div class="form-row">
|
||||
<label for="externalController">External Controller</label>
|
||||
<input type="text" id="externalController" value="0.0.0.0:9090">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="secret">Secret (API ключ)</label>
|
||||
<input type="text" id="secret" placeholder="опционально">
|
||||
</div>
|
||||
<div style="margin-top:16px;display:flex;gap:8px">
|
||||
<button type="submit" class="btn btn-primary">Сохранить настройки</button>
|
||||
<button type="button" class="btn btn-ghost" id="saveAndRestartBtn">Сохранить и перезапустить</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="form-divider" style="margin:20px 0"></div>
|
||||
<h3 style="color:var(--text);font-size:.95rem;margin-bottom:8px">config.yaml (только чтение)</h3>
|
||||
<div class="section-desc" style="margin-bottom:8px">Текущий конфиг mihomo. Обновляется автоматически после сохранения настроек.</div>
|
||||
<div class="form-row">
|
||||
<textarea id="yamlPreview" rows="18" readonly style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-muted);padding:9px 12px;font-size:.8rem;font-family:'JetBrains Mono','Fira Code',monospace;width:100%;resize:vertical;opacity:.85"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs Tab -->
|
||||
<div id="tab-logs" class="ptab-content hidden">
|
||||
<div class="section-header" style="margin-bottom:16px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<h2>Логи ядра</h2>
|
||||
<div class="section-desc">Вывод процесса mihomo (обновление каждые 500мс)</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" id="clearLogsBtn">Очистить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="logOutput" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px;font-family:'JetBrains Mono','Fira Code',monospace;font-size:.78rem;color:var(--text-muted);min-height:200px;max-height:calc(100vh - 300px);overflow-y:auto;white-space:pre-wrap;word-break:break-all"></div>
|
||||
</div>
|
||||
|
||||
<!-- Core Tab -->
|
||||
<div id="tab-core" class="ptab-content hidden">
|
||||
<div class="section-header" style="margin-bottom:16px">
|
||||
<h2>Ядро Mihomo</h2>
|
||||
<div class="section-desc">Управление бинарным файлом ядра</div>
|
||||
</div>
|
||||
<div id="coreStatus" class="proxy-card" style="margin-bottom:16px">
|
||||
<div class="card-info">
|
||||
<div class="info-row"><span class="info-label">Путь</span><span class="info-val" id="corePath">—</span></div>
|
||||
<div class="info-row"><span class="info-label">Наличие</span><span class="info-val" id="coreExists">—</span></div>
|
||||
<div class="info-row"><span class="info-label">PID</span><span class="info-val" id="corePid">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-divider" style="margin-bottom:16px"></div>
|
||||
<h3 style="color:var(--text);font-size:.95rem;margin-bottom:12px">Загрузить ядро</h3>
|
||||
<div class="section-desc" style="margin-bottom:12px">Загрузите бинарный файл mihomo (например, mihomo-linux-amd64). Файл автоматически определит архитектуру по имени.</div>
|
||||
<form id="uploadCoreForm">
|
||||
<div class="form-row">
|
||||
<input type="file" id="coreFile" accept=".gz,.zip,application/octet-stream" style="color:var(--text)">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:8px">Загрузить</button>
|
||||
</form>
|
||||
<div class="form-divider" style="margin:20px 0"></div>
|
||||
<h3 style="color:var(--text);font-size:.95rem;margin-bottom:12px">Ручная конфигурация (config.yaml)</h3>
|
||||
<div class="section-desc" style="margin-bottom:8px">Редактировать конфигурационный файл напрямую</div>
|
||||
<div class="form-row">
|
||||
<textarea id="yamlEditor" rows="20" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.8rem;font-family:'JetBrains Mono','Fira Code',monospace;width:100%;resize:vertical"></textarea>
|
||||
</div>
|
||||
<div style="margin-top:8px;display:flex;gap:8px">
|
||||
<button class="btn btn-primary btn-sm" id="yamlLoadBtn">Загрузить</button>
|
||||
<button class="btn btn-success btn-sm" id="yamlSaveBtn">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proxy Modal -->
|
||||
<div id="proxyModal" class="modal hidden" role="dialog" aria-modal="true">
|
||||
<div class="modal-backdrop" id="proxyModalBackdrop"></div>
|
||||
<div class="modal-box" style="width:min(560px,calc(100vw - 40px))">
|
||||
<div class="modal-header">
|
||||
<h2 id="proxyModalTitle">Добавить прокси</h2>
|
||||
<button class="btn-icon" id="closeProxyModal" title="Закрыть">✕</button>
|
||||
</div>
|
||||
<form id="proxyForm" autocomplete="off" style="max-height:60vh;overflow-y:auto">
|
||||
<div class="form-row">
|
||||
<label for="proxyType">Тип</label>
|
||||
<select id="proxyType" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.9rem;width:100%">
|
||||
<option value="ss">Shadowsocks</option>
|
||||
<option value="vmess">VMess</option>
|
||||
<option value="vless">VLESS</option>
|
||||
<option value="trojan">Trojan</option>
|
||||
<option value="hysteria2">Hysteria2</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
<option value="direct">DIRECT</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="proxyName">Имя</label>
|
||||
<input type="text" id="proxyName" placeholder="my-proxy">
|
||||
</div>
|
||||
<div id="proxyServerFields">
|
||||
<div class="form-row">
|
||||
<label for="proxyServer">Сервер</label>
|
||||
<input type="text" id="proxyServer" placeholder="example.com">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="proxyPort">Порт</label>
|
||||
<input type="number" id="proxyPort" placeholder="443">
|
||||
</div>
|
||||
</div>
|
||||
<div id="proxyAuthFields" class="hidden">
|
||||
<div class="form-row">
|
||||
<label for="proxyUsername">Имя пользователя</label>
|
||||
<input type="text" id="proxyUsername" placeholder="username">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="proxyPassword">Пароль</label>
|
||||
<input type="text" id="proxyPassword" placeholder="password">
|
||||
</div>
|
||||
</div>
|
||||
<div id="proxyCipherField" class="hidden">
|
||||
<div class="form-row">
|
||||
<label for="proxyCipher">Шифр</label>
|
||||
<select id="proxyCipher" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.9rem;width:100%">
|
||||
<option value="auto">auto</option>
|
||||
<option value="aes-128-gcm">aes-128-gcm</option>
|
||||
<option value="aes-256-gcm">aes-256-gcm</option>
|
||||
<option value="chacha20-ietf-poly1305">chacha20-ietf-poly1305</option>
|
||||
<option value="none">none</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="proxyUUIDField" class="hidden">
|
||||
<div class="form-row">
|
||||
<label for="proxyUUID">UUID</label>
|
||||
<input type="text" id="proxyUUID" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
|
||||
</div>
|
||||
</div>
|
||||
<div id="proxyTrojanPassField" class="hidden">
|
||||
<div class="form-row">
|
||||
<label for="proxyTrojanPass">Пароль (Trojan)</label>
|
||||
<input type="text" id="proxyTrojanPass" placeholder="password">
|
||||
</div>
|
||||
</div>
|
||||
<div id="proxyHysteria2Fields" class="hidden">
|
||||
<div class="form-row">
|
||||
<label for="proxyObfs">Obfs тип</label>
|
||||
<input type="text" id="proxyObfs" placeholder="salamander или пусто">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="proxyObfsPass">Obfs пароль</label>
|
||||
<input type="text" id="proxyObfsPass" placeholder="obfs password">
|
||||
</div>
|
||||
</div>
|
||||
<div id="proxyTLSField" class="hidden">
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="proxyTLS">
|
||||
<span>TLS</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="proxySNI">SNI (ServerName)</label>
|
||||
<input type="text" id="proxySNI" placeholder="example.com">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="proxySkipCertVerify">
|
||||
<span>Пропустить проверку сертификата</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="proxyVlessFlowField" class="hidden">
|
||||
<div class="form-row">
|
||||
<label for="proxyFlow">Flow</label>
|
||||
<input type="text" id="proxyFlow" placeholder="xtls-rprx-vision (опционально)">
|
||||
</div>
|
||||
</div>
|
||||
<div id="proxyNetworkField" class="hidden">
|
||||
<div class="form-row">
|
||||
<label for="proxyNetwork">Транспорт</label>
|
||||
<select id="proxyNetwork" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.9rem;width:100%">
|
||||
<option value="">tcp (по умолчанию)</option>
|
||||
<option value="ws">WebSocket</option>
|
||||
<option value="grpc">gRPC</option>
|
||||
<option value="h2">HTTP/2</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="proxyUDP" checked>
|
||||
<span>UDP</span>
|
||||
</label>
|
||||
</div>
|
||||
<input type="hidden" id="proxyEditName" value="">
|
||||
</form>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" id="cancelProxyBtn">Отмена</button>
|
||||
<button class="btn btn-primary" id="saveProxyBtn">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Modal -->
|
||||
<div id="groupModal" class="modal hidden" role="dialog" aria-modal="true">
|
||||
<div class="modal-backdrop" id="groupModalBackdrop"></div>
|
||||
<div class="modal-box" style="width:min(560px,calc(100vw - 40px))">
|
||||
<div class="modal-header">
|
||||
<h2 id="groupModalTitle">Добавить группу</h2>
|
||||
<button class="btn-icon" id="closeGroupModal" title="Закрыть">✕</button>
|
||||
</div>
|
||||
<form id="groupForm" autocomplete="off">
|
||||
<div class="form-row">
|
||||
<label for="groupName">Имя группы</label>
|
||||
<input type="text" id="groupName" placeholder="proxy">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="groupType">Тип</label>
|
||||
<select id="groupType" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.9rem;width:100%">
|
||||
<option value="select">Select (ручной выбор)</option>
|
||||
<option value="url-test">URL-test (автовыбор по задержке)</option>
|
||||
<option value="fallback">Fallback (резервный)</option>
|
||||
<option value="load-balance">Load Balance (балансировка)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="groupURLField" class="hidden">
|
||||
<div class="form-row">
|
||||
<label for="groupURL">URL тестирования</label>
|
||||
<input type="text" id="groupURL" value="https://www.gstatic.com/generate_204" placeholder="https://www.gstatic.com/generate_204">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="groupInterval">Интервал тестирования (сек)</label>
|
||||
<input type="number" id="groupInterval" value="300">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="groupTolerance">Допуск (мс)</label>
|
||||
<input type="number" id="groupTolerance" value="50" placeholder="50">
|
||||
</div>
|
||||
</div>
|
||||
<div id="groupLBStrategy" class="hidden">
|
||||
<div class="form-row">
|
||||
<label>Стратегия балансировки</label>
|
||||
<select id="groupLBStrategy" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.9rem;width:100%">
|
||||
<option value="round-robin">Round Robin</option>
|
||||
<option value="consistent-hashing">Consistent Hashing</option>
|
||||
<option value="sticky-sessions">Sticky Sessions</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Прокси-ноды в группе</label>
|
||||
<div id="groupProxyCheckboxes" style="max-height:200px;overflow-y:auto;background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="groupIncludeAll">
|
||||
<span>Включить все прокси автоматически</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="groupFilter">Фильтр (regex)</label>
|
||||
<input type="text" id="groupFilter" placeholder="напр. (?i)hk|hongkong">
|
||||
</div>
|
||||
<input type="hidden" id="groupEditName" value="">
|
||||
</form>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" id="cancelGroupBtn">Отмена</button>
|
||||
<button class="btn btn-primary" id="saveGroupBtn">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rule Modal -->
|
||||
<div id="ruleModal" class="modal hidden" role="dialog" aria-modal="true">
|
||||
<div class="modal-backdrop" id="ruleModalBackdrop"></div>
|
||||
<div class="modal-box" style="width:min(520px,calc(100vw - 40px))">
|
||||
<div class="modal-header">
|
||||
<h2 id="ruleModalTitle">Добавить правило</h2>
|
||||
<button class="btn-icon" id="closeRuleModal" title="Закрыть">✕</button>
|
||||
</div>
|
||||
<form id="ruleForm" autocomplete="off">
|
||||
<div class="form-row">
|
||||
<label for="ruleType">Тип правила</label>
|
||||
<select id="ruleType" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.9rem;width:100%">
|
||||
<option value="DOMAIN">DOMAIN (точный домен)</option>
|
||||
<option value="DOMAIN-SUFFIX">DOMAIN-SUFFIX (домен суффикс)</option>
|
||||
<option value="DOMAIN-KEYWORD">DOMAIN-KEYWORD (ключевое слово)</option>
|
||||
<option value="GEOSITE">GEOSITE (геосайт)</option>
|
||||
<option value="GEOIP">GEOIP (гео IP)</option>
|
||||
<option value="IP-CIDR">IP-CIDR (подсеть)</option>
|
||||
<option value="SRC-IP-CIDR">SRC-IP-CIDR (источник подсеть)</option>
|
||||
<option value="DST-PORT">DST-PORT (порт назначения)</option>
|
||||
<option value="SRC-PORT">SRC-PORT (порт источника)</option>
|
||||
<option value="MATCH">MATCH (всё)</option>
|
||||
<option value="RULE-SET">RULE-SET (набор правил)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="ruleValueField" class="form-row">
|
||||
<label for="ruleValue">Значение</label>
|
||||
<input type="text" id="ruleValue" placeholder="напр. google.com или 192.168.0.0/16">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="ruleProxy">Прокси / Группа</label>
|
||||
<select id="ruleProxy" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.9rem;width:100%">
|
||||
<option value="DIRECT">DIRECT</option>
|
||||
<option value="REJECT">REJECT</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="ruleNoResolveDiv" class="form-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="ruleNoResolve" checked>
|
||||
<span>no-resolve</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" id="cancelRuleBtn">Отмена</button>
|
||||
<button class="btn btn-primary" id="saveRuleBtn">Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification toast -->
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<script src="proxy.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
957
public/proxy.js
Normal file
957
public/proxy.js
Normal 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');
|
||||
})();
|
||||
1140
public/style.css
Normal file
1140
public/style.css
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user