Files
alpine-router/public/app.js

636 lines
21 KiB
JavaScript
Raw Normal View History

2026-04-13 09:46:02 +03:00
'use strict';
const state = {
2026-04-15 11:38:26 +03:00
interfaces: [],
pending: [],
configModal: null,
configModalParent: null,
nat: null,
2026-04-13 09:46:02 +03:00
};
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);
2026-04-13 12:40:49 +03:00
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;
}
2026-04-13 09:46:02 +03:00
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 || '?');
}
2026-04-13 18:56:13 +03:00
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>`,
};
2026-04-15 11:38:26 +03:00
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;
}
2026-04-13 09:46:02 +03:00
function renderAll() {
const grid = document.getElementById('ifaceGrid');
grid.innerHTML = '';
2026-04-13 12:40:49 +03:00
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);
}
}
2026-04-15 11:38:26 +03:00
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');
2026-04-13 12:40:49 +03:00
for (const iface of physicals) {
2026-04-15 11:38:26 +03:00
const isLo = iface.name === 'lo' || iface.mode === 'loopback';
if (isLo) continue;
2026-04-13 12:40:49 +03:00
const vlans = vlansByParent[iface.name] || [];
2026-04-15 11:38:26 +03:00
tbody.appendChild(buildPhysicalRow(iface, vlans));
2026-04-13 12:40:49 +03:00
}
2026-04-13 09:46:02 +03:00
document.getElementById('loading').classList.add('hidden');
grid.classList.remove('hidden');
renderPendingBanner();
}
2026-04-15 11:38:26 +03:00
function buildPhysicalRow(iface, vlans) {
2026-04-13 09:46:02 +03:00
const hasPending = state.pending.includes(iface.name);
const sc = stateClass(iface.state);
2026-04-13 18:56:13 +03:00
const isUp = iface.state === 'up';
2026-04-15 11:38:26 +03:00
const isWAN = iface.type === 'wan';
2026-04-13 18:56:13 +03:00
const label = iface.label || '';
2026-04-15 11:38:26 +03:00
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>`;
2026-04-13 09:46:02 +03:00
2026-04-15 11:38:26 +03:00
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}
2026-04-13 09:46:02 +03:00
${hasPending ? '<span class="pending-badge">несохранено</span>' : ''}
2026-04-15 11:38:26 +03:00
<button class="btn btn-ghost btn-xs${isWAN ? ' hidden' : ''}" data-action="addvlan" data-iface="${iface.name}" style="margin-left:8px">${ICON.plus} VLAN</button>
2026-04-13 09:46:02 +03:00
</div>
2026-04-15 11:38:26 +03:00
</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>
2026-04-13 09:46:02 +03:00
</div>
2026-04-15 11:38:26 +03:00
</td>
2026-04-13 09:46:02 +03:00
`;
2026-04-15 11:38:26 +03:00
const frag = document.createDocumentFragment();
frag.appendChild(tr);
for (const v of vlans) {
frag.appendChild(buildVLANRow(v, iface.name));
}
return frag;
2026-04-13 09:46:02 +03:00
}
2026-04-15 11:38:26 +03:00
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';
2026-04-13 12:40:49 +03:00
const ip = v.ipv4
2026-04-15 11:38:26 +03:00
? v.ipv4 + (v.ipv4_mask ? maskToCIDR(v.ipv4_mask) : '')
2026-04-13 12:40:49 +03:00
: '<span class="none">&mdash;</span>';
2026-04-15 11:38:26 +03:00
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>' : ''}
2026-04-13 12:40:49 +03:00
</div>
2026-04-15 11:38:26 +03:00
</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>
2026-04-13 12:40:49 +03:00
</div>
2026-04-15 11:38:26 +03:00
</td>
`;
return tr;
2026-04-13 12:40:49 +03:00
}
2026-04-13 09:46:02 +03:00
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) {
2026-04-13 12:40:49 +03:00
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;
}
2026-04-13 09:46:02 +03:00
try {
await post(`/api/interfaces/${name}/${action}`);
showToast(`${name}: ${action} выполнено`, 'success');
await loadAll();
} catch (e) {
showToast(`${name} ${action}: ${e.message}`, 'error');
2026-04-15 11:38:26 +03:00
await loadAll();
2026-04-13 09:46:02 +03:00
}
}
async function openConfig(name) {
state.configModal = name;
2026-04-13 12:40:49 +03:00
state.configModalParent = null;
2026-04-13 09:46:02 +03:00
document.getElementById('modalTitle').textContent = `Настройка: ${name}`;
2026-04-13 12:40:49 +03:00
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;
}
2026-04-13 09:46:02 +03:00
try {
2026-04-13 18:56:13 +03:00
const [configData, natData] = await Promise.all([
2026-04-13 09:46:02 +03:00
get(`/api/config/${name}`),
get('/api/nat').catch(() => null),
]);
if (natData) state.nat = natData;
2026-04-15 11:38:26 +03:00
let currentType = configData.type || 'lan';
if (isVLAN(name)) currentType = 'lan';
fillForm(configData.config, configData.pending, name, configData.label || '', currentType, isVLAN(name));
2026-04-13 09:46:02 +03:00
document.getElementById('modal').classList.remove('hidden');
} catch (e) {
showToast('Ошибка загрузки конфига: ' + e.message, 'error');
}
}
2026-04-13 12:40:49 +03:00
async function openNewVLAN(parentName) {
2026-04-15 11:38:26 +03:00
const parentIface = state.interfaces.find(i => i.name === parentName);
if (parentIface && parentIface.type === 'wan') {
showToast('Нельзя добавить VLAN на WAN-интерфейс', 'error');
return;
}
2026-04-13 12:40:49 +03:00
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 (_) {}
2026-04-15 11:38:26 +03:00
fillForm({ auto: true, mode: 'static' }, false, '', '', 'lan', true);
2026-04-13 12:40:49 +03:00
document.getElementById('modal').classList.remove('hidden');
}
2026-04-15 11:38:26 +03:00
function fillForm(cfg, pending, name, label = '', ifaceType = 'lan', forceLAN = false) {
2026-04-13 18:56:13 +03:00
document.getElementById('cfgLabel').value = label;
2026-04-13 09:46:02 +03:00
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(' ');
2026-04-15 11:38:26 +03:00
setType(ifaceType, forceLAN);
2026-04-13 09:46:02 +03:00
const mode = cfg.mode === 'static' ? 'static' : 'dhcp';
setMode(mode);
2026-04-13 12:40:49 +03:00
if (pending && name) {
document.getElementById('modalTitle').textContent = `Настройка: ${name} (несохранённые изменения)`;
2026-04-13 09:46:02 +03:00
}
2026-04-15 11:38:26 +03:00
}
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';
}
2026-04-13 09:46:02 +03:00
2026-04-15 11:38:26 +03:00
function updateTypeVisibility(type) {
const modeRow = document.getElementById('modeRow');
const gatewayRow = document.getElementById('gatewayRow');
const dnsRow = document.getElementById('dnsRow');
2026-04-13 09:46:02 +03:00
const natSection = document.getElementById('natSection');
2026-04-15 11:38:26 +03:00
if (type === 'lan') {
modeRow.classList.add('hidden');
setMode('static');
gatewayRow.classList.add('hidden');
dnsRow.classList.add('hidden');
2026-04-13 09:46:02 +03:00
natSection.classList.remove('hidden');
2026-04-15 11:38:26 +03:00
} 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;
2026-04-13 09:46:02 +03:00
const natInstalled = state.nat?.installed !== false;
cfgNAT.disabled = !natInstalled;
natNotInstalled.classList.toggle('hidden', natInstalled);
cfgNAT.checked = !!(state.nat?.interfaces || []).includes(name);
}
}
function setMode(mode) {
2026-04-15 11:38:26 +03:00
document.querySelectorAll('#modeSwitch .seg-btn').forEach(b => {
2026-04-13 09:46:02 +03:00
b.classList.toggle('active', b.dataset.mode === mode);
});
document.getElementById('staticFields').classList.toggle('hidden', mode !== 'static');
}
function currentMode() {
2026-04-15 11:38:26 +03:00
return document.querySelector('#modeSwitch .seg-btn.active')?.dataset.mode ?? 'dhcp';
2026-04-13 09:46:02 +03:00
}
function closeModal() {
document.getElementById('modal').classList.add('hidden');
document.getElementById('configForm').reset();
state.configModal = null;
2026-04-13 12:40:49 +03:00
state.configModalParent = null;
2026-04-13 09:46:02 +03:00
}
async function saveConfig() {
2026-04-13 12:40:49 +03:00
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;
}
}
2026-04-13 09:46:02 +03:00
2026-04-15 11:38:26 +03:00
const type = currentType();
const mode = type === 'lan' ? 'static' : currentMode();
2026-04-13 09:46:02 +03:00
const cfg = {
name,
2026-04-13 18:56:13 +03:00
label: document.getElementById('cfgLabel').value.trim(),
2026-04-15 11:38:26 +03:00
type,
2026-04-13 09:46:02 +03:00
auto: document.getElementById('cfgAuto').checked,
mode,
address: document.getElementById('cfgAddress').value.trim(),
netmask: document.getElementById('cfgNetmask').value.trim(),
2026-04-15 11:38:26 +03:00
gateway: type === 'wan' ? document.getElementById('cfgGateway').value.trim() : '',
dns: type === 'wan' ? document.getElementById('cfgDNS').value.trim().split(/\s+/).filter(Boolean) : [],
2026-04-13 09:46:02 +03:00
extra: {},
};
if (mode === 'static' && !cfg.address) {
showToast('Укажите IP-адрес', 'error');
return;
}
2026-04-15 11:38:26 +03:00
if (type === 'wan' && mode === 'static' && !cfg.netmask) {
showToast('Укажите маску сети', 'error');
return;
}
2026-04-13 09:46:02 +03:00
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;
2026-04-13 18:56:13 +03:00
if (btn.tagName === 'INPUT' && btn.type === 'checkbox') return;
2026-04-13 09:46:02 +03:00
const { action, iface } = btn.dataset;
2026-04-13 18:56:13 +03:00
if (!action || !iface) return;
2026-04-13 09:46:02 +03:00
if (action === 'config') {
openConfig(iface);
2026-04-13 12:40:49 +03:00
} else if (action === 'addvlan') {
openNewVLAN(iface);
2026-04-13 09:46:02 +03:00
} else {
doAction(iface, action);
}
});
2026-04-13 18:56:13 +03:00
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');
});
2026-04-13 09:46:02 +03:00
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();
});
2026-04-15 11:38:26 +03:00
document.getElementById('typeSwitch').addEventListener('click', e => {
const btn = e.target.closest('.seg-btn');
if (btn) setType(btn.dataset.type);
});
2026-04-13 09:46:02 +03:00
document.getElementById('modeSwitch').addEventListener('click', e => {
const btn = e.target.closest('.seg-btn');
if (btn) setMode(btn.dataset.mode);
});
2026-04-15 11:38:26 +03:00
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;
});
2026-04-13 09:46:02 +03:00
document.getElementById('saveConfigBtn').addEventListener('click', saveConfig);
document.getElementById('configForm').addEventListener('submit', e => {
e.preventDefault();
saveConfig();
});
setInterval(loadAll, 10000);
(async () => {
await loadAll();
2026-04-15 11:38:26 +03:00
})();