Files
alpine-router/public/app.js
2026-04-13 18:56:13 +03:00

558 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';
// ── State ────────────────────────────────────────────────────────────────────
const state = {
interfaces: [], // latest data from /api/interfaces
pending: [], // interface names with pending config
configModal: null, // name of interface being configured (null = new VLAN)
configModalParent: null, // parent interface when creating a new VLAN
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);
// ── VLAN helpers ─────────────────────────────────────────────────────────────
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;
}
// ── 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 || '?');
}
// ── SVG icons ────────────────────────────────────────────────────────────────
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>`,
};
// ── Render ───────────────────────────────────────────────────────────────────
function renderAll() {
const grid = document.getElementById('ifaceGrid');
grid.innerHTML = '';
// Group VLANs by parent
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);
}
}
for (const iface of physicals) {
const vlans = vlansByParent[iface.name] || [];
grid.appendChild(buildCard(iface, vlans));
}
document.getElementById('loading').classList.add('hidden');
grid.classList.remove('hidden');
renderPendingBanner();
}
function buildCard(iface, vlans) {
const hasPending = state.pending.includes(iface.name);
const sc = stateClass(iface.state);
const isLo = iface.name === 'lo' || iface.mode === 'loopback';
const isUp = iface.state === 'up';
const label = iface.label || '';
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('');
const nameBlock = label
? `<div class="card-name-stack">
<span class="card-label-text">${label}</span>
<span class="card-iface-sub">${iface.name}</span>
</div>`
: `<span class="card-iface-name">${iface.name}</span>`;
card.innerHTML = `
<div class="card-header">
<div class="card-name">
<span class="state-dot ${sc}"></span>
${nameBlock}
${hasPending ? '<span class="pending-badge">несохранено</span>' : ''}
</div>
<span class="mode-badge ${iface.mode || 'unknown'}">${modeLabel(iface.mode)}</span>
</div>
<div class="card-info">
<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">
${!isLo ? `
<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>
${!isLo ? buildVLANSection(iface.name, vlans) : ''}
`;
return card;
}
function buildVLANSection(parentName, vlans) {
const rows = vlans.map(v => {
const sc = stateClass(v.state);
const hasPending = state.pending.includes(v.name);
const isUp = v.state === 'up';
const label = v.label || '';
const ip = v.ipv4
? v.ipv4 + (v.ipv4_mask ? ' / ' + v.ipv4_mask : '')
: '<span class="none">&mdash;</span>';
return `
<div class="vlan-row" data-name="${v.name}">
<div class="vlan-row-left">
<span class="state-dot ${sc}" style="width:8px;height:8px"></span>
${label
? `<div class="vlan-name-stack"><span class="vlan-label-text">${label}</span><span class="vlan-iface-name">${v.name}</span></div>`
: `<span class="vlan-iface-name">${v.name}</span>`}
<span class="vlan-id-tag">VLAN ${vlanId(v.name)}</span>
<span class="mode-badge ${v.mode || 'unknown'}">${modeLabel(v.mode)}</span>
${hasPending ? '<span class="pending-badge">несохранено</span>' : ''}
</div>
<div class="vlan-row-info">${ip}</div>
<div class="vlan-row-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" 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>
</div>`;
}).join('');
const empty = vlans.length === 0
? `<div class="vlan-empty">Нет тегированных VLAN</div>`
: '';
return `
<div class="vlan-section">
<div class="vlan-header">
<span class="vlan-title">VLAN</span>
<button class="btn btn-ghost btn-xs" data-action="addvlan" data-iface="${parentName}">${ICON.plus} Добавить</button>
</div>
<div class="vlan-list">
${rows}
${empty}
</div>
</div>`;
}
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) {
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(); // refresh to restore correct toggle state
}
}
// ── Config modal ──────────────────────────────────────────────────────────────
async function openConfig(name) {
state.configModal = name;
state.configModalParent = null;
document.getElementById('modalTitle').textContent = `Настройка: ${name}`;
// Show/hide VLAN ID field
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;
fillForm(configData.config, configData.pending, name, configData.label || '');
document.getElementById('modal').classList.remove('hidden');
} catch (e) {
showToast('Ошибка загрузки конфига: ' + e.message, 'error');
}
}
async function openNewVLAN(parentName) {
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, '', '');
document.getElementById('modal').classList.remove('hidden');
}
function fillForm(cfg, pending, name, label = '') {
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(' ');
const mode = cfg.mode === 'static' ? 'static' : 'dhcp';
setMode(mode);
if (pending && name) {
document.getElementById('modalTitle').textContent = `Настройка: ${name} (несохранённые изменения)`;
}
// 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;
state.configModalParent = null;
}
async function saveConfig() {
let name = state.configModal;
if (!name) {
// New VLAN — build name from parent + VLAN ID
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}`;
// Check for duplicate
if (state.interfaces.find(i => i.name === name)) {
showToast(`VLAN ${name} уже существует`, 'error');
return;
}
}
const mode = currentMode();
const cfg = {
name,
label: document.getElementById('cfgLabel').value.trim(),
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: {},
};
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();
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 button clicks (delegated)
document.getElementById('ifaceGrid').addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
// Don't handle toggle inputs here (handled by 'change' below)
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);
}
});
// Toggle switch (on/off) — delegated change event
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');
});
// 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 () => {
await loadAll();
})();