2026-04-13 09:46:02 +03:00
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
|
|
// ── State ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const state = {
|
|
|
|
|
|
interfaces: [], // latest data from /api/interfaces
|
|
|
|
|
|
pending: [], // interface names with pending config
|
2026-04-13 12:40:49 +03:00
|
|
|
|
configModal: null, // name of interface being configured (null = new VLAN)
|
|
|
|
|
|
configModalParent: null, // parent interface when creating a new VLAN
|
2026-04-13 09:46:02 +03:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-04-13 12:40:49 +03:00
|
|
|
|
// ── 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 09:46:02 +03:00
|
|
|
|
// ── 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 = '';
|
|
|
|
|
|
|
2026-04-13 12:40:49 +03:00
|
|
|
|
// 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));
|
|
|
|
|
|
}
|
2026-04-13 09:46:02 +03:00
|
|
|
|
|
|
|
|
|
|
document.getElementById('loading').classList.add('hidden');
|
|
|
|
|
|
grid.classList.remove('hidden');
|
|
|
|
|
|
|
|
|
|
|
|
renderPendingBanner();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 12:40:49 +03:00
|
|
|
|
function buildCard(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 12:40:49 +03:00
|
|
|
|
const isLo = iface.name === 'lo' || iface.mode === 'loopback';
|
2026-04-13 09:46:02 +03:00
|
|
|
|
|
|
|
|
|
|
const card = document.createElement('div');
|
|
|
|
|
|
card.className = 'iface-card' + (hasPending ? ' has-pending' : '');
|
|
|
|
|
|
card.dataset.name = iface.name;
|
|
|
|
|
|
|
|
|
|
|
|
const ipv6lines = (iface.ipv6 || []).map(a =>
|
|
|
|
|
|
`<div class="info-row"><span class="info-label">IPv6</span><span class="info-val">${a}</span></div>`
|
|
|
|
|
|
).join('');
|
|
|
|
|
|
|
|
|
|
|
|
card.innerHTML = `
|
|
|
|
|
|
<div class="card-header">
|
|
|
|
|
|
<div class="card-name">
|
|
|
|
|
|
<span class="state-dot ${sc}"></span>
|
|
|
|
|
|
<span>${iface.name}</span>
|
|
|
|
|
|
${hasPending ? '<span class="pending-badge">несохранено</span>' : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="display:flex;gap:6px;align-items:center">
|
|
|
|
|
|
<span class="mode-badge ${iface.mode || 'unknown'}">${modeLabel(iface.mode)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="card-info">
|
|
|
|
|
|
<div class="info-row">
|
|
|
|
|
|
<span class="info-label">Статус</span>
|
|
|
|
|
|
<span class="info-val">${iface.state || 'unknown'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-row">
|
|
|
|
|
|
<span class="info-label">IPv4</span>
|
|
|
|
|
|
<span class="info-val">${iface.ipv4
|
|
|
|
|
|
? iface.ipv4 + (iface.ipv4_mask ? ' / ' + iface.ipv4_mask : '')
|
|
|
|
|
|
: '<span class="none">—</span>'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
${ipv6lines || `<div class="info-row"><span class="info-label">IPv6</span><span class="info-val none">—</span></div>`}
|
|
|
|
|
|
<div class="info-row">
|
|
|
|
|
|
<span class="info-label">Шлюз</span>
|
|
|
|
|
|
<span class="info-val">${iface.gateway || '<span class="none">—</span>'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="traffic-row">
|
|
|
|
|
|
<div class="traffic-item">
|
|
|
|
|
|
<span class="traffic-label">RX</span>
|
|
|
|
|
|
<span class="traffic-val">${fmtBytes(iface.rx_bytes)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="traffic-item">
|
|
|
|
|
|
<span class="traffic-label">TX</span>
|
|
|
|
|
|
<span class="traffic-val">${fmtBytes(iface.tx_bytes)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="traffic-item">
|
|
|
|
|
|
<span class="traffic-label">Пакеты</span>
|
|
|
|
|
|
<span class="traffic-val">${iface.rx_packets ?? 0} / ${iface.tx_packets ?? 0}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="card-actions">
|
|
|
|
|
|
<button class="btn btn-success btn-sm" data-action="up" data-iface="${iface.name}">ON</button>
|
|
|
|
|
|
<button class="btn btn-danger btn-sm" data-action="down" data-iface="${iface.name}">OFF</button>
|
|
|
|
|
|
<button class="btn btn-ghost btn-sm" data-action="restart" data-iface="${iface.name}">RESTART</button>
|
|
|
|
|
|
<button class="btn btn-primary btn-sm" data-action="config" data-iface="${iface.name}" style="margin-left:auto">CONFIG</button>
|
|
|
|
|
|
</div>
|
2026-04-13 12:40:49 +03:00
|
|
|
|
|
|
|
|
|
|
${!isLo ? buildVLANSection(iface.name, vlans) : ''}
|
2026-04-13 09:46:02 +03:00
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
return card;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 12:40:49 +03:00
|
|
|
|
function buildVLANSection(parentName, vlans) {
|
|
|
|
|
|
const rows = vlans.map(v => {
|
|
|
|
|
|
const sc = stateClass(v.state);
|
|
|
|
|
|
const hasPending = state.pending.includes(v.name);
|
|
|
|
|
|
const ip = v.ipv4
|
|
|
|
|
|
? v.ipv4 + (v.ipv4_mask ? ' / ' + v.ipv4_mask : '')
|
|
|
|
|
|
: '<span class="none">—</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>
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<button class="btn btn-success btn-xs" data-action="up" data-iface="${v.name}">ON</button>
|
|
|
|
|
|
<button class="btn btn-danger btn-xs" data-action="down" data-iface="${v.name}">OFF</button>
|
|
|
|
|
|
<button class="btn btn-primary btn-xs" data-action="config" data-iface="${v.name}">CONFIG</button>
|
|
|
|
|
|
<button class="btn btn-danger btn-xs" data-action="delete" data-iface="${v.name}" title="Удалить VLAN">✕</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}">+ Добавить</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="vlan-list">
|
|
|
|
|
|
${rows}
|
|
|
|
|
|
${empty}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 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) {
|
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
|
|
|
|
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;
|
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
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 09:46:02 +03:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 12:40:49 +03:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 09:46:02 +03:00
|
|
|
|
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
|
2026-04-13 12:40:49 +03:00
|
|
|
|
if (pending && name) {
|
|
|
|
|
|
document.getElementById('modalTitle').textContent = `Настройка: ${name} (несохранённые изменения)`;
|
2026-04-13 09:46:02 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
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) {
|
|
|
|
|
|
// 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 (1–4094)', 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
name = `${parent}.${id}`;
|
|
|
|
|
|
|
|
|
|
|
|
// Check for duplicate
|
|
|
|
|
|
if (state.interfaces.find(i => i.name === name)) {
|
|
|
|
|
|
showToast(`VLAN ${name} уже существует`, 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-13 09:46:02 +03:00
|
|
|
|
|
|
|
|
|
|
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: {},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
})();
|