14.04.2026 Update
This commit is contained in:
407
public/app.js
407
public/app.js
@@ -1,17 +1,13 @@
|
||||
'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
|
||||
interfaces: [],
|
||||
pending: [],
|
||||
configModal: null,
|
||||
configModalParent: null,
|
||||
nat: null,
|
||||
};
|
||||
|
||||
// ── API helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = {
|
||||
method,
|
||||
@@ -30,8 +26,6 @@ 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);
|
||||
}
|
||||
@@ -43,8 +37,6 @@ function vlanId(name) {
|
||||
return m ? parseInt(m[1]) : 0;
|
||||
}
|
||||
|
||||
// ── Format helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (n === undefined || n === null) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
@@ -64,8 +56,6 @@ 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"/>
|
||||
@@ -83,13 +73,42 @@ const ICON = {
|
||||
</svg>`,
|
||||
};
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────────
|
||||
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 = '';
|
||||
|
||||
// Group VLANs by parent
|
||||
const vlansByParent = {};
|
||||
const physicals = [];
|
||||
|
||||
@@ -103,9 +122,38 @@ function renderAll() {
|
||||
}
|
||||
}
|
||||
|
||||
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] || [];
|
||||
grid.appendChild(buildCard(iface, vlans));
|
||||
tbody.appendChild(buildPhysicalRow(iface, vlans));
|
||||
}
|
||||
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
@@ -114,131 +162,126 @@ function renderAll() {
|
||||
renderPendingBanner();
|
||||
}
|
||||
|
||||
function buildCard(iface, vlans) {
|
||||
function buildPhysicalRow(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 isWAN = iface.type === 'wan';
|
||||
const label = iface.label || '';
|
||||
const ipDisplay = iface.ipv4
|
||||
? iface.ipv4 + (iface.ipv4_mask ? maskToCIDR(iface.ipv4_mask) : '')
|
||||
: '<span class="none">—</span>';
|
||||
const gwDisplay = iface.gateway
|
||||
? iface.gateway
|
||||
: '<span class="none">—</span>';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'iface-card' + (hasPending ? ' has-pending' : '');
|
||||
card.dataset.name = iface.name;
|
||||
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 ipv6lines = (iface.ipv6 || []).map(a =>
|
||||
`<div class="info-row"><span class="info-label">IPv6</span><span class="info-val">${a}</span></div>`
|
||||
).join('');
|
||||
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 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>`;
|
||||
const typeBadge = isWAN
|
||||
? '<span class="type-badge type-wan">WAN</span>'
|
||||
: '<span class="type-badge type-lan">LAN</span>';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-header">
|
||||
<div class="card-name">
|
||||
<span class="state-dot ${sc}"></span>
|
||||
${nameBlock}
|
||||
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>
|
||||
<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">—</span>'}</span>
|
||||
</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>
|
||||
${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">
|
||||
${!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) : ''}
|
||||
</td>
|
||||
`;
|
||||
|
||||
return card;
|
||||
const frag = document.createDocumentFragment();
|
||||
frag.appendChild(tr);
|
||||
|
||||
for (const v of vlans) {
|
||||
frag.appendChild(buildVLANRow(v, iface.name));
|
||||
}
|
||||
|
||||
|
||||
return frag;
|
||||
}
|
||||
|
||||
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 || '';
|
||||
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 ? ' / ' + v.ipv4_mask : '')
|
||||
? v.ipv4 + (v.ipv4_mask ? maskToCIDR(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>
|
||||
${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 gwDisplay = v.gateway ? v.gateway : '<span class="none">—</span>';
|
||||
const typeBadge = isWAN
|
||||
? '<span class="type-badge type-wan">WAN</span>'
|
||||
: '<span class="type-badge type-lan">LAN</span>';
|
||||
|
||||
const empty = vlans.length === 0
|
||||
? `<div class="vlan-empty">Нет тегированных VLAN</div>`
|
||||
: '';
|
||||
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>`;
|
||||
|
||||
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>
|
||||
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>
|
||||
<div class="vlan-list">
|
||||
${rows}
|
||||
${empty}
|
||||
</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>
|
||||
</div>`;
|
||||
</td>
|
||||
`;
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
function renderPendingBanner() {
|
||||
@@ -252,8 +295,6 @@ function renderPendingBanner() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data loading ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
const [ifaces, pending] = await Promise.all([
|
||||
@@ -268,8 +309,6 @@ async function loadAll() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Interface actions ─────────────────────────────────────────────────────────
|
||||
|
||||
async function doAction(name, action) {
|
||||
if (action === 'delete') {
|
||||
if (!confirm(`Удалить VLAN ${name}?`)) return;
|
||||
@@ -289,18 +328,15 @@ async function doAction(name, action) {
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
showToast(`${name} ${action}: ${e.message}`, 'error');
|
||||
await loadAll(); // refresh to restore correct toggle state
|
||||
await loadAll();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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)) {
|
||||
@@ -318,7 +354,9 @@ async function openConfig(name) {
|
||||
get('/api/nat').catch(() => null),
|
||||
]);
|
||||
if (natData) state.nat = natData;
|
||||
fillForm(configData.config, configData.pending, name, configData.label || '');
|
||||
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');
|
||||
@@ -326,6 +364,11 @@ async function openConfig(name) {
|
||||
}
|
||||
|
||||
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}`;
|
||||
@@ -341,11 +384,11 @@ async function openNewVLAN(parentName) {
|
||||
if (natData) state.nat = natData;
|
||||
} catch (_) {}
|
||||
|
||||
fillForm({ auto: true, mode: 'static' }, false, '', '');
|
||||
fillForm({ auto: true, mode: 'static' }, false, '', '', 'lan', true);
|
||||
document.getElementById('modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function fillForm(cfg, pending, name, label = '') {
|
||||
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 || '';
|
||||
@@ -353,22 +396,56 @@ function fillForm(cfg, pending, name, label = '') {
|
||||
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} (несохранённые изменения)`;
|
||||
}
|
||||
}
|
||||
|
||||
// NAT section — show for all non-loopback interfaces
|
||||
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');
|
||||
const natNotInstalled = document.getElementById('natNotInstalled');
|
||||
const cfgNAT = document.getElementById('cfgNAT');
|
||||
|
||||
if (cfg.mode === 'loopback' || name === 'lo') {
|
||||
natSection.classList.add('hidden');
|
||||
} else {
|
||||
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);
|
||||
@@ -377,14 +454,14 @@ function fillForm(cfg, pending, name, label = '') {
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
document.querySelectorAll('.seg-btn').forEach(b => {
|
||||
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('.seg-btn.active')?.dataset.mode ?? 'dhcp';
|
||||
return document.querySelector('#modeSwitch .seg-btn.active')?.dataset.mode ?? 'dhcp';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
@@ -398,7 +475,6 @@ 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;
|
||||
@@ -408,23 +484,24 @@ async function saveConfig() {
|
||||
}
|
||||
name = `${parent}.${id}`;
|
||||
|
||||
// Check for duplicate
|
||||
if (state.interfaces.find(i => i.name === name)) {
|
||||
showToast(`VLAN ${name} уже существует`, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const mode = currentMode();
|
||||
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: document.getElementById('cfgGateway').value.trim(),
|
||||
dns: document.getElementById('cfgDNS').value.trim().split(/\s+/).filter(Boolean),
|
||||
gateway: type === 'wan' ? document.getElementById('cfgGateway').value.trim() : '',
|
||||
dns: type === 'wan' ? document.getElementById('cfgDNS').value.trim().split(/\s+/).filter(Boolean) : [],
|
||||
extra: {},
|
||||
};
|
||||
|
||||
@@ -433,10 +510,14 @@ async function saveConfig() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'wan' && mode === 'static' && !cfg.netmask) {
|
||||
showToast('Укажите маску сети', '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;
|
||||
@@ -456,8 +537,6 @@ async function saveConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Apply / discard ───────────────────────────────────────────────────────────
|
||||
|
||||
async function applyAll() {
|
||||
const btn = document.getElementById('applyBtn');
|
||||
btn.disabled = true;
|
||||
@@ -484,8 +563,6 @@ async function discardAll() {
|
||||
await loadAll();
|
||||
}
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let toastTimer;
|
||||
function showToast(msg, type = 'info') {
|
||||
const t = document.getElementById('toast');
|
||||
@@ -496,17 +573,12 @@ function showToast(msg, type = 'info') {
|
||||
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;
|
||||
@@ -519,14 +591,12 @@ document.getElementById('ifaceGrid').addEventListener('click', e => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
@@ -534,24 +604,33 @@ document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
|
||||
// Mode switcher
|
||||
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);
|
||||
});
|
||||
|
||||
// Save config
|
||||
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();
|
||||
});
|
||||
|
||||
// Auto-refresh every 10 seconds
|
||||
setInterval(loadAll, 10000);
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
(async () => {
|
||||
await loadAll();
|
||||
})();
|
||||
})();
|
||||
Reference in New Issue
Block a user