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:
Claude
2026-03-23 15:04:51 +00:00
parent b1b2b91905
commit 8b4ed7f331
3 changed files with 202 additions and 5 deletions
+144 -3
View File
@@ -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}