Files
alpine-router/public/app.js
2026-04-15 11:38:26 +03:00

636 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const state = {
interfaces: [],
pending: [],
configModal: null,
configModalParent: null,
nat: null,
};
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);
function isVLAN(name) {
return /\.\d+$/.test(name);
}
function vlanParent(name) {
return name.replace(/\.\d+$/, '');
}
function vlanId(name) {
const m = name.match(/\.(\d+)$/);
return m ? parseInt(m[1]) : 0;
}
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 || '?');
}
const ICON = {
pencil: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>`,
restart: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<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>`,
trash: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/>
</svg>`,
plus: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13">
<path d="M12 5v14M5 12h14"/>
</svg>`,
};
function ipClass(ip) {
if (!ip) return '';
const p = ip.split('.').map(Number);
if (p.length !== 4 || p.some(isNaN)) return '';
if (p[0] >= 1 && p[0] <= 126) return 'A';
if (p[0] === 127) return 'loopback';
if (p[0] >= 128 && p[0] <= 191) return 'B';
if (p[0] >= 192 && p[0] <= 223) return 'C';
return '';
}
function guessMask(ip) {
const c = ipClass(ip);
switch (c) {
case 'A': return '255.0.0.0';
case 'B': return '255.255.0.0';
case 'C': return '255.255.255.0';
default: return '';
}
}
function maskToCIDR(mask) {
if (!mask) return '';
if (/^\d+$/.test(mask)) return '/' + mask;
const parts = mask.split('.').map(Number);
if (parts.length !== 4 || parts.some(isNaN)) return '/' + mask;
const bits = parts.map(p => (p >>> 0).toString(2).padStart(8, '0')).join('');
const cidr = bits.split('0')[0].length;
if (cidr > 0 && cidr <= 32) return '/' + cidr;
return '/' + mask;
}
function renderAll() {
const grid = document.getElementById('ifaceGrid');
grid.innerHTML = '';
const vlansByParent = {};
const physicals = [];
for (const iface of state.interfaces) {
if (isVLAN(iface.name)) {
const p = vlanParent(iface.name);
if (!vlansByParent[p]) vlansByParent[p] = [];
vlansByParent[p].push(iface);
} else {
physicals.push(iface);
}
}
const wrap = document.createElement('div');
wrap.className = 'iface-table-wrap';
const table = document.createElement('table');
table.className = 'iface-table';
table.innerHTML = `
<thead>
<tr>
<th class="col-if-state"></th>
<th class="col-if-name">Интерфейс</th>
<th class="col-if-type">Тип</th>
<th class="col-if-ipv4">IPv4</th>
<th class="col-if-gw">Шлюз</th>
<th class="col-if-mode">Режим</th>
<th class="col-if-traffic">Трафик</th>
<th class="col-if-actions">Действия</th>
</tr>
</thead>
<tbody id="ifaceTableBody"></tbody>
`;
wrap.appendChild(table);
grid.appendChild(wrap);
const tbody = document.getElementById('ifaceTableBody');
for (const iface of physicals) {
const isLo = iface.name === 'lo' || iface.mode === 'loopback';
if (isLo) continue;
const vlans = vlansByParent[iface.name] || [];
tbody.appendChild(buildPhysicalRow(iface, vlans));
}
document.getElementById('loading').classList.add('hidden');
grid.classList.remove('hidden');
renderPendingBanner();
}
function buildPhysicalRow(iface, vlans) {
const hasPending = state.pending.includes(iface.name);
const sc = stateClass(iface.state);
const isUp = iface.state === 'up';
const isWAN = iface.type === 'wan';
const label = iface.label || '';
const ipDisplay = iface.ipv4
? iface.ipv4 + (iface.ipv4_mask ? maskToCIDR(iface.ipv4_mask) : '')
: '<span class="none">&mdash;</span>';
const gwDisplay = iface.gateway
? iface.gateway
: '<span class="none">&mdash;</span>';
const trafficDisplay = `
<div class="traffic-mini">
<span class="traffic-mini-item"><span class="traffic-mini-label">↓</span> ${fmtBytes(iface.rx_bytes)}</span>
<span class="traffic-mini-item"><span class="traffic-mini-label">↑</span> ${fmtBytes(iface.tx_bytes)}</span>
</div>`;
const nameCell = label
? `<div class="iface-name-stack"><span class="iface-label-text">${label}</span><span class="iface-name-sub">${iface.name}</span></div>`
: `<span class="iface-name-text">${iface.name}</span>`;
const typeBadge = isWAN
? '<span class="type-badge type-wan">WAN</span>'
: '<span class="type-badge type-lan">LAN</span>';
const tr = document.createElement('tr');
tr.className = 'iface-row' + (hasPending ? ' has-pending' : '') + (isWAN ? ' row-wan' : '');
tr.dataset.name = iface.name;
tr.innerHTML = `
<td class="col-if-state"><span class="state-dot ${sc}"></span></td>
<td class="col-if-name">
<div class="iface-name-block">
${nameCell}
${hasPending ? '<span class="pending-badge">несохранено</span>' : ''}
<button class="btn btn-ghost btn-xs${isWAN ? ' hidden' : ''}" data-action="addvlan" data-iface="${iface.name}" style="margin-left:8px">${ICON.plus} VLAN</button>
</div>
</td>
<td class="col-if-type">${typeBadge}</td>
<td class="col-if-ipv4 mono">${ipDisplay}</td>
<td class="col-if-gw mono">${gwDisplay}</td>
<td class="col-if-mode"><span class="mode-badge ${iface.mode || 'unknown'}">${modeLabel(iface.mode)}</span></td>
<td class="col-if-traffic">${trafficDisplay}</td>
<td class="col-if-actions">
<div class="iface-actions">
<label class="toggle-label iface-power-toggle" title="${isUp ? 'Выключить интерфейс' : 'Включить интерфейс'}">
<input type="checkbox" ${isUp ? 'checked' : ''} data-action="toggle" data-iface="${iface.name}">
<span class="toggle-slider"></span>
<span class="iface-toggle-label">${isUp ? 'Вкл' : 'Выкл'}</span>
</label>
<button class="btn-icon" data-action="restart" data-iface="${iface.name}" title="Перезапустить">${ICON.restart}</button>
<button class="btn-icon btn-icon-accent" data-action="config" data-iface="${iface.name}" title="Настроить" style="margin-left:auto">${ICON.pencil}</button>
</div>
</td>
`;
const frag = document.createDocumentFragment();
frag.appendChild(tr);
for (const v of vlans) {
frag.appendChild(buildVLANRow(v, iface.name));
}
return frag;
}
function buildVLANRow(v, parentName) {
const sc = stateClass(v.state);
const hasPending = state.pending.includes(v.name);
const isUp = v.state === 'up';
const label = v.label || '';
const isWAN = v.type === 'wan';
const ip = v.ipv4
? v.ipv4 + (v.ipv4_mask ? maskToCIDR(v.ipv4_mask) : '')
: '<span class="none">&mdash;</span>';
const gwDisplay = v.gateway ? v.gateway : '<span class="none">&mdash;</span>';
const typeBadge = isWAN
? '<span class="type-badge type-wan">WAN</span>'
: '<span class="type-badge type-lan">LAN</span>';
const nameCell = label
? `<div class="iface-name-stack"><span class="iface-label-text">${label}</span><span class="iface-name-sub">${v.name}</span></div>`
: `<span class="iface-name-text">${v.name}</span>`;
const tr = document.createElement('tr');
tr.className = 'iface-row iface-row-vlan' + (hasPending ? ' has-pending' : '');
tr.dataset.name = v.name;
tr.dataset.parent = parentName;
tr.innerHTML = `
<td class="col-if-state"><span class="state-dot ${sc}" style="width:8px;height:8px"></span></td>
<td class="col-if-name">
<div class="vlan-nested-name">
<span class="vlan-tree-line"></span>
<span class="vlan-id-tag">VLAN ${vlanId(v.name)}</span>
${nameCell}
${hasPending ? '<span class="pending-badge">несохранено</span>' : ''}
</div>
</td>
<td class="col-if-type">${typeBadge}</td>
<td class="col-if-ipv4 mono">${ip}</td>
<td class="col-if-gw mono">${gwDisplay}</td>
<td class="col-if-mode"><span class="mode-badge ${v.mode || 'unknown'}">${modeLabel(v.mode)}</span></td>
<td class="col-if-traffic"></td>
<td class="col-if-actions">
<div class="iface-actions">
<label class="toggle-label" title="${isUp ? 'Выключить' : 'Включить'}">
<input type="checkbox" ${isUp ? 'checked' : ''} data-action="toggle" data-iface="${v.name}">
<span class="toggle-slider toggle-sm"></span>
</label>
<button class="btn-icon btn-icon-accent" data-action="config" data-iface="${v.name}" title="Настроить">${ICON.pencil}</button>
<button class="btn-icon btn-icon-danger" data-action="delete" data-iface="${v.name}" title="Удалить VLAN">${ICON.trash}</button>
</div>
</td>
`;
return tr;
}
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');
}
}
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');
}
}
async function doAction(name, action) {
if (action === 'delete') {
if (!confirm(`Удалить VLAN ${name}?`)) return;
try {
await post(`/api/interfaces/${name}/delete`);
showToast(`${name}: удалён`, 'success');
await loadAll();
} catch (e) {
showToast(`${name} delete: ${e.message}`, 'error');
}
return;
}
try {
await post(`/api/interfaces/${name}/${action}`);
showToast(`${name}: ${action} выполнено`, 'success');
await loadAll();
} catch (e) {
showToast(`${name} ${action}: ${e.message}`, 'error');
await loadAll();
}
}
async function openConfig(name) {
state.configModal = name;
state.configModalParent = null;
document.getElementById('modalTitle').textContent = `Настройка: ${name}`;
const vlanSection = document.getElementById('vlanIdSection');
const vlanInput = document.getElementById('cfgVLANId');
if (isVLAN(name)) {
vlanSection.classList.remove('hidden');
vlanInput.value = vlanId(name);
vlanInput.readOnly = true;
} else {
vlanSection.classList.add('hidden');
vlanInput.readOnly = false;
}
try {
const [configData, natData] = await Promise.all([
get(`/api/config/${name}`),
get('/api/nat').catch(() => null),
]);
if (natData) state.nat = natData;
let currentType = configData.type || 'lan';
if (isVLAN(name)) currentType = 'lan';
fillForm(configData.config, configData.pending, name, configData.label || '', currentType, isVLAN(name));
document.getElementById('modal').classList.remove('hidden');
} catch (e) {
showToast('Ошибка загрузки конфига: ' + e.message, 'error');
}
}
async function openNewVLAN(parentName) {
const parentIface = state.interfaces.find(i => i.name === parentName);
if (parentIface && parentIface.type === 'wan') {
showToast('Нельзя добавить VLAN на WAN-интерфейс', 'error');
return;
}
state.configModal = null;
state.configModalParent = parentName;
document.getElementById('modalTitle').textContent = `Новый VLAN на ${parentName}`;
const vlanSection = document.getElementById('vlanIdSection');
const vlanInput = document.getElementById('cfgVLANId');
vlanSection.classList.remove('hidden');
vlanInput.readOnly = false;
vlanInput.value = '';
try {
const natData = await get('/api/nat').catch(() => null);
if (natData) state.nat = natData;
} catch (_) {}
fillForm({ auto: true, mode: 'static' }, false, '', '', 'lan', true);
document.getElementById('modal').classList.remove('hidden');
}
function fillForm(cfg, pending, name, label = '', ifaceType = 'lan', forceLAN = false) {
document.getElementById('cfgLabel').value = label;
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(' ');
setType(ifaceType, forceLAN);
const mode = cfg.mode === 'static' ? 'static' : 'dhcp';
setMode(mode);
if (pending && name) {
document.getElementById('modalTitle').textContent = `Настройка: ${name} (несохранённые изменения)`;
}
}
function setType(t, forceLAN = false) {
document.querySelectorAll('#typeSwitch .seg-btn').forEach(b => {
b.classList.toggle('active', b.dataset.type === t);
});
const typeRow = document.getElementById('typeSwitch').closest('.form-row');
typeRow.classList.toggle('hidden', forceLAN);
updateTypeVisibility(t);
}
function currentType() {
return document.querySelector('#typeSwitch .seg-btn.active')?.dataset.type ?? 'lan';
}
function updateTypeVisibility(type) {
const modeRow = document.getElementById('modeRow');
const gatewayRow = document.getElementById('gatewayRow');
const dnsRow = document.getElementById('dnsRow');
const natSection = document.getElementById('natSection');
if (type === 'lan') {
modeRow.classList.add('hidden');
setMode('static');
gatewayRow.classList.add('hidden');
dnsRow.classList.add('hidden');
natSection.classList.remove('hidden');
} else {
modeRow.classList.remove('hidden');
gatewayRow.classList.remove('hidden');
dnsRow.classList.remove('hidden');
natSection.classList.add('hidden');
}
updateNATSection(type);
}
function updateNATSection(type) {
if (type === 'lan') {
const natNotInstalled = document.getElementById('natNotInstalled');
const cfgNAT = document.getElementById('cfgNAT');
const name = state.configModal;
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('#modeSwitch .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('#modeSwitch .seg-btn.active')?.dataset.mode ?? 'dhcp';
}
function closeModal() {
document.getElementById('modal').classList.add('hidden');
document.getElementById('configForm').reset();
state.configModal = null;
state.configModalParent = null;
}
async function saveConfig() {
let name = state.configModal;
if (!name) {
const parent = state.configModalParent;
const id = parseInt(document.getElementById('cfgVLANId').value);
if (!parent) return;
if (!id || id < 1 || id > 4094) {
showToast('Укажите корректный VLAN ID (14094)', 'error');
return;
}
name = `${parent}.${id}`;
if (state.interfaces.find(i => i.name === name)) {
showToast(`VLAN ${name} уже существует`, 'error');
return;
}
}
const type = currentType();
const mode = type === 'lan' ? 'static' : currentMode();
const cfg = {
name,
label: document.getElementById('cfgLabel').value.trim(),
type,
auto: document.getElementById('cfgAuto').checked,
mode,
address: document.getElementById('cfgAddress').value.trim(),
netmask: document.getElementById('cfgNetmask').value.trim(),
gateway: type === 'wan' ? document.getElementById('cfgGateway').value.trim() : '',
dns: type === 'wan' ? document.getElementById('cfgDNS').value.trim().split(/\s+/).filter(Boolean) : [],
extra: {},
};
if (mode === 'static' && !cfg.address) {
showToast('Укажите IP-адрес', 'error');
return;
}
if (type === 'wan' && mode === 'static' && !cfg.netmask) {
showToast('Укажите маску сети', 'error');
return;
}
try {
await post(`/api/config/${name}`, cfg);
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');
}
}
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();
showToast('Изменения отменены', 'info');
await loadAll();
}
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('applyBtn').addEventListener('click', applyAll);
document.getElementById('discardAllBtn').addEventListener('click', discardAll);
document.getElementById('ifaceGrid').addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
if (btn.tagName === 'INPUT' && btn.type === 'checkbox') return;
const { action, iface } = btn.dataset;
if (!action || !iface) return;
if (action === 'config') {
openConfig(iface);
} else if (action === 'addvlan') {
openNewVLAN(iface);
} else {
doAction(iface, action);
}
});
document.getElementById('ifaceGrid').addEventListener('change', e => {
const input = e.target.closest('input[data-action="toggle"]');
if (!input) return;
doAction(input.dataset.iface, input.checked ? 'up' : 'down');
});
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();
});
document.getElementById('typeSwitch').addEventListener('click', e => {
const btn = e.target.closest('.seg-btn');
if (btn) setType(btn.dataset.type);
});
document.getElementById('modeSwitch').addEventListener('click', e => {
const btn = e.target.closest('.seg-btn');
if (btn) setMode(btn.dataset.mode);
});
document.getElementById('cfgNetmask').addEventListener('focus', () => {
const maskInput = document.getElementById('cfgNetmask');
if (maskInput.value) return;
const addr = document.getElementById('cfgAddress').value.trim();
if (!addr) return;
const m = guessMask(addr);
if (m) maskInput.value = m;
});
document.getElementById('saveConfigBtn').addEventListener('click', saveConfig);
document.getElementById('configForm').addEventListener('submit', e => {
e.preventDefault();
saveConfig();
});
setInterval(loadAll, 10000);
(async () => {
await loadAll();
})();