Add DHCP relay config — all VLANs except 99 relay to OPNsense
- Backend: _get_relay_status() reads current ip helper-address per VLAN - Backend: _build_relay_cmds() generates ERS 5952 relay CLI commands - Backend: /api/dhcp/relay/status and /api/dhcp/relay/configure endpoints - Backend: dhcp_overview now includes relay status in response - Frontend: VLAN_MAP + VlanBadge + vlanFromIp() helpers for consistent labelling - Frontend: RelayPanel shows per-VLAN relay status grid with push button; VLAN 99 always shown as locked/local, VLANs 10/20/30/40/50 show live relay target and purpose note - Frontend: Reservations table gains VLAN column and inline purpose note (from descr/notes or VLAN_MAP fallback) VLAN 99 is excluded from relay at both backend and UI level — it is the switch management / OPNsense recovery path. https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
This commit is contained in:
+144
-3
@@ -1906,6 +1906,131 @@ function WireGuardTab({ session, onNeedAuth, backendOk }) {
|
||||
// DHCP MANAGEMENT TAB
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// VLAN definitions — matches the network plan
|
||||
const VLAN_MAP = {
|
||||
99: { name:"Management", color:"var(--dm)", note:"Switch recovery — always local" },
|
||||
10: { name:"House", color:"var(--ok)", note:"Devices get hostname.lan via OPNsense DNS" },
|
||||
20: { name:"Servers", color:"var(--ac)", note:"Server names resolve via OPNsense Unbound" },
|
||||
30: { name:"IoT", color:"var(--warn)",note:"Isolated devices, predictable IPs for firewall rules" },
|
||||
40: { name:"Guest", color:"#b388ff", note:"Internet only — printer access via firewall rule" },
|
||||
50: { name:"Cameras", color:"#ff80ab", note:"NVR-only access, no internet" },
|
||||
};
|
||||
|
||||
// Infer VLAN from IP address third octet
|
||||
function vlanFromIp(ip) {
|
||||
if (!ip) return null;
|
||||
const third = parseInt(ip.split('.')[2], 10);
|
||||
return isNaN(third) ? null : third;
|
||||
}
|
||||
|
||||
function VlanBadge({ vlan }) {
|
||||
const info = VLAN_MAP[vlan];
|
||||
const color = info?.color || "var(--dm)";
|
||||
const label = info ? `VLAN ${vlan} · ${info.name}` : (vlan ? `VLAN ${vlan}` : "—");
|
||||
return (
|
||||
<span style={{
|
||||
background: `${color}18`, color, borderRadius:10,
|
||||
padding:"1px 7px", fontSize:10, fontFamily:"var(--mono)", fontWeight:700,
|
||||
whiteSpace:"nowrap",
|
||||
}}>{label}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Relay panel — shows per-VLAN relay status and lets you push config
|
||||
function RelayPanel({ overview, opnsenseHost, session, onNeedAuth, onRefresh }) {
|
||||
const [pushing, setPushing] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [opIp, setOpIp] = useState(opnsenseHost || "");
|
||||
|
||||
useEffect(() => { if (opnsenseHost && !opIp) setOpIp(opnsenseHost); }, [opnsenseHost]);
|
||||
|
||||
const relay = overview?.relay || {};
|
||||
const relayMap = relay.vlans || {}; // { "10": "192.168.99.1", ... }
|
||||
|
||||
const relayVlans = [10, 20, 30, 40, 50];
|
||||
|
||||
const push = async () => {
|
||||
if (!session) { onNeedAuth(); return; }
|
||||
if (!opIp) return;
|
||||
setPushing(true); setResult(null);
|
||||
try {
|
||||
const r = await API("/dhcp/relay/configure", {
|
||||
method:"POST", body:{ token:session.token, opnsense_ip:opIp, vlans:relayVlans }
|
||||
});
|
||||
setResult(r);
|
||||
onRefresh();
|
||||
} catch(e) { setResult({ success:false, error:e.message }); }
|
||||
setPushing(false);
|
||||
};
|
||||
|
||||
const allConfigured = relayVlans.every(v => relayMap[v] === opIp && opIp);
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="ph">◈ DHCP Relay — Switch → OPNsense</div>
|
||||
<div className="pb">
|
||||
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:14}}>
|
||||
The switch relays DHCP requests from each VLAN to OPNsense, which assigns
|
||||
IPs, records leases, and registers hostnames in Unbound DNS automatically.
|
||||
VLAN 99 always stays local — it's the recovery path if OPNsense is unreachable.
|
||||
</div>
|
||||
|
||||
{/* Per-VLAN status grid */}
|
||||
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(200px,1fr))",gap:8,marginBottom:14}}>
|
||||
|
||||
{/* VLAN 99 — always local */}
|
||||
<div style={{background:"var(--bg)",border:"1px solid var(--b2)",borderRadius:4,padding:"10px 12px"}}>
|
||||
<div style={{display:"flex",alignItems:"center",gap:6,marginBottom:4}}>
|
||||
<VlanBadge vlan={99}/>
|
||||
<span style={{fontSize:10,marginLeft:"auto",color:"var(--dm)"}}>🔒 local</span>
|
||||
</div>
|
||||
<div style={{fontSize:10,color:"var(--dm)",lineHeight:1.5}}>{VLAN_MAP[99].note}</div>
|
||||
</div>
|
||||
|
||||
{relayVlans.map(vid => {
|
||||
const configured = relayMap[vid];
|
||||
const isOk = configured && configured === opIp;
|
||||
const stale = configured && configured !== opIp;
|
||||
return (
|
||||
<div key={vid} style={{background:"var(--bg)",border:`1px solid ${isOk?"rgba(0,230,118,.3)":stale?"rgba(255,234,0,.3)":"var(--b2)"}`,borderRadius:4,padding:"10px 12px"}}>
|
||||
<div style={{display:"flex",alignItems:"center",gap:6,marginBottom:4}}>
|
||||
<VlanBadge vlan={vid}/>
|
||||
<span style={{fontSize:10,marginLeft:"auto",
|
||||
color: isOk?"var(--ok)":stale?"var(--warn)":"var(--dm)"}}>
|
||||
{isOk ? `→ ${configured}` : stale ? `→ ${configured} ⚠` : "not set"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{fontSize:10,color:"var(--dm)",lineHeight:1.5}}>{VLAN_MAP[vid]?.note}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Push controls */}
|
||||
<div style={{display:"flex",gap:8,alignItems:"flex-end",flexWrap:"wrap"}}>
|
||||
<div className="field" style={{margin:0}}>
|
||||
<label>OPNsense IP</label>
|
||||
<input value={opIp} onChange={e=>setOpIp(e.target.value)}
|
||||
placeholder="192.168.99.1"
|
||||
style={{fontFamily:"var(--mono)",maxWidth:160}}/>
|
||||
</div>
|
||||
<button className="btn bp" onClick={push}
|
||||
disabled={pushing||!opIp||!session}
|
||||
style={{fontSize:11}}>
|
||||
{pushing ? "Pushing…" : allConfigured ? "✓ Re-apply Relay Config" : "Push Relay Config to Switch"}
|
||||
</button>
|
||||
</div>
|
||||
{result && (
|
||||
<div style={{marginTop:8,fontSize:11,
|
||||
color:result.success?"var(--ok)":"var(--err)"}}>
|
||||
{result.success ? "✓ Relay config pushed — save running-config to make permanent" : `✗ ${result.error||"Push failed"}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConflictBadge({ conflict }) {
|
||||
return (
|
||||
<div style={{
|
||||
@@ -2035,6 +2160,10 @@ function DHCPRow({ res, source, onSync, session, onNeedAuth }) {
|
||||
const sourceColor = source === "switch" ? "var(--ac)" : "var(--warn)";
|
||||
const sourceName = source === "switch" ? "Switch" : "OPNsense";
|
||||
|
||||
// Derive VLAN: prefer explicit vlan field, fall back to IP third octet
|
||||
const vid = res.vlan || vlanFromIp(res.ip);
|
||||
const purpose = res.descr || res.notes || VLAN_MAP[vid]?.note || "";
|
||||
|
||||
return (
|
||||
<tr style={{borderBottom:"1px solid var(--b1)"}}>
|
||||
<td style={{padding:"7px 10px",fontFamily:"var(--mono)",fontSize:11,color:"var(--dm)"}}>
|
||||
@@ -2044,7 +2173,11 @@ function DHCPRow({ res, source, onSync, session, onNeedAuth }) {
|
||||
{res.ip}
|
||||
</td>
|
||||
<td style={{padding:"7px 10px",fontSize:12}}>
|
||||
{res.hostname || res.descr || <span style={{color:"var(--dm)"}}>—</span>}
|
||||
{res.hostname || res.name || <span style={{color:"var(--dm)"}}>—</span>}
|
||||
{purpose && <div style={{fontSize:10,color:"var(--dm)",marginTop:2}}>{purpose}</div>}
|
||||
</td>
|
||||
<td style={{padding:"7px 10px"}}>
|
||||
{vid ? <VlanBadge vlan={vid}/> : <span style={{color:"var(--dm)",fontSize:10}}>—</span>}
|
||||
</td>
|
||||
<td style={{padding:"7px 10px"}}>
|
||||
<span style={{
|
||||
@@ -2052,7 +2185,6 @@ function DHCPRow({ res, source, onSync, session, onNeedAuth }) {
|
||||
borderRadius: 10, padding: "1px 7px", fontSize: 10,
|
||||
fontFamily: "var(--mono)", fontWeight: 700
|
||||
}}>{sourceName}</span>
|
||||
{res.if && <span style={{fontSize:10,color:"var(--dm)",marginLeft:6}}>{res.if}</span>}
|
||||
</td>
|
||||
<td style={{padding:"7px 10px"}}>
|
||||
<div style={{display:"flex",gap:4}}>
|
||||
@@ -2197,6 +2329,15 @@ function DHCPTab({ session, onNeedAuth, backendOk }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Relay configuration */}
|
||||
<RelayPanel
|
||||
overview={overview}
|
||||
opnsenseHost={ops?.host||""}
|
||||
session={session}
|
||||
onNeedAuth={onNeedAuth}
|
||||
onRefresh={load}
|
||||
/>
|
||||
|
||||
{/* Conflicts */}
|
||||
{conflicts.length > 0 && (
|
||||
<div className="panel">
|
||||
@@ -2269,7 +2410,7 @@ function DHCPTab({ session, onNeedAuth, backendOk }) {
|
||||
<table style={{width:"100%",borderCollapse:"collapse"}}>
|
||||
<thead>
|
||||
<tr style={{borderBottom:"1px solid var(--b1)"}}>
|
||||
{["MAC","IP","Name / Description","Source","Actions"].map(h=>(
|
||||
{["MAC","IP","Name / Description","VLAN","Source","Actions"].map(h=>(
|
||||
<th key={h} style={{textAlign:"left",padding:"6px 10px",fontSize:10,
|
||||
letterSpacing:2,textTransform:"uppercase",color:"var(--dm)"}}>
|
||||
{h}
|
||||
|
||||
Reference in New Issue
Block a user