|
|
@@ -42,7 +42,9 @@ let state = {
|
|
|
routingTables: [],
|
|
|
routingFilters: [],
|
|
|
filterChainSelected: 'all',
|
|
|
- activeToolsSubTab: 'ping'
|
|
|
+ activeToolsSubTab: 'ping',
|
|
|
+ dhcpClients: [],
|
|
|
+ dhcpSummary: null
|
|
|
};
|
|
|
|
|
|
const API_BASE = '/api';
|
|
|
@@ -99,6 +101,7 @@ function navigate(tabName) {
|
|
|
const titles = {
|
|
|
dashboard: { title: 'Dashboard Overview', sub: 'Live router performance and network telemetry' },
|
|
|
interfaces: { title: 'Network Interfaces', sub: 'Hierarchical view of physical interfaces and nested 802.1Q VLANs' },
|
|
|
+ 'dhcp-client': { title: 'DHCP Client (/ip dhcp-client)', sub: 'RouterOS-style dynamic IPv4 lease configuration, default gateway & peer DNS on any interface' },
|
|
|
bridge: { title: 'Bridge Interfaces', sub: 'MikroTik-style IEEE 802.1D / 802.1Q VLAN Filtering Bridge with RSTP & FDB' },
|
|
|
vlans: { title: 'VLAN Virtual Networks', sub: 'IEEE 802.1Q sub-interface segmentation and routing' },
|
|
|
firewall: { title: 'MikroTik Firewall', sub: 'Filter rules, NAT, Mangle, Address Lists & Active Connections tracking' },
|
|
|
@@ -155,6 +158,7 @@ function navigate(tabName) {
|
|
|
if (tabName === 'l2tp') loadL2TP();
|
|
|
if (tabName === 'pptp') loadPPTP();
|
|
|
if (tabName === 'tools') loadToolsView();
|
|
|
+ if (tabName === 'dhcp-client') loadDHCPClients();
|
|
|
}
|
|
|
|
|
|
// Fetch all core system, network and VPN telemetry
|
|
|
@@ -163,16 +167,27 @@ async function fetchData(isManual = false) {
|
|
|
if (isManual && spinner) spinner.classList.add('animate-spin');
|
|
|
|
|
|
try {
|
|
|
- const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes, brSummaryRes] = await Promise.all([
|
|
|
+ const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes, brSummaryRes, dhcpSumRes, dhcpClientsRes] = await Promise.all([
|
|
|
fetch(`${API_BASE}/system/status`),
|
|
|
fetch(`${API_BASE}/interfaces`),
|
|
|
fetch(`${API_BASE}/vlans`),
|
|
|
fetch(`${API_BASE}/vpn/status`),
|
|
|
fetch(`${API_BASE}/firewall/summary`),
|
|
|
fetch(`${API_BASE}/routing/dynamic/summary`),
|
|
|
- fetch(`${API_BASE}/bridge/summary`)
|
|
|
+ fetch(`${API_BASE}/bridge/summary`),
|
|
|
+ fetch(`${API_BASE}/dhcp/clients/summary`),
|
|
|
+ fetch(`${API_BASE}/dhcp/clients`)
|
|
|
]);
|
|
|
|
|
|
+ if (dhcpSumRes && dhcpSumRes.ok) {
|
|
|
+ state.dhcpSummary = await dhcpSumRes.json();
|
|
|
+ const bDhcp = document.getElementById('badge-dhcp-client-count');
|
|
|
+ if (bDhcp) bDhcp.innerText = state.dhcpSummary?.total_clients || 0;
|
|
|
+ }
|
|
|
+ if (dhcpClientsRes && dhcpClientsRes.ok) {
|
|
|
+ state.dhcpClients = await dhcpClientsRes.json();
|
|
|
+ }
|
|
|
+
|
|
|
if (brSummaryRes.ok) {
|
|
|
state.bridgeSummary = await brSummaryRes.json();
|
|
|
const bBridge = document.getElementById('badge-bridge-count');
|
|
|
@@ -220,6 +235,7 @@ async function fetchData(isManual = false) {
|
|
|
if (state.activeTab === 'ipsec') loadIPSec(false);
|
|
|
if (state.activeTab === 'l2tp') loadL2TP(false);
|
|
|
if (state.activeTab === 'pptp') loadPPTP(false);
|
|
|
+ if (state.activeTab === 'dhcp-client') loadDHCPClients(false);
|
|
|
|
|
|
} catch (err) {
|
|
|
console.error('Failed to fetch router state:', err);
|
|
|
@@ -538,6 +554,12 @@ function renderInterfacesList() {
|
|
|
? `<div class="text-white font-medium">${parent.ipv4[0]}</div>`
|
|
|
: '<span class="text-slate-600 font-sans">Unassigned</span>'
|
|
|
}
|
|
|
+ ${(() => {
|
|
|
+ const c = (state.dhcpClients || []).find(dc => dc.interface === parent.name);
|
|
|
+ if (!c) return '';
|
|
|
+ const statusCls = c.status === 'bound' ? 'bg-amber-500/20 text-amber-300 border-amber-500/30' : (c.status === 'searching' ? 'bg-sky-500/20 text-sky-300 border-sky-500/30' : 'bg-slate-800 text-slate-400 border-slate-700');
|
|
|
+ return `<div class="mt-1"><span class="px-1.5 py-0.5 rounded text-[10px] font-mono border ${statusCls} cursor-pointer hover:underline" onclick="navigate('dhcp-client')" title="DHCP Client: ${c.status.toUpperCase()} (${c.ip_address || 'requesting...'})">⚡ DHCP: ${c.status.toUpperCase()}</span></div>`;
|
|
|
+ })()}
|
|
|
</td>
|
|
|
<td class="py-3 px-4 text-slate-400 text-[11px]">
|
|
|
<div>${parent.mac || 'N/A'}</div>
|
|
|
@@ -559,6 +581,13 @@ function renderInterfacesList() {
|
|
|
</button>
|
|
|
</td>
|
|
|
<td class="py-3 px-4 text-right space-x-2 font-sans">
|
|
|
+ ${(() => {
|
|
|
+ const c = (state.dhcpClients || []).find(dc => dc.interface === parent.name);
|
|
|
+ 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="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>`;
|
|
|
+ })()}
|
|
|
<button onclick="openVlanModal('${parent.name}')"
|
|
|
class="text-xs bg-cyan-950/60 hover:bg-cyan-900 text-cyan-300 font-semibold px-2.5 py-1 rounded-lg border border-cyan-800/70 shadow-sm transition">
|
|
|
+ Add VLAN
|
|
|
@@ -634,6 +663,12 @@ function renderInterfacesList() {
|
|
|
</td>
|
|
|
<td class="py-2.5 px-4 text-emerald-400 font-medium">
|
|
|
${v.ipv4 && v.ipv4.length > 0 ? v.ipv4[0] : '<span class="text-slate-600 font-sans">Unassigned</span>'}
|
|
|
+ ${(() => {
|
|
|
+ const c = (state.dhcpClients || []).find(dc => dc.interface === v.name);
|
|
|
+ if (!c) return '';
|
|
|
+ const statusCls = c.status === 'bound' ? 'bg-amber-500/20 text-amber-300 border-amber-500/30' : 'bg-slate-800 text-slate-400 border-slate-700';
|
|
|
+ return `<div class="mt-0.5"><span class="px-1.5 py-0.2 rounded text-[9px] font-mono border ${statusCls} cursor-pointer hover:underline" onclick="navigate('dhcp-client')" title="DHCP Client: ${c.status.toUpperCase()}">⚡ DHCP: ${c.status.toUpperCase()}</span></div>`;
|
|
|
+ })()}
|
|
|
</td>
|
|
|
<td class="py-2.5 px-4 font-sans text-slate-300">
|
|
|
<span class="px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-slate-300 mr-1.5">${v.role || 'VLAN'}</span>
|
|
|
@@ -654,6 +689,13 @@ function renderInterfacesList() {
|
|
|
</button>
|
|
|
</td>
|
|
|
<td class="py-2.5 px-4 text-right space-x-1.5 font-sans">
|
|
|
+ ${(() => {
|
|
|
+ const c = (state.dhcpClients || []).find(dc => dc.interface === v.name);
|
|
|
+ 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="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="confirmDeleteVlan('${v.name}')" class="text-xs text-rose-400 hover:text-rose-300 hover:bg-rose-950/40 px-2 py-0.5 rounded transition">Delete</button>
|
|
|
</td>
|
|
|
@@ -5143,31 +5185,40 @@ function switchDynSubTab(subTab) {
|
|
|
|
|
|
async function loadDynamicRouting(showToastMsg = false) {
|
|
|
try {
|
|
|
- const [sumRes, bgpInstRes, bgpPeersRes, bgpNetsRes, bgpRoutesRes, ospfInstRes, ospfIfacesRes, ospfAreasRes, ospfNbrsRes, ospfRoutesRes, filtersRes] = await Promise.all([
|
|
|
- fetch(`${API_BASE}/routing/dynamic/summary`),
|
|
|
- fetch(`${API_BASE}/routing/bgp/instance`),
|
|
|
- fetch(`${API_BASE}/routing/bgp/peers`),
|
|
|
- fetch(`${API_BASE}/routing/bgp/networks`),
|
|
|
- fetch(`${API_BASE}/routing/bgp/routes`),
|
|
|
- fetch(`${API_BASE}/routing/ospf/instance`),
|
|
|
- fetch(`${API_BASE}/routing/ospf/interfaces`),
|
|
|
- fetch(`${API_BASE}/routing/ospf/areas`),
|
|
|
- fetch(`${API_BASE}/routing/ospf/neighbors`),
|
|
|
- fetch(`${API_BASE}/routing/ospf/routes`),
|
|
|
- fetch(`${API_BASE}/routing/filters`)
|
|
|
+ const fetchSafeJson = async (url) => {
|
|
|
+ try {
|
|
|
+ const r = await fetch(url);
|
|
|
+ return r.ok ? await r.json() : null;
|
|
|
+ } catch (e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const [sum, bgpInst, bgpPeers, bgpNets, bgpRoutes, ospfInst, ospfIfaces, ospfAreas, ospfNbrs, ospfRoutes, filters] = await Promise.all([
|
|
|
+ fetchSafeJson(`${API_BASE}/routing/dynamic/summary`),
|
|
|
+ fetchSafeJson(`${API_BASE}/routing/bgp/instance`),
|
|
|
+ fetchSafeJson(`${API_BASE}/routing/bgp/peers`),
|
|
|
+ fetchSafeJson(`${API_BASE}/routing/bgp/networks`),
|
|
|
+ fetchSafeJson(`${API_BASE}/routing/bgp/routes`),
|
|
|
+ fetchSafeJson(`${API_BASE}/routing/ospf/instance`),
|
|
|
+ fetchSafeJson(`${API_BASE}/routing/ospf/interfaces`),
|
|
|
+ fetchSafeJson(`${API_BASE}/routing/ospf/areas`),
|
|
|
+ fetchSafeJson(`${API_BASE}/routing/ospf/neighbors`),
|
|
|
+ fetchSafeJson(`${API_BASE}/routing/ospf/routes`),
|
|
|
+ fetchSafeJson(`${API_BASE}/routing/filters`)
|
|
|
]);
|
|
|
|
|
|
- if (sumRes.ok) state.dynSummary = await sumRes.json();
|
|
|
- if (bgpInstRes.ok) state.bgpInstance = await bgpInstRes.json();
|
|
|
- if (bgpPeersRes.ok) state.bgpPeers = await bgpPeersRes.json();
|
|
|
- if (bgpNetsRes.ok) state.bgpNets = await bgpNetsRes.json();
|
|
|
- if (bgpRoutesRes.ok) state.bgpRoutes = await bgpRoutesRes.json();
|
|
|
- if (ospfInstRes.ok) state.ospfInstance = await ospfInstRes.json();
|
|
|
- if (ospfIfacesRes.ok) state.ospfInterfaces = await ospfIfacesRes.json();
|
|
|
- if (ospfAreasRes.ok) state.ospfAreas = await ospfAreasRes.json();
|
|
|
- if (ospfNbrsRes.ok) state.ospfNeighbors = await ospfNbrsRes.json();
|
|
|
- if (ospfRoutesRes.ok) state.ospfRoutes = await ospfRoutesRes.json();
|
|
|
- if (filtersRes && filtersRes.ok) state.routingFilters = await filtersRes.json();
|
|
|
+ if (sum) state.dynSummary = sum;
|
|
|
+ if (bgpInst) state.bgpInstance = bgpInst;
|
|
|
+ if (bgpPeers) state.bgpPeers = bgpPeers;
|
|
|
+ if (bgpNets) state.bgpNets = bgpNets;
|
|
|
+ if (bgpRoutes) state.bgpRoutes = bgpRoutes;
|
|
|
+ if (ospfInst) state.ospfInstance = ospfInst;
|
|
|
+ if (ospfIfaces) state.ospfInterfaces = ospfIfaces;
|
|
|
+ if (ospfAreas) state.ospfAreas = ospfAreas;
|
|
|
+ if (ospfNbrs) state.ospfNeighbors = ospfNbrs;
|
|
|
+ if (ospfRoutes) state.ospfRoutes = ospfRoutes;
|
|
|
+ if (filters) state.routingFilters = filters;
|
|
|
|
|
|
try { renderBgpStatusPill(); } catch(e) { console.error('renderBgpStatusPill error:', e); }
|
|
|
try { renderBgpPeers(); } catch(e) { console.error('renderBgpPeers error:', e); }
|
|
|
@@ -5184,7 +5235,7 @@ async function loadDynamicRouting(showToastMsg = false) {
|
|
|
if (showToastMsg) showToast('Refreshed', 'Dynamic routing telemetry updated', 'info');
|
|
|
} catch (err) {
|
|
|
console.error('Failed to load dynamic routing:', err);
|
|
|
- if (showToastMsg) showToast('Error', 'Failed to load dynamic routing telemetry', 'error');
|
|
|
+ if (showToastMsg) showToast('Notice', 'Unable to refresh dynamic routing telemetry', 'warning');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -7739,4 +7790,368 @@ async function executePortProbe(e) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// ==========================================
|
|
|
+// DHCP CLIENT (/ip dhcp-client) CONTROLLER
|
|
|
+// ==========================================
|
|
|
+
|
|
|
+async function loadDHCPClients(showToastMsg = false) {
|
|
|
+ try {
|
|
|
+ const [clientsRes, sumRes] = await Promise.all([
|
|
|
+ fetch(`${API_BASE}/dhcp/clients`),
|
|
|
+ fetch(`${API_BASE}/dhcp/clients/summary`)
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if (clientsRes.ok) {
|
|
|
+ state.dhcpClients = await clientsRes.json();
|
|
|
+ }
|
|
|
+ if (sumRes.ok) {
|
|
|
+ state.dhcpSummary = await sumRes.json();
|
|
|
+ }
|
|
|
+
|
|
|
+ renderDHCPClients();
|
|
|
+ updateDHCPSummaryCards();
|
|
|
+
|
|
|
+ const bDhcp = document.getElementById('badge-dhcp-client-count');
|
|
|
+ if (bDhcp && state.dhcpSummary) {
|
|
|
+ bDhcp.innerText = state.dhcpSummary.total_clients || 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (showToastMsg) {
|
|
|
+ showToast('Refreshed', 'DHCP Client telemetry updated', 'info');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error('Failed to load DHCP clients:', err);
|
|
|
+ if (showToastMsg) {
|
|
|
+ showToast('Notice', 'Unable to fetch DHCP client status', 'warning');
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function updateDHCPSummaryCards() {
|
|
|
+ const sum = state.dhcpSummary || {
|
|
|
+ total_clients: (state.dhcpClients || []).length,
|
|
|
+ bound_clients: (state.dhcpClients || []).filter(c => c.status === 'bound').length,
|
|
|
+ searching_clients: (state.dhcpClients || []).filter(c => c.status === 'searching').length,
|
|
|
+ disabled_clients: (state.dhcpClients || []).filter(c => !c.enabled).length
|
|
|
+ };
|
|
|
+
|
|
|
+ const totalEl = document.getElementById('dhcp-stat-total');
|
|
|
+ const boundEl = document.getElementById('dhcp-stat-bound');
|
|
|
+ const searchEl = document.getElementById('dhcp-stat-searching');
|
|
|
+ const disEl = document.getElementById('dhcp-stat-disabled');
|
|
|
+
|
|
|
+ if (totalEl) totalEl.innerText = sum.total_clients ?? 0;
|
|
|
+ if (boundEl) boundEl.innerText = sum.bound_clients ?? 0;
|
|
|
+ if (searchEl) searchEl.innerText = sum.searching_clients ?? 0;
|
|
|
+ if (disEl) disEl.innerText = sum.disabled_clients ?? 0;
|
|
|
+}
|
|
|
+
|
|
|
+function renderDHCPClients() {
|
|
|
+ const tbody = document.getElementById('dhcp-clients-tbody');
|
|
|
+ const emptyState = document.getElementById('dhcp-clients-empty');
|
|
|
+ if (!tbody) return;
|
|
|
+
|
|
|
+ const clients = Array.isArray(state.dhcpClients) ? state.dhcpClients : [];
|
|
|
+
|
|
|
+ if (clients.length === 0) {
|
|
|
+ tbody.innerHTML = '';
|
|
|
+ if (emptyState) emptyState.classList.remove('hidden');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (emptyState) emptyState.classList.add('hidden');
|
|
|
+
|
|
|
+ tbody.innerHTML = clients.map(c => {
|
|
|
+ let statusBadge = '';
|
|
|
+ if (!c.enabled) {
|
|
|
+ statusBadge = '<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-800 text-slate-400 border border-slate-700">DISABLED</span>';
|
|
|
+ } else if (c.status === 'bound') {
|
|
|
+ statusBadge = '<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-950 text-emerald-300 border border-emerald-800/60 inline-flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>BOUND</span>';
|
|
|
+ } else if (c.status === 'searching') {
|
|
|
+ statusBadge = '<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-amber-950 text-amber-300 border border-amber-800/60 inline-flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping"></span>SEARCHING...</span>';
|
|
|
+ } else if (c.status === 'stopped') {
|
|
|
+ statusBadge = '<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-800 text-slate-400 border border-slate-700">STOPPED</span>';
|
|
|
+ } else {
|
|
|
+ statusBadge = `<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-rose-950 text-rose-300 border border-rose-800/60">${escapeHtml((c.status || 'UNKNOWN').toUpperCase())}</span>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ const ipDisplay = c.ip_address ? `<span class="font-bold text-white">${escapeHtml(c.ip_address)}</span>` : '<span class="text-slate-500 italic">No lease</span>';
|
|
|
+ const gwDisplay = c.gateway ? `<span class="text-slate-300">${escapeHtml(c.gateway)}</span>` : '<span class="text-slate-600">--</span>';
|
|
|
+ const srvDisplay = c.dhcp_server ? `<span class="text-slate-300">${escapeHtml(c.dhcp_server)}</span>` : '<span class="text-slate-600">--</span>';
|
|
|
+ const dnsDisplay = (Array.isArray(c.dns_servers) && c.dns_servers.length > 0) ? `<span class="text-cyan-400">${c.dns_servers.map(d => escapeHtml(d)).join(', ')}</span>` : '<span class="text-slate-600">--</span>';
|
|
|
+ const expDisplay = c.expires_in ? `<span class="text-purple-300">${escapeHtml(c.expires_in)}</span>` : '<span class="text-slate-600">--</span>';
|
|
|
+
|
|
|
+ // Option flags
|
|
|
+ const flags = [];
|
|
|
+ if (c.add_default_route) flags.push('<span class="px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-slate-300 border border-slate-700" title="Add Default Route">+GW</span>');
|
|
|
+ if (c.use_peer_dns) flags.push('<span class="px-1.5 py-0.5 rounded text-[10px] bg-cyan-950/60 text-cyan-300 border border-cyan-800/50" title="Use Peer DNS">+DNS</span>');
|
|
|
+ if (c.use_peer_ntp) flags.push('<span class="px-1.5 py-0.5 rounded text-[10px] bg-indigo-950/60 text-indigo-300 border border-indigo-800/50" title="Use Peer NTP">+NTP</span>');
|
|
|
+ flags.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-slate-800/60 text-slate-400" title="Default Route Distance">dist:${c.default_route_distance || 1}</span>`);
|
|
|
+
|
|
|
+ return `
|
|
|
+ <tr class="hover:bg-slate-800/40 border-b border-slate-800/60 text-xs transition">
|
|
|
+ <td class="py-3 px-4">
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <span class="font-bold text-white text-sm">${escapeHtml(c.interface)}</span>
|
|
|
+ ${c.comment ? `<span class="text-[11px] text-slate-400 font-sans italic">(${escapeHtml(c.comment)})</span>` : ''}
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ <td class="py-3 px-4">${statusBadge}</td>
|
|
|
+ <td class="py-3 px-4 font-mono">${ipDisplay}</td>
|
|
|
+ <td class="py-3 px-4 font-mono">${gwDisplay}</td>
|
|
|
+ <td class="py-3 px-4 font-mono">${srvDisplay}</td>
|
|
|
+ <td class="py-3 px-4 font-mono text-[11px]">${dnsDisplay}</td>
|
|
|
+ <td class="py-3 px-4 font-mono text-[11px]">${expDisplay}</td>
|
|
|
+ <td class="py-3 px-4">
|
|
|
+ <div class="flex flex-wrap gap-1 font-mono">${flags.join('')}</div>
|
|
|
+ </td>
|
|
|
+ <td class="py-3 px-4 text-right space-x-1.5 font-sans whitespace-nowrap">
|
|
|
+ ${c.enabled ? `
|
|
|
+ <button onclick="renewDHCPClient('${c.id}')" class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-amber-400 text-xs font-medium border border-slate-700 transition" title="Renew Lease">Renew</button>
|
|
|
+ <button onclick="releaseDHCPClient('${c.id}')" class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium border border-slate-700 transition" title="Release Lease">Release</button>
|
|
|
+ <button onclick="toggleDHCPClientState('${c.id}', false)" class="px-2 py-1 rounded bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 text-xs font-medium border border-amber-500/30 transition" title="Disable DHCP Client">Disable</button>
|
|
|
+ ` : `
|
|
|
+ <button onclick="toggleDHCPClientState('${c.id}', true)" class="px-2.5 py-1 rounded bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 text-xs font-semibold border border-emerald-500/30 transition" title="Enable DHCP Client">Enable</button>
|
|
|
+ `}
|
|
|
+ <button onclick="openDHCPClientModal('${c.id}')" class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-white text-xs font-medium border border-slate-700 transition" title="Edit Settings">Edit</button>
|
|
|
+ <button onclick="deleteDHCPClient('${c.id}', '${c.interface}')" class="px-2 py-1 rounded bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-medium border border-rose-500/30 transition" title="Remove Client">✕</button>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ `;
|
|
|
+ }).join('');
|
|
|
+}
|
|
|
+
|
|
|
+function openDHCPClientModal(clientId = null) {
|
|
|
+ const modal = document.getElementById('modal-dhcp-client');
|
|
|
+ if (!modal) return;
|
|
|
+
|
|
|
+ const title = document.getElementById('modal-dhcp-title');
|
|
|
+ const idInput = document.getElementById('modal-dhcp-id');
|
|
|
+ const ifaceSelect = document.getElementById('modal-dhcp-iface');
|
|
|
+ const distInput = document.getElementById('modal-dhcp-distance');
|
|
|
+ const commentInput = document.getElementById('modal-dhcp-comment');
|
|
|
+ const defRouteCheck = document.getElementById('modal-dhcp-def-route');
|
|
|
+ const peerDnsCheck = document.getElementById('modal-dhcp-peer-dns');
|
|
|
+ const peerNtpCheck = document.getElementById('modal-dhcp-peer-ntp');
|
|
|
+ const enabledCheck = document.getElementById('modal-dhcp-enabled');
|
|
|
+
|
|
|
+ // Populate interfaces
|
|
|
+ if (ifaceSelect) {
|
|
|
+ let opts = '<option value="">Select interface...</option>';
|
|
|
+ const allIfaces = state.interfaces || [];
|
|
|
+ const allVlans = state.vlans || [];
|
|
|
+ const usedIfaces = new Set((state.dhcpClients || []).map(c => c.interface));
|
|
|
+
|
|
|
+ // Group 1: Physical & Bridge Interfaces
|
|
|
+ opts += '<optgroup label="Physical & Bridge Interfaces">';
|
|
|
+ allIfaces.forEach(i => {
|
|
|
+ const isConfigured = usedIfaces.has(i.name) ? ' (Configured)' : '';
|
|
|
+ opts += `<option value="${escapeHtml(i.name)}">${escapeHtml(i.name)} [${escapeHtml(i.role || i.type)}]${isConfigured}</option>`;
|
|
|
+ });
|
|
|
+ opts += '</optgroup>';
|
|
|
+
|
|
|
+ // Group 2: VLAN Interfaces
|
|
|
+ if (allVlans.length > 0) {
|
|
|
+ opts += '<optgroup label="802.1Q VLAN Sub-interfaces">';
|
|
|
+ allVlans.forEach(v => {
|
|
|
+ const isConfigured = usedIfaces.has(v.name) ? ' (Configured)' : '';
|
|
|
+ opts += `<option value="${escapeHtml(v.name)}">${escapeHtml(v.name)} (VLAN ${v.vlan_id})${isConfigured}</option>`;
|
|
|
+ });
|
|
|
+ opts += '</optgroup>';
|
|
|
+ }
|
|
|
+
|
|
|
+ ifaceSelect.innerHTML = opts;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (clientId) {
|
|
|
+ const client = (state.dhcpClients || []).find(c => c.id === clientId);
|
|
|
+ if (client) {
|
|
|
+ if (title) title.innerText = `Edit DHCP Client: ${client.interface}`;
|
|
|
+ if (idInput) idInput.value = client.id;
|
|
|
+ if (ifaceSelect) {
|
|
|
+ ifaceSelect.value = client.interface;
|
|
|
+ ifaceSelect.disabled = true; // interface cannot be changed on edit
|
|
|
+ }
|
|
|
+ if (distInput) distInput.value = client.default_route_distance || 1;
|
|
|
+ if (commentInput) commentInput.value = client.comment || '';
|
|
|
+ if (defRouteCheck) defRouteCheck.checked = client.add_default_route !== false;
|
|
|
+ if (peerDnsCheck) peerDnsCheck.checked = client.use_peer_dns !== false;
|
|
|
+ if (peerNtpCheck) peerNtpCheck.checked = client.use_peer_ntp !== false;
|
|
|
+ if (enabledCheck) enabledCheck.checked = client.enabled !== false;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if (title) title.innerText = 'Add DHCP Client';
|
|
|
+ if (idInput) idInput.value = '';
|
|
|
+ if (ifaceSelect) {
|
|
|
+ ifaceSelect.value = '';
|
|
|
+ ifaceSelect.disabled = false;
|
|
|
+ }
|
|
|
+ if (distInput) distInput.value = 1;
|
|
|
+ if (commentInput) commentInput.value = '';
|
|
|
+ if (defRouteCheck) defRouteCheck.checked = true;
|
|
|
+ if (peerDnsCheck) peerDnsCheck.checked = true;
|
|
|
+ if (peerNtpCheck) peerNtpCheck.checked = true;
|
|
|
+ if (enabledCheck) enabledCheck.checked = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ modal.classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function openDHCPClientModalForIface(ifaceName) {
|
|
|
+ openDHCPClientModal();
|
|
|
+ const ifaceSelect = document.getElementById('modal-dhcp-iface');
|
|
|
+ if (ifaceSelect && ifaceName) {
|
|
|
+ ifaceSelect.value = ifaceName;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function closeDHCPClientModal() {
|
|
|
+ const modal = document.getElementById('modal-dhcp-client');
|
|
|
+ if (modal) modal.classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+async function submitSaveDHCPClient(e) {
|
|
|
+ if (e) e.preventDefault();
|
|
|
+
|
|
|
+ const id = document.getElementById('modal-dhcp-id')?.value;
|
|
|
+ const iface = document.getElementById('modal-dhcp-iface')?.value?.trim();
|
|
|
+ const distance = parseInt(document.getElementById('modal-dhcp-distance')?.value || '1', 10);
|
|
|
+ const comment = document.getElementById('modal-dhcp-comment')?.value?.trim() || '';
|
|
|
+ const defRoute = document.getElementById('modal-dhcp-def-route')?.checked ?? true;
|
|
|
+ const peerDns = document.getElementById('modal-dhcp-peer-dns')?.checked ?? true;
|
|
|
+ const peerNtp = document.getElementById('modal-dhcp-peer-ntp')?.checked ?? true;
|
|
|
+ const enabled = document.getElementById('modal-dhcp-enabled')?.checked ?? true;
|
|
|
+
|
|
|
+ if (!id && !iface) {
|
|
|
+ showToast('Input Required', 'Please select an interface for the DHCP client', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ let res;
|
|
|
+ if (id) {
|
|
|
+ // PUT update
|
|
|
+ res = await fetch(`${API_BASE}/dhcp/clients/${id}`, {
|
|
|
+ method: 'PUT',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ enabled,
|
|
|
+ add_default_route: defRoute,
|
|
|
+ default_route_distance: distance,
|
|
|
+ use_peer_dns: peerDns,
|
|
|
+ use_peer_ntp: peerNtp,
|
|
|
+ comment
|
|
|
+ })
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ // POST create
|
|
|
+ res = await fetch(`${API_BASE}/dhcp/clients`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ interface: iface,
|
|
|
+ enabled,
|
|
|
+ add_default_route: defRoute,
|
|
|
+ default_route_distance: distance,
|
|
|
+ use_peer_dns: peerDns,
|
|
|
+ use_peer_ntp: peerNtp,
|
|
|
+ comment
|
|
|
+ })
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const data = await res.json();
|
|
|
+ if (!res.ok) {
|
|
|
+ showToast('Error', data.error || 'Failed to save DHCP client', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ closeDHCPClientModal();
|
|
|
+ showToast('Success', id ? 'DHCP Client updated successfully' : `DHCP Client activated on ${iface}`, 'success');
|
|
|
+ await loadDHCPClients();
|
|
|
+ fetchData(); // refresh interfaces list with new lease/status
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleDHCPClientState(id, enabled) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/dhcp/clients/${id}/state`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ enabled })
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+ if (!res.ok) {
|
|
|
+ showToast('Error', data.error || 'Failed to toggle client state', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ showToast('Updated', `DHCP client ${enabled ? 'enabled' : 'disabled'}`, 'info');
|
|
|
+ await loadDHCPClients();
|
|
|
+ fetchData();
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function renewDHCPClient(id) {
|
|
|
+ try {
|
|
|
+ showToast('Renewing...', 'Requesting DHCP lease renewal from upstream server', 'info');
|
|
|
+ const res = await fetch(`${API_BASE}/dhcp/clients/${id}/renew`, {
|
|
|
+ method: 'POST'
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+ if (!res.ok) {
|
|
|
+ showToast('Error', data.error || 'Failed to renew lease', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ showToast('Lease Renewed', 'DHCP renewal signal sent successfully', 'success');
|
|
|
+ setTimeout(() => {
|
|
|
+ loadDHCPClients();
|
|
|
+ fetchData();
|
|
|
+ }, 1500);
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function releaseDHCPClient(id) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/dhcp/clients/${id}/release`, {
|
|
|
+ method: 'POST'
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+ if (!res.ok) {
|
|
|
+ showToast('Error', data.error || 'Failed to release lease', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ showToast('Lease Released', 'DHCP lease released from upstream server', 'info');
|
|
|
+ await loadDHCPClients();
|
|
|
+ fetchData();
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteDHCPClient(id, ifaceName) {
|
|
|
+ if (!confirm(`Are you sure you want to remove the DHCP Client on ${ifaceName}? The interface will release its dynamic IP.`)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/dhcp/clients/${id}`, {
|
|
|
+ method: 'DELETE'
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+ if (!res.ok) {
|
|
|
+ showToast('Error', data.error || 'Failed to delete DHCP client', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ showToast('Deleted', `DHCP client removed from ${ifaceName}`, 'info');
|
|
|
+ await loadDHCPClients();
|
|
|
+ fetchData();
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
|