first commit

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

388
public/app.js Normal file
View 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">&mdash;</span>'}</span>
</div>
${ipv6lines || `<div class="info-row"><span class="info-label">IPv6</span><span class="info-val none">&mdash;</span></div>`}
<div class="info-row">
<span class="info-label">Шлюз</span>
<span class="info-val">${iface.gateway || '<span class="none">&mdash;</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
View 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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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
View 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
View 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
View 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
View 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
View File

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

1140
public/style.css Normal file

File diff suppressed because it is too large Load Diff