|
@@ -81,14 +81,145 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
initTableSorting();
|
|
initTableSorting();
|
|
|
|
|
|
|
|
fetchData();
|
|
fetchData();
|
|
|
- // Live polling every 2.5 seconds
|
|
|
|
|
|
|
+ initLiveTrafficStream();
|
|
|
|
|
+
|
|
|
|
|
+ // Full state telemetry polling every 5 seconds (smooth & flicker-free)
|
|
|
setInterval(() => {
|
|
setInterval(() => {
|
|
|
if (state.isPolling) {
|
|
if (state.isPolling) {
|
|
|
fetchData(false);
|
|
fetchData(false);
|
|
|
}
|
|
}
|
|
|
- }, 2500);
|
|
|
|
|
|
|
+ }, 5000);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+let trafficEventSource = null;
|
|
|
|
|
+let trafficPollFallback = null;
|
|
|
|
|
+
|
|
|
|
|
+function initLiveTrafficStream() {
|
|
|
|
|
+ if (trafficEventSource) {
|
|
|
|
|
+ try { trafficEventSource.close(); } catch(e) {}
|
|
|
|
|
+ trafficEventSource = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (trafficPollFallback) {
|
|
|
|
|
+ clearInterval(trafficPollFallback);
|
|
|
|
|
+ trafficPollFallback = null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (window.EventSource) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ trafficEventSource = new EventSource(`${API_BASE}/interfaces/traffic/stream`);
|
|
|
|
|
+ trafficEventSource.onmessage = (event) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const data = JSON.parse(event.data);
|
|
|
|
|
+ updateLiveInterfaceTraffic(data);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error('Error parsing live traffic event:', e);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ trafficEventSource.onerror = () => {
|
|
|
|
|
+ // Fallback polling if SSE disconnects
|
|
|
|
|
+ if (!trafficPollFallback) {
|
|
|
|
|
+ trafficPollFallback = setInterval(pollLiveTrafficSnapshot, 1000);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ trafficEventSource.onopen = () => {
|
|
|
|
|
+ if (trafficPollFallback) {
|
|
|
|
|
+ clearInterval(trafficPollFallback);
|
|
|
|
|
+ trafficPollFallback = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ return;
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.warn('EventSource failed, using 1s polling fallback:', e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Fallback if EventSource is unavailable
|
|
|
|
|
+ trafficPollFallback = setInterval(pollLiveTrafficSnapshot, 1000);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function pollLiveTrafficSnapshot() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch(`${API_BASE}/interfaces/traffic/rates`);
|
|
|
|
|
+ if (res.ok) {
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ updateLiveInterfaceTraffic(data);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function updateLiveInterfaceTraffic(data) {
|
|
|
|
|
+ if (!data || !data.interfaces) return;
|
|
|
|
|
+ state.latestTraffic = data;
|
|
|
|
|
+
|
|
|
|
|
+ // 1. Update Top & Dashboard Total Bandwidth Indicators
|
|
|
|
|
+ const rxHuman = data.total_rx_human || '0 B/s';
|
|
|
|
|
+ const txHuman = data.total_tx_human || '0 B/s';
|
|
|
|
|
+ const statRx = document.getElementById('stat-traffic-rx');
|
|
|
|
|
+ if (statRx && statRx.innerText !== rxHuman) statRx.innerText = rxHuman;
|
|
|
|
|
+ const statTx = document.getElementById('stat-traffic-tx');
|
|
|
|
|
+ if (statTx && statTx.innerText !== txHuman) statTx.innerText = txHuman;
|
|
|
|
|
+
|
|
|
|
|
+ // 2. Update state.interfaces rates in-memory
|
|
|
|
|
+ if (Array.isArray(state.interfaces)) {
|
|
|
|
|
+ state.interfaces.forEach(i => {
|
|
|
|
|
+ const live = data.interfaces[i.name];
|
|
|
|
|
+ if (live) {
|
|
|
|
|
+ i.rate = {
|
|
|
|
|
+ rx_bytes_sec: live.rx_bytes_sec,
|
|
|
|
|
+ tx_bytes_sec: live.tx_bytes_sec,
|
|
|
|
|
+ rx_human: live.rx_human,
|
|
|
|
|
+ tx_human: live.tx_human
|
|
|
|
|
+ };
|
|
|
|
|
+ i.stats = {
|
|
|
|
|
+ rx_bytes: live.rx_bytes,
|
|
|
|
|
+ tx_bytes: live.tx_bytes,
|
|
|
|
|
+ rx_packets: live.rx_packets,
|
|
|
|
|
+ tx_packets: live.tx_packets,
|
|
|
|
|
+ rx_errors: live.rx_errors,
|
|
|
|
|
+ tx_errors: live.tx_errors,
|
|
|
|
|
+ rx_dropped: live.rx_dropped,
|
|
|
|
|
+ tx_dropped: live.tx_dropped
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 3. Update all live UI rate spans in-place (ZERO flicker)
|
|
|
|
|
+ Object.keys(data.interfaces).forEach(name => {
|
|
|
|
|
+ const live = data.interfaces[name];
|
|
|
|
|
+
|
|
|
|
|
+ // Dashboard Port Matrix Card
|
|
|
|
|
+ const cardRate = document.getElementById(`port-card-rate-${name}`);
|
|
|
|
|
+ if (cardRate && cardRate.innerText !== live.rx_human) {
|
|
|
|
|
+ cardRate.innerText = live.rx_human;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Dashboard Quick Interfaces Table
|
|
|
|
|
+ const quickRx = document.getElementById(`quick-rx-${name}`);
|
|
|
|
|
+ if (quickRx) quickRx.innerText = `↓ ${live.rx_human}`;
|
|
|
|
|
+ const quickTx = document.getElementById(`quick-tx-${name}`);
|
|
|
|
|
+ if (quickTx) quickTx.innerText = `↑ ${live.tx_human}`;
|
|
|
|
|
+
|
|
|
|
|
+ // Main Interfaces Tree Table
|
|
|
|
|
+ const ifaceRx = document.getElementById(`iface-rate-rx-${name}`);
|
|
|
|
|
+ if (ifaceRx) ifaceRx.innerText = `↓ ${live.rx_human}`;
|
|
|
|
|
+ const ifaceTx = document.getElementById(`iface-rate-tx-${name}`);
|
|
|
|
|
+ if (ifaceTx) ifaceTx.innerText = `↑ ${live.tx_human}`;
|
|
|
|
|
+
|
|
|
|
|
+ // VLAN Sub-interfaces Table
|
|
|
|
|
+ const vlanRx = document.getElementById(`vlan-rate-rx-${name}`);
|
|
|
|
|
+ if (vlanRx) vlanRx.innerText = `↓ ${live.rx_human}`;
|
|
|
|
|
+ const vlanTx = document.getElementById(`vlan-rate-tx-${name}`);
|
|
|
|
|
+ if (vlanTx) vlanTx.innerText = `↑ ${live.tx_human}`;
|
|
|
|
|
+
|
|
|
|
|
+ // VLAN Page Table
|
|
|
|
|
+ const vlanPageRx = document.getElementById(`vlan-page-rate-rx-${name}`);
|
|
|
|
|
+ if (vlanPageRx) vlanPageRx.innerText = `↓ ${live.rx_human}`;
|
|
|
|
|
+ const vlanPageTx = document.getElementById(`vlan-page-rate-tx-${name}`);
|
|
|
|
|
+ if (vlanPageTx) vlanPageTx.innerText = `↑ ${live.tx_human}`;
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// Navigation Handler
|
|
// Navigation Handler
|
|
|
function navigate(tabName) {
|
|
function navigate(tabName) {
|
|
|
if (tabName === 'dynamic-routing' || tabName === 'routing') {
|
|
if (tabName === 'dynamic-routing' || tabName === 'routing') {
|
|
@@ -508,6 +639,14 @@ function updateBadges() {
|
|
|
// Render Dashboard View
|
|
// Render Dashboard View
|
|
|
function renderDashboard() {
|
|
function renderDashboard() {
|
|
|
const matrix = document.getElementById('port-matrix');
|
|
const matrix = document.getElementById('port-matrix');
|
|
|
|
|
+ if (!matrix) return;
|
|
|
|
|
+
|
|
|
|
|
+ const ifaces = state.interfaces || [];
|
|
|
|
|
+ const sig = ifaces.map(i => `${i.name}:${i.state}:${(i.ipv4 || []).join(',')}:${i.type}:${i.is_vlan}`).join(';');
|
|
|
|
|
+ if (matrix.dataset.sig === sig) {
|
|
|
|
|
+ return; // Structure & link states haven't changed! Do not rebuild or flicker!
|
|
|
|
|
+ }
|
|
|
|
|
+ matrix.dataset.sig = sig;
|
|
|
matrix.innerHTML = '';
|
|
matrix.innerHTML = '';
|
|
|
|
|
|
|
|
(state.interfaces || []).forEach(iface => {
|
|
(state.interfaces || []).forEach(iface => {
|
|
@@ -533,7 +672,7 @@ function renderDashboard() {
|
|
|
<div class="text-[11px] font-mono text-slate-300 truncate">${iface.ipv4 && iface.ipv4.length > 0 ? iface.ipv4[0] : (iface.mac || 'No IP')}</div>
|
|
<div class="text-[11px] font-mono text-slate-300 truncate">${iface.ipv4 && iface.ipv4.length > 0 ? iface.ipv4[0] : (iface.mac || 'No IP')}</div>
|
|
|
<div class="mt-2 flex items-center justify-between text-[10px] text-slate-400">
|
|
<div class="mt-2 flex items-center justify-between text-[10px] text-slate-400">
|
|
|
<span class="uppercase tracking-wider font-semibold">${isVlan ? `VLAN ${iface.vlan_id}` : iface.type}</span>
|
|
<span class="uppercase tracking-wider font-semibold">${isVlan ? `VLAN ${iface.vlan_id}` : iface.type}</span>
|
|
|
- <span>${iface.rate?.rx_human || '0 B/s'}</span>
|
|
|
|
|
|
|
+ <span id="port-card-rate-${iface.name}">${iface.rate?.rx_human || '0 B/s'}</span>
|
|
|
</div>
|
|
</div>
|
|
|
`;
|
|
`;
|
|
|
matrix.appendChild(portCard);
|
|
matrix.appendChild(portCard);
|
|
@@ -560,7 +699,7 @@ function renderDashboard() {
|
|
|
<td class="py-2.5 text-slate-400 text-[11px] font-mono">${i.mac || '-'}</td>
|
|
<td class="py-2.5 text-slate-400 text-[11px] font-mono">${i.mac || '-'}</td>
|
|
|
<td class="py-2.5 text-slate-200 font-semibold">${i.ipv4 && i.ipv4.length > 0 ? i.ipv4.join(', ') : '<span class="text-slate-600 font-normal">Unassigned</span>'}</td>
|
|
<td class="py-2.5 text-slate-200 font-semibold">${i.ipv4 && i.ipv4.length > 0 ? i.ipv4.join(', ') : '<span class="text-slate-600 font-normal">Unassigned</span>'}</td>
|
|
|
<td class="py-2.5 text-slate-300 text-[11px] font-mono">
|
|
<td class="py-2.5 text-slate-300 text-[11px] font-mono">
|
|
|
- <span class="text-emerald-400">↓ ${rxStr}</span> <span class="text-slate-600">•</span> <span class="text-cyan-400">↑ ${txStr}</span>
|
|
|
|
|
|
|
+ <span id="quick-rx-${i.name}" class="text-emerald-400">↓ ${rxStr}</span> <span class="text-slate-600">•</span> <span id="quick-tx-${i.name}" class="text-cyan-400">↑ ${txStr}</span>
|
|
|
</td>
|
|
</td>
|
|
|
<td class="py-2.5 text-right font-sans font-semibold">
|
|
<td class="py-2.5 text-right font-sans font-semibold">
|
|
|
<span class="px-2 py-0.5 rounded text-[10px] ${isUp ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'bg-rose-500/10 text-rose-400 border border-rose-500/20'}">${i.state}</span>
|
|
<span class="px-2 py-0.5 rounded text-[10px] ${isUp ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'bg-rose-500/10 text-rose-400 border border-rose-500/20'}">${i.state}</span>
|
|
@@ -656,8 +795,6 @@ function renderInterfacesList() {
|
|
|
const summary = document.getElementById('ifaces-list-summary');
|
|
const summary = document.getElementById('ifaces-list-summary');
|
|
|
const query = (document.getElementById('iface-search')?.value || '').trim().toLowerCase();
|
|
const query = (document.getElementById('iface-search')?.value || '').trim().toLowerCase();
|
|
|
|
|
|
|
|
- tbody.innerHTML = '';
|
|
|
|
|
-
|
|
|
|
|
const allIfaces = state.interfaces || [];
|
|
const allIfaces = state.interfaces || [];
|
|
|
const allVlans = state.vlans || [];
|
|
const allVlans = state.vlans || [];
|
|
|
|
|
|
|
@@ -692,10 +829,22 @@ function renderInterfacesList() {
|
|
|
|
|
|
|
|
if (filteredParents.length === 0) {
|
|
if (filteredParents.length === 0) {
|
|
|
emptyState?.classList.remove('hidden');
|
|
emptyState?.classList.remove('hidden');
|
|
|
|
|
+ tbody.innerHTML = '';
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
emptyState?.classList.add('hidden');
|
|
emptyState?.classList.add('hidden');
|
|
|
|
|
|
|
|
|
|
+ const sig = `${state.ifaceFilter}|${query}|` + filteredParents.map(p => {
|
|
|
|
|
+ const cVlans = allVlans.filter(v => v.parent_name === p.name || v.name.startsWith(p.name + '.'));
|
|
|
|
|
+ return `${p.name}:${p.state}:${(p.ipv4||[]).join(',')}:${p.mtu}:${cVlans.length}:${state.expandedParents.has(p.name)}`;
|
|
|
|
|
+ }).join(';');
|
|
|
|
|
+
|
|
|
|
|
+ if (tbody.dataset.sig === sig) {
|
|
|
|
|
+ return; // ZERO changes to structure, links, IPs or VLANs! Do not rebuild DOM!
|
|
|
|
|
+ }
|
|
|
|
|
+ tbody.dataset.sig = sig;
|
|
|
|
|
+ tbody.innerHTML = '';
|
|
|
|
|
+
|
|
|
filteredParents.forEach(parent => {
|
|
filteredParents.forEach(parent => {
|
|
|
const isUp = parent.state === 'UP';
|
|
const isUp = parent.state === 'UP';
|
|
|
const childVlans = allVlans.filter(v => v.parent_name === parent.name || v.name.startsWith(parent.name + '.'));
|
|
const childVlans = allVlans.filter(v => v.parent_name === parent.name || v.name.startsWith(parent.name + '.'));
|
|
@@ -766,8 +915,8 @@ function renderInterfacesList() {
|
|
|
</td>
|
|
</td>
|
|
|
<td class="py-3 px-4 text-[11px]">
|
|
<td class="py-3 px-4 text-[11px]">
|
|
|
<div class="flex items-center space-x-2">
|
|
<div class="flex items-center space-x-2">
|
|
|
- <span class="text-emerald-400">↓ ${parent.rate?.rx_human || '0 B/s'}</span>
|
|
|
|
|
- <span class="text-cyan-400">↑ ${parent.rate?.tx_human || '0 B/s'}</span>
|
|
|
|
|
|
|
+ <span id="iface-rate-rx-${parent.name}" class="text-emerald-400">↓ ${parent.rate?.rx_human || '0 B/s'}</span>
|
|
|
|
|
+ <span id="iface-rate-tx-${parent.name}" class="text-cyan-400">↑ ${parent.rate?.tx_human || '0 B/s'}</span>
|
|
|
</div>
|
|
</div>
|
|
|
</td>
|
|
</td>
|
|
|
<td class="py-3 px-4 font-sans">
|
|
<td class="py-3 px-4 font-sans">
|
|
@@ -880,8 +1029,8 @@ function renderInterfacesList() {
|
|
|
</td>
|
|
</td>
|
|
|
<td class="py-2.5 px-4 text-slate-400">${v.mtu}</td>
|
|
<td class="py-2.5 px-4 text-slate-400">${v.mtu}</td>
|
|
|
<td class="py-2.5 px-4 text-[11px]">
|
|
<td class="py-2.5 px-4 text-[11px]">
|
|
|
- <span class="text-emerald-400">↓ ${v.rate?.rx_human || '0 B/s'}</span>
|
|
|
|
|
- <span class="text-cyan-400 ml-1.5">↑ ${v.rate?.tx_human || '0 B/s'}</span>
|
|
|
|
|
|
|
+ <span id="vlan-rate-rx-${v.name}" class="text-emerald-400">↓ ${v.rate?.rx_human || '0 B/s'}</span>
|
|
|
|
|
+ <span id="vlan-rate-tx-${v.name}" class="text-cyan-400 ml-1.5">↑ ${v.rate?.tx_human || '0 B/s'}</span>
|
|
|
</td>
|
|
</td>
|
|
|
<td class="py-2.5 px-4 font-sans">
|
|
<td class="py-2.5 px-4 font-sans">
|
|
|
<button onclick="toggleInterfaceState('${v.name}', '${isVlanUp ? 'down' : 'up'}')"
|
|
<button onclick="toggleInterfaceState('${v.name}', '${isVlanUp ? 'down' : 'up'}')"
|
|
@@ -926,7 +1075,6 @@ function renderVlansList() {
|
|
|
const countBadge = document.getElementById('vlans-table-count');
|
|
const countBadge = document.getElementById('vlans-table-count');
|
|
|
const query = (document.getElementById('vlan-search')?.value || '').toLowerCase();
|
|
const query = (document.getElementById('vlan-search')?.value || '').toLowerCase();
|
|
|
|
|
|
|
|
- tbody.innerHTML = '';
|
|
|
|
|
const allVlans = state.vlans || [];
|
|
const allVlans = state.vlans || [];
|
|
|
if (countBadge) countBadge.innerText = allVlans.length;
|
|
if (countBadge) countBadge.innerText = allVlans.length;
|
|
|
|
|
|
|
@@ -940,10 +1088,16 @@ function renderVlansList() {
|
|
|
|
|
|
|
|
if (allVlans.length === 0) {
|
|
if (allVlans.length === 0) {
|
|
|
emptyState.classList.remove('hidden');
|
|
emptyState.classList.remove('hidden');
|
|
|
|
|
+ tbody.innerHTML = '';
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
emptyState.classList.add('hidden');
|
|
emptyState.classList.add('hidden');
|
|
|
|
|
|
|
|
|
|
+ const sig = `${query}|` + filtered.map(v => `${v.name}:${v.state}:${(v.ipv4||[]).join(',')}:${v.vlan_id}:${v.mtu}`).join(';');
|
|
|
|
|
+ if (tbody.dataset.sig === sig) return;
|
|
|
|
|
+ tbody.dataset.sig = sig;
|
|
|
|
|
+ tbody.innerHTML = '';
|
|
|
|
|
+
|
|
|
filtered.forEach(v => {
|
|
filtered.forEach(v => {
|
|
|
const isUp = v.state === 'UP';
|
|
const isUp = v.state === 'UP';
|
|
|
const tr = document.createElement('tr');
|
|
const tr = document.createElement('tr');
|
|
@@ -3207,10 +3361,31 @@ async function loadFirewall(populateDropdowns = true) {
|
|
|
const bGeo = document.getElementById('fw-badge-geo');
|
|
const bGeo = document.getElementById('fw-badge-geo');
|
|
|
if (bGeo) bGeo.innerText = state.geoStatus?.enabled_count || 0;
|
|
if (bGeo) bGeo.innerText = state.geoStatus?.enabled_count || 0;
|
|
|
|
|
|
|
|
- renderFilterRules();
|
|
|
|
|
- renderNATRules();
|
|
|
|
|
- renderMangleRules();
|
|
|
|
|
- renderAddressLists();
|
|
|
|
|
|
|
+ const sigFilter = (state.filterRules || []).map(r => `${r.id}:${r.chain}:${r.action}:${r.disabled}`).join(';');
|
|
|
|
|
+ const sigNat = (state.natRules || []).map(r => `${r.id}:${r.chain}:${r.action}:${r.disabled}`).join(';');
|
|
|
|
|
+ const sigMangle = (state.mangleRules || []).map(r => `${r.id}:${r.chain}:${r.action}:${r.disabled}`).join(';');
|
|
|
|
|
+ const sigAddr = (state.addressLists || []).map(a => `${a.id}:${a.name}:${a.address}:${a.disabled}`).join(';');
|
|
|
|
|
+
|
|
|
|
|
+ const tbodyFilter = document.getElementById('filter-rules-table-body');
|
|
|
|
|
+ if (tbodyFilter && (populateDropdowns || tbodyFilter.dataset.sig !== sigFilter)) {
|
|
|
|
|
+ tbodyFilter.dataset.sig = sigFilter;
|
|
|
|
|
+ renderFilterRules();
|
|
|
|
|
+ }
|
|
|
|
|
+ const tbodyNat = document.getElementById('nat-rules-table-body');
|
|
|
|
|
+ if (tbodyNat && (populateDropdowns || tbodyNat.dataset.sig !== sigNat)) {
|
|
|
|
|
+ tbodyNat.dataset.sig = sigNat;
|
|
|
|
|
+ renderNATRules();
|
|
|
|
|
+ }
|
|
|
|
|
+ const tbodyMangle = document.getElementById('mangle-rules-table-body');
|
|
|
|
|
+ if (tbodyMangle && (populateDropdowns || tbodyMangle.dataset.sig !== sigMangle)) {
|
|
|
|
|
+ tbodyMangle.dataset.sig = sigMangle;
|
|
|
|
|
+ renderMangleRules();
|
|
|
|
|
+ }
|
|
|
|
|
+ const tbodyAddr = document.getElementById('addr-list-table-body');
|
|
|
|
|
+ if (tbodyAddr && (populateDropdowns || tbodyAddr.dataset.sig !== sigAddr)) {
|
|
|
|
|
+ tbodyAddr.dataset.sig = sigAddr;
|
|
|
|
|
+ renderAddressLists();
|
|
|
|
|
+ }
|
|
|
renderGeoCountries();
|
|
renderGeoCountries();
|
|
|
|
|
|
|
|
if (state.fwSubTab === 'connections') {
|
|
if (state.fwSubTab === 'connections') {
|
|
@@ -6100,7 +6275,6 @@ function renderRoutesList() {
|
|
|
const tbody = document.getElementById('routes-table-body');
|
|
const tbody = document.getElementById('routes-table-body');
|
|
|
const empty = document.getElementById('routes-empty');
|
|
const empty = document.getElementById('routes-empty');
|
|
|
if (!tbody) return;
|
|
if (!tbody) return;
|
|
|
- tbody.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
// Update table filter select
|
|
// Update table filter select
|
|
|
const sel = document.getElementById('routes-filter-table');
|
|
const sel = document.getElementById('routes-filter-table');
|
|
@@ -6139,10 +6313,18 @@ function renderRoutesList() {
|
|
|
|
|
|
|
|
if (routes.length === 0) {
|
|
if (routes.length === 0) {
|
|
|
empty?.classList.remove('hidden');
|
|
empty?.classList.remove('hidden');
|
|
|
|
|
+ tbody.innerHTML = '';
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
empty?.classList.add('hidden');
|
|
empty?.classList.add('hidden');
|
|
|
|
|
|
|
|
|
|
+ const sig = `${tableFilter}|${typeFilter}|` + routes.map(r => `${r.id}:${r.active}:${r.disabled}:${r.distance}:${r.protocol}:${r.health_status}:${(r.health_rtt_ms || 0).toFixed(0)}`).join(';');
|
|
|
|
|
+ if (tbody.dataset.sig === sig) {
|
|
|
|
|
+ return; // Routes table unchanged! Do not wipe DOM or flicker!
|
|
|
|
|
+ }
|
|
|
|
|
+ tbody.dataset.sig = sig;
|
|
|
|
|
+ tbody.innerHTML = '';
|
|
|
|
|
+
|
|
|
routes.forEach(r => {
|
|
routes.forEach(r => {
|
|
|
const tr = document.createElement('tr');
|
|
const tr = document.createElement('tr');
|
|
|
tr.className = `hover:bg-slate-800/40 transition text-xs cursor-pointer ${r.disabled ? 'opacity-50' : ''}`;
|
|
tr.className = `hover:bg-slate-800/40 transition text-xs cursor-pointer ${r.disabled ? 'opacity-50' : ''}`;
|
|
@@ -6518,7 +6700,6 @@ function renderPolicyRules() {
|
|
|
const tbody = document.getElementById('rules-table-body');
|
|
const tbody = document.getElementById('rules-table-body');
|
|
|
const empty = document.getElementById('rules-empty');
|
|
const empty = document.getElementById('rules-empty');
|
|
|
if (!tbody) return;
|
|
if (!tbody) return;
|
|
|
- tbody.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
const showSystem = document.getElementById('prule-show-system')?.checked;
|
|
const showSystem = document.getElementById('prule-show-system')?.checked;
|
|
|
const filtered = (state.policyRules || []).filter(pr => showSystem || (!pr.is_default && !pr.is_system));
|
|
const filtered = (state.policyRules || []).filter(pr => showSystem || (!pr.is_default && !pr.is_system));
|
|
@@ -6530,10 +6711,16 @@ function renderPolicyRules() {
|
|
|
|
|
|
|
|
if (filtered.length === 0) {
|
|
if (filtered.length === 0) {
|
|
|
empty?.classList.remove('hidden');
|
|
empty?.classList.remove('hidden');
|
|
|
|
|
+ tbody.innerHTML = '';
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
empty?.classList.add('hidden');
|
|
empty?.classList.add('hidden');
|
|
|
|
|
|
|
|
|
|
+ const sig = `${showSystem}|` + filtered.map(pr => `${pr.id}:${pr.priority}:${pr.src_address}:${pr.dst_address}:${pr.table}:${pr.routing_mark}`).join(';');
|
|
|
|
|
+ if (tbody.dataset.sig === sig) return;
|
|
|
|
|
+ tbody.dataset.sig = sig;
|
|
|
|
|
+ tbody.innerHTML = '';
|
|
|
|
|
+
|
|
|
filtered.forEach(pr => {
|
|
filtered.forEach(pr => {
|
|
|
const isSys = pr.is_default || pr.is_system;
|
|
const isSys = pr.is_default || pr.is_system;
|
|
|
const tr = document.createElement('tr');
|
|
const tr = document.createElement('tr');
|
|
@@ -6623,6 +6810,10 @@ async function deletePolicyRule(id) {
|
|
|
function renderRoutingTables() {
|
|
function renderRoutingTables() {
|
|
|
const tbody = document.getElementById('tables-table-body');
|
|
const tbody = document.getElementById('tables-table-body');
|
|
|
if (!tbody) return;
|
|
if (!tbody) return;
|
|
|
|
|
+ const tables = state.routingTables || [];
|
|
|
|
|
+ const sig = tables.map(t => `${t.table_id}:${t.name}:${t.comment}`).join(';');
|
|
|
|
|
+ if (tbody.dataset.sig === sig) return;
|
|
|
|
|
+ tbody.dataset.sig = sig;
|
|
|
tbody.innerHTML = '';
|
|
tbody.innerHTML = '';
|
|
|
|
|
|
|
|
(state.routingTables || []).forEach(t => {
|
|
(state.routingTables || []).forEach(t => {
|
|
@@ -8111,6 +8302,10 @@ function renderBgpPeers() {
|
|
|
}
|
|
}
|
|
|
if (empty) empty.classList.add('hidden');
|
|
if (empty) empty.classList.add('hidden');
|
|
|
|
|
|
|
|
|
|
+ const sig = peers.map(p => `${p.name}:${p.state}:${p.uptime}:${p.prefix_received}:${p.prefix_sent}:${p.disabled}`).join(';');
|
|
|
|
|
+ if (tbody.dataset.sig === sig) return;
|
|
|
|
|
+ tbody.dataset.sig = sig;
|
|
|
|
|
+
|
|
|
tbody.innerHTML = peers.map(p => {
|
|
tbody.innerHTML = peers.map(p => {
|
|
|
let flagA = !p.disabled ? '<span class="text-cyan-400 font-bold" title="Active">A</span>' : '';
|
|
let flagA = !p.disabled ? '<span class="text-cyan-400 font-bold" title="Active">A</span>' : '';
|
|
|
let flagE = p.state === 'Established' ? '<span class="text-emerald-400 font-bold ml-1" title="Established">E</span>' : '';
|
|
let flagE = p.state === 'Established' ? '<span class="text-emerald-400 font-bold ml-1" title="Established">E</span>' : '';
|
|
@@ -8182,6 +8377,10 @@ function renderBgpNetworks() {
|
|
|
}
|
|
}
|
|
|
if (empty) empty.classList.add('hidden');
|
|
if (empty) empty.classList.add('hidden');
|
|
|
|
|
|
|
|
|
|
+ const sig = nets.map(n => `${n.network}:${n.disabled}:${n.comment}`).join(';');
|
|
|
|
|
+ if (tbody.dataset.sig === sig) return;
|
|
|
|
|
+ tbody.dataset.sig = sig;
|
|
|
|
|
+
|
|
|
tbody.innerHTML = nets.map(n => `
|
|
tbody.innerHTML = nets.map(n => `
|
|
|
<tr class="hover:bg-slate-800/40 transition">
|
|
<tr class="hover:bg-slate-800/40 transition">
|
|
|
<td class="py-2.5 px-3 font-semibold text-purple-300 font-mono">${escapeHtml(n.network)}</td>
|
|
<td class="py-2.5 px-3 font-semibold text-purple-300 font-mono">${escapeHtml(n.network)}</td>
|
|
@@ -8218,6 +8417,10 @@ function renderBgpRoutes() {
|
|
|
}
|
|
}
|
|
|
if (empty) empty.classList.add('hidden');
|
|
if (empty) empty.classList.add('hidden');
|
|
|
|
|
|
|
|
|
|
+ const sig = routes.map(r => `${r.prefix}:${r.next_hop}:${r.metric}:${r.path}`).join(';');
|
|
|
|
|
+ if (tbody.dataset.sig === sig) return;
|
|
|
|
|
+ tbody.dataset.sig = sig;
|
|
|
|
|
+
|
|
|
tbody.innerHTML = routes.map(r => `
|
|
tbody.innerHTML = routes.map(r => `
|
|
|
<tr class="hover:bg-slate-800/40 transition">
|
|
<tr class="hover:bg-slate-800/40 transition">
|
|
|
<td class="py-2.5 px-3 font-semibold text-white">${escapeHtml(r.prefix)}</td>
|
|
<td class="py-2.5 px-3 font-semibold text-white">${escapeHtml(r.prefix)}</td>
|
|
@@ -12591,6 +12794,10 @@ function renderDHCPClients() {
|
|
|
}
|
|
}
|
|
|
if (emptyState) emptyState.classList.add('hidden');
|
|
if (emptyState) emptyState.classList.add('hidden');
|
|
|
|
|
|
|
|
|
|
+ const sig = clients.map(c => `${c.id}:${c.status}:${c.ip_address}:${c.gateway}:${c.enabled}`).join(';');
|
|
|
|
|
+ if (tbody.dataset.sig === sig) return;
|
|
|
|
|
+ tbody.dataset.sig = sig;
|
|
|
|
|
+
|
|
|
tbody.innerHTML = clients.map(c => {
|
|
tbody.innerHTML = clients.map(c => {
|
|
|
let statusBadge = '';
|
|
let statusBadge = '';
|
|
|
if (!c.enabled) {
|
|
if (!c.enabled) {
|
|
@@ -13003,15 +13210,20 @@ function renderDHCPServers() {
|
|
|
const tbody = document.getElementById('dhcp-server-tbody');
|
|
const tbody = document.getElementById('dhcp-server-tbody');
|
|
|
const empty = document.getElementById('dhcp-server-empty');
|
|
const empty = document.getElementById('dhcp-server-empty');
|
|
|
if (!tbody) return;
|
|
if (!tbody) return;
|
|
|
- tbody.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
const servers = state.dhcpServers || [];
|
|
const servers = state.dhcpServers || [];
|
|
|
if (servers.length === 0) {
|
|
if (servers.length === 0) {
|
|
|
|
|
+ tbody.innerHTML = '';
|
|
|
empty?.classList.remove('hidden');
|
|
empty?.classList.remove('hidden');
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
empty?.classList.add('hidden');
|
|
empty?.classList.add('hidden');
|
|
|
|
|
|
|
|
|
|
+ const sig = servers.map(s => `${s.id}:${s.running}:${s.disabled}:${s.active_leases}:${s.interface}`).join(';');
|
|
|
|
|
+ if (tbody.dataset.sig === sig) return;
|
|
|
|
|
+ tbody.dataset.sig = sig;
|
|
|
|
|
+ tbody.innerHTML = '';
|
|
|
|
|
+
|
|
|
servers.forEach(s => {
|
|
servers.forEach(s => {
|
|
|
const tr = document.createElement('tr');
|
|
const tr = document.createElement('tr');
|
|
|
tr.className = `hover:bg-slate-800/40 transition text-xs ${s.disabled ? 'opacity-50' : ''}`;
|
|
tr.className = `hover:bg-slate-800/40 transition text-xs ${s.disabled ? 'opacity-50' : ''}`;
|