|
@@ -770,6 +770,36 @@ function supportsVLAN(iface) {
|
|
|
return true;
|
|
return true;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// DHCP Client Support Checker: Only broadcast-capable layer-2 interfaces (Ethernet, veth, VLAN, bridge, bond)
|
|
|
|
|
+function supportsDHCPClient(iface) {
|
|
|
|
|
+ if (!iface) return false;
|
|
|
|
|
+
|
|
|
|
|
+ const name = (iface.name || '').toLowerCase();
|
|
|
|
|
+ const type = (iface.type || '').toLowerCase();
|
|
|
|
|
+ const role = (iface.role || '').toLowerCase();
|
|
|
|
|
+
|
|
|
|
|
+ // Exclude loopback
|
|
|
|
|
+ if (name === 'lo' || type === 'loopback' || role === 'loopback') return false;
|
|
|
|
|
+
|
|
|
|
|
+ // Exclude WireGuard (Layer 3 point-to-point tunnel, no broadcast/DHCP)
|
|
|
|
|
+ if (type === 'wireguard' || name.startsWith('wg')) return false;
|
|
|
|
|
+
|
|
|
|
|
+ // Exclude PPP / PPPoE / L2TP / PPTP tunnels
|
|
|
|
|
+ if (type === 'ppp' || name.startsWith('ppp') || name.startsWith('l2tp') || name.startsWith('pptp')) return false;
|
|
|
|
|
+
|
|
|
|
|
+ // Exclude other Layer 3 tunnels and dummy devices
|
|
|
|
|
+ if (type === 'dummy' || type === 'gre' || type === 'sit' || type === 'tun' || type === 'tap' ||
|
|
|
|
|
+ name.startsWith('gre') || name.startsWith('sit') || name.startsWith('ipsec') || name.startsWith('xfrm') || name.startsWith('dummy')) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Exclude bridge member/slave ports (bridge itself handles IP / DHCP)
|
|
|
|
|
+ const isBridgePort = (state.bridgePorts || []).some(bp => bp.interface === iface.name);
|
|
|
|
|
+ if (isBridgePort) return false;
|
|
|
|
|
+
|
|
|
|
|
+ return true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// Expand / Collapse Handlers
|
|
// Expand / Collapse Handlers
|
|
|
function toggleExpand(parentName) {
|
|
function toggleExpand(parentName) {
|
|
|
const childVlans = (state.vlans || []).filter(v => v.parent_name === parentName || v.name.startsWith(parentName + '.'));
|
|
const childVlans = (state.vlans || []).filter(v => v.parent_name === parentName || v.name.startsWith(parentName + '.'));
|
|
@@ -1111,7 +1141,8 @@ function renderInterfacesList() {
|
|
|
|
|
|
|
|
const sig = `${state.ifaceFilter}|${query}|` + filteredParents.map(p => {
|
|
const sig = `${state.ifaceFilter}|${query}|` + filteredParents.map(p => {
|
|
|
const cVlans = allVlans.filter(v => v.parent_name === p.name || v.name.startsWith(p.name + '.'));
|
|
const cVlans = allVlans.filter(v => v.parent_name === p.name || v.name.startsWith(p.name + '.'));
|
|
|
- return `${p.name}:${p.state}:${p.role||'LAN'}:${(p.ipv4||[]).join(',')}:${p.mtu}:${cVlans.length}:${state.expandedParents.has(p.name)}`;
|
|
|
|
|
|
|
+ const dhcp = (state.dhcpClients || []).find(dc => dc.interface === p.name);
|
|
|
|
|
+ return `${p.name}:${p.state}:${p.role||'LAN'}:${(p.ipv4||[]).join(',')}:${p.mtu}:${cVlans.length}:${state.expandedParents.has(p.name)}:${dhcp?.status || ''}`;
|
|
|
}).join(';');
|
|
}).join(';');
|
|
|
|
|
|
|
|
if (tbody.dataset.sig === sig) {
|
|
if (tbody.dataset.sig === sig) {
|
|
@@ -1209,6 +1240,9 @@ function renderInterfacesList() {
|
|
|
if (c) {
|
|
if (c) {
|
|
|
return `<button onclick="navigate('dhcp-client')" title="Manage DHCP Client (${c.status})" class="text-xs bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 font-medium px-2.5 py-1 rounded-lg border border-amber-500/30 transition">⚡ DHCP</button>`;
|
|
return `<button onclick="navigate('dhcp-client')" title="Manage DHCP Client (${c.status})" class="text-xs bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 font-medium px-2.5 py-1 rounded-lg border border-amber-500/30 transition">⚡ DHCP</button>`;
|
|
|
}
|
|
}
|
|
|
|
|
+ if (!supportsDHCPClient(parent)) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
return `<button onclick="openDHCPClientModalForIface('${parent.name}')" title="Add DHCP Client on ${parent.name}" class="text-xs bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-amber-400 font-medium px-2 py-1 rounded-lg border border-slate-700 transition">+ DHCP</button>`;
|
|
return `<button onclick="openDHCPClientModalForIface('${parent.name}')" title="Add DHCP Client on ${parent.name}" class="text-xs bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-amber-400 font-medium px-2 py-1 rounded-lg border border-slate-700 transition">+ DHCP</button>`;
|
|
|
})()}
|
|
})()}
|
|
|
${canHaveVlan ? `
|
|
${canHaveVlan ? `
|
|
@@ -1219,7 +1253,7 @@ function renderInterfacesList() {
|
|
|
` : ''}
|
|
` : ''}
|
|
|
<button onclick="openIfaceModal('${parent.name}')"
|
|
<button onclick="openIfaceModal('${parent.name}')"
|
|
|
class="text-xs bg-slate-800 hover:bg-slate-700 text-white font-medium px-2.5 py-1 rounded-lg border border-slate-700 transition">
|
|
class="text-xs bg-slate-800 hover:bg-slate-700 text-white font-medium px-2.5 py-1 rounded-lg border border-slate-700 transition">
|
|
|
- Configure
|
|
|
|
|
|
|
+ Edit
|
|
|
</button>
|
|
</button>
|
|
|
</td>
|
|
</td>
|
|
|
`;
|
|
`;
|
|
@@ -1324,6 +1358,9 @@ function renderInterfacesList() {
|
|
|
if (c) {
|
|
if (c) {
|
|
|
return `<button onclick="navigate('dhcp-client')" class="text-xs text-amber-400 hover:text-amber-300 px-2 py-0.5 rounded bg-amber-950/40 border border-amber-800/50">⚡ DHCP</button>`;
|
|
return `<button onclick="navigate('dhcp-client')" class="text-xs text-amber-400 hover:text-amber-300 px-2 py-0.5 rounded bg-amber-950/40 border border-amber-800/50">⚡ DHCP</button>`;
|
|
|
}
|
|
}
|
|
|
|
|
+ if (!supportsDHCPClient(v)) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
return `<button onclick="openDHCPClientModalForIface('${v.name}')" class="text-xs text-slate-400 hover:text-amber-400 px-1.5 py-0.5 rounded border border-slate-700">+ DHCP</button>`;
|
|
return `<button onclick="openDHCPClientModalForIface('${v.name}')" class="text-xs text-slate-400 hover:text-amber-400 px-1.5 py-0.5 rounded border border-slate-700">+ DHCP</button>`;
|
|
|
})()}
|
|
})()}
|
|
|
<button onclick="openIfaceModal('${v.name}')" class="text-xs bg-slate-800 hover:bg-slate-700 text-slate-200 px-2 py-0.5 rounded border border-slate-700">Edit</button>
|
|
<button onclick="openIfaceModal('${v.name}')" class="text-xs bg-slate-800 hover:bg-slate-700 text-slate-200 px-2 py-0.5 rounded border border-slate-700">Edit</button>
|
|
@@ -13159,7 +13196,7 @@ function openDHCPClientModal(clientId = null) {
|
|
|
|
|
|
|
|
// Group 1: Physical & Bridge Interfaces
|
|
// Group 1: Physical & Bridge Interfaces
|
|
|
opts += '<optgroup label="Physical & Bridge Interfaces">';
|
|
opts += '<optgroup label="Physical & Bridge Interfaces">';
|
|
|
- allIfaces.forEach(i => {
|
|
|
|
|
|
|
+ allIfaces.filter(i => supportsDHCPClient(i)).forEach(i => {
|
|
|
const isConfigured = usedIfaces.has(i.name) ? ' (Configured)' : '';
|
|
const isConfigured = usedIfaces.has(i.name) ? ' (Configured)' : '';
|
|
|
opts += `<option value="${escapeHtml(i.name)}">${escapeHtml(i.name)} [${escapeHtml(i.role || i.type)}]${isConfigured}</option>`;
|
|
opts += `<option value="${escapeHtml(i.name)}">${escapeHtml(i.name)} [${escapeHtml(i.role || i.type)}]${isConfigured}</option>`;
|
|
|
});
|
|
});
|
|
@@ -14250,8 +14287,9 @@ function setupSortableTable(table) {
|
|
|
} else {
|
|
} else {
|
|
|
tagOriginalRowOrder(tbody);
|
|
tagOriginalRowOrder(tbody);
|
|
|
}
|
|
}
|
|
|
- }, 10);
|
|
|
|
|
|
|
+ }, 50);
|
|
|
});
|
|
});
|
|
|
|
|
+ tbody._sortObserver = observer;
|
|
|
observer.observe(tbody, { childList: true });
|
|
observer.observe(tbody, { childList: true });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -14346,12 +14384,46 @@ function applyTableSort(table, colIdx, dir, userInitiated) {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Check if rows are already in correct order to avoid modifying DOM
|
|
|
|
|
+ let alreadyOrdered = true;
|
|
|
|
|
+ let childIdx = 0;
|
|
|
|
|
+ for (const g of groups) {
|
|
|
|
|
+ if (tbody.children[childIdx] !== g.mainRow) {
|
|
|
|
|
+ alreadyOrdered = false;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ childIdx++;
|
|
|
|
|
+ for (const sub of g.subRows) {
|
|
|
|
|
+ if (tbody.children[childIdx] !== sub) {
|
|
|
|
|
+ alreadyOrdered = false;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ childIdx++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (alreadyOrdered) {
|
|
|
|
|
+ tbody._isSorting = false;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Disconnect observer while re-ordering to prevent recursion and thrashing
|
|
|
|
|
+ if (tbody._sortObserver) {
|
|
|
|
|
+ tbody._sortObserver.disconnect();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// Re-append rows in sorted order
|
|
// Re-append rows in sorted order
|
|
|
groups.forEach(g => {
|
|
groups.forEach(g => {
|
|
|
tbody.appendChild(g.mainRow);
|
|
tbody.appendChild(g.mainRow);
|
|
|
g.subRows.forEach(sub => tbody.appendChild(sub));
|
|
g.subRows.forEach(sub => tbody.appendChild(sub));
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // Drain any queued mutations and re-connect observer
|
|
|
|
|
+ if (tbody._sortObserver) {
|
|
|
|
|
+ tbody._sortObserver.takeRecords();
|
|
|
|
|
+ tbody._sortObserver.observe(tbody, { childList: true });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
tbody._isSorting = false;
|
|
tbody._isSorting = false;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -14694,12 +14766,16 @@ async function toggleVRRPState(id, disabled) {
|
|
|
|
|
|
|
|
// Global observer for dynamically injected tables (e.g. inside modals or drawers)
|
|
// Global observer for dynamically injected tables (e.g. inside modals or drawers)
|
|
|
if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') {
|
|
if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') {
|
|
|
|
|
+ let docSortDebounce = null;
|
|
|
const docTableObserver = new MutationObserver((mutations) => {
|
|
const docTableObserver = new MutationObserver((mutations) => {
|
|
|
let needsInit = false;
|
|
let needsInit = false;
|
|
|
for (const m of mutations) {
|
|
for (const m of mutations) {
|
|
|
for (const node of m.addedNodes) {
|
|
for (const node of m.addedNodes) {
|
|
|
if (node.nodeType === 1) {
|
|
if (node.nodeType === 1) {
|
|
|
- if (node.tagName === 'TABLE' || (node.querySelector && node.querySelector('table'))) {
|
|
|
|
|
|
|
+ if (node.tagName === 'TABLE' && !node.closest('tbody, thead')) {
|
|
|
|
|
+ needsInit = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ } else if (node.querySelector && !node.closest('table') && node.querySelector('table')) {
|
|
|
needsInit = true;
|
|
needsInit = true;
|
|
|
break;
|
|
break;
|
|
|
}
|
|
}
|
|
@@ -14708,7 +14784,8 @@ if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined')
|
|
|
if (needsInit) break;
|
|
if (needsInit) break;
|
|
|
}
|
|
}
|
|
|
if (needsInit) {
|
|
if (needsInit) {
|
|
|
- initTableSorting();
|
|
|
|
|
|
|
+ clearTimeout(docSortDebounce);
|
|
|
|
|
+ docSortDebounce = setTimeout(initTableSorting, 100);
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
docTableObserver.observe(document.body || document.documentElement, { childList: true, subtree: true });
|
|
docTableObserver.observe(document.body || document.documentElement, { childList: true, subtree: true });
|