Add port forwarding, PoE dashboard, topology tabs + deduplicate firewall

Removed duplicate firewall policy endpoints (kept existing ones at
/api/firewall/* which match the frontend).

Port Forwarding tab:
- Create/delete OPNsense NAT port forwards via API
- Track rule UUIDs for clean removal
- Form: protocol, WAN port, target IP:port, description
- Table: active forwards with one-click remove
- Note: for HTTP services, use Services tab (Caddy) instead

PoE Budget tab:
- Visual power bar: used/total/remaining watts with percentage
- Color-coded thresholds: green (<75%), orange (75-90%), red (>90%)
- Warning banner when budget exceeds 85%
- Per-port power draw grid with status indicators
- Auto-parsed from cached switch PoE status

Network Topology tab:
- Auto-generated from live switch + OPNsense data
- Router node: IP, version, online/offline status
- Switch node: hostname, IP, port up/down counts
- Trunk link visualization between router and switch
- VLAN fan-out cards: port counts, device counts, subnets
- One-click refresh

https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
This commit is contained in:
Claude
2026-03-28 02:27:50 +00:00
parent 130baf9303
commit e486de26b0
2 changed files with 321 additions and 363 deletions
+321
View File
@@ -1528,6 +1528,7 @@ export default function App() {
{ id:"network", label:"Network" },
{ id:"firewall", label:"Firewall" },
{ id:"services", label:"Services" },
{ id:"portfwd", label:"Port Fwd" },
{ id:"ports", label:"Port Map" },
{ id:"vlans", label:"VLANs" },
{ id:"acls", label:"ACL Builder" },
@@ -1536,6 +1537,8 @@ export default function App() {
{ id:"dhcp", label:"DHCP" },
{ id:"dns", label:"DNS Filtering" },
{ id:"vpn", label:"VPN" },
{ id:"poe", label:"PoE" },
{ id:"topology", label:"Topology" },
{ id:"backups", label:"Backups" },
{ id:"alerts", label:"Alerts" },
];
@@ -1638,6 +1641,16 @@ export default function App() {
onNeedAuth={() => setShowTotp(true)}
backendOk={pollStatus!=="err"}
/>}
{tab==="portfwd" && <PortForwardTab
session={session}
onNeedAuth={() => setShowTotp(true)}
backendOk={pollStatus!=="err"}
/>}
{tab==="poe" && <PoETab backendOk={pollStatus!=="err"} />}
{tab==="topology" && <TopologyTab
vlans={vlans} ports={ports}
backendOk={pollStatus!=="err"}
/>}
{showTotp && <TotpModal
onSuccess={handleTotpSuccess}
@@ -5608,3 +5621,311 @@ function VlanScheduleWizard({ vlans, session, onNeedAuth, onSaved }) {
</div>
);
}
//
// PORT FORWARD TAB
//
function PortForwardTab({ session, onNeedAuth, backendOk }) {
const [forwards, setForwards] = useState([]);
const [form, setForm] = useState({ proto:"tcp", wan_port:"", target_ip:"", target_port:"", description:"" });
const [creating, setCreating] = useState(false);
const load = async () => {
try { setForwards((await API("/port-forwards")).forwards || []); } catch(e) { console.error(e); }
};
useEffect(() => { if (backendOk) load(); }, [backendOk]);
const create = async () => {
if (!session) { onNeedAuth(); return; }
setCreating(true);
try {
const r = await API("/port-forwards", { method:"POST", body:{
token: session.token, forward: {...form, target_port: form.target_port || form.wan_port }
}});
if (r.note) alert(r.note);
setForm({ proto:"tcp", wan_port:"", target_ip:"", target_port:"", description:"" });
await load();
} catch(e) { alert("Failed: " + e.message); }
setCreating(false);
};
const remove = async (uuid) => {
if (!session) { onNeedAuth(); return; }
await API("/port-forwards", { method:"DELETE", body:{ token:session.token, uuid }});
await load();
};
return (
<div className="main">
<div style={{flex:1}}>
<div className="panel">
<div className="ph">Port Forwarding OPNsense NAT</div>
<div className="pb" style={{fontSize:12,color:"var(--dm)",lineHeight:1.6,marginBottom:12}}>
Forward WAN ports to internal servers. For services behind Caddy (reverse proxy),
you only need port 443 forwarded Caddy handles routing by hostname.
Use this for non-HTTP services (game servers, SSH, mail, etc.).
</div>
<div style={{display:"grid",gridTemplateColumns:"80px 100px 1fr 100px 1fr",gap:12,alignItems:"flex-end"}}>
<div className="field"><label>Protocol</label>
<select value={form.proto} onChange={e => setForm(f => ({...f, proto: e.target.value}))}>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
<option value="tcp/udp">Both</option>
</select>
</div>
<div className="field"><label>WAN Port</label>
<input value={form.wan_port} onChange={e => setForm(f => ({...f, wan_port: e.target.value}))}
placeholder="25565" type="number"/>
</div>
<div className="field"><label>Target IP (LAN server)</label>
<input value={form.target_ip} onChange={e => setForm(f => ({...f, target_ip: e.target.value}))}
placeholder="192.168.1.100"/>
</div>
<div className="field"><label>Target Port</label>
<input value={form.target_port} onChange={e => setForm(f => ({...f, target_port: e.target.value}))}
placeholder="same" type="number"/>
</div>
<div className="field"><label>Description</label>
<input value={form.description} onChange={e => setForm(f => ({...f, description: e.target.value}))}
placeholder="Minecraft server"/>
</div>
</div>
<button className="btn bp" onClick={create} disabled={creating || !form.wan_port || !form.target_ip}
style={{marginTop:12}}>
{creating ? "Creating..." : "Create Port Forward"}
</button>
</div>
{forwards.length > 0 && (
<div className="panel">
<div className="ph">Active Port Forwards ({forwards.length})</div>
<div className="pb">
<table className="vtbl">
<thead><tr><th>Proto</th><th>WAN Port</th><th>Target</th><th>Description</th><th>Created</th><th></th></tr></thead>
<tbody>
{forwards.map((f,i) => (
<tr key={i}>
<td style={{fontWeight:600,textTransform:"uppercase"}}>{f.proto}</td>
<td style={{fontFamily:"monospace",color:"var(--ac)"}}>{f.wan_port}</td>
<td style={{fontFamily:"monospace"}}>{f.target_ip}:{f.target_port}</td>
<td>{f.description || "—"}</td>
<td style={{fontSize:11,color:"var(--dm)"}}>{f.created_at || "—"}</td>
<td><button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
onClick={() => remove(f.uuid)}>Remove</button></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
}
//
// POE BUDGET TAB
//
function PoETab({ backendOk }) {
const [poe, setPoe] = useState(null);
const [loading, setLoading] = useState(false);
const load = async () => {
setLoading(true);
try { setPoe(await API("/poe/budget")); } catch(e) { console.error(e); }
setLoading(false);
};
useEffect(() => { if (backendOk) load(); }, [backendOk]);
if (!poe || !poe.available) return (
<div className="main"><div style={{flex:1}}>
<div className="panel"><div className="ph">PoE Budget</div>
<div className="pb" style={{color:"var(--dm)",fontSize:12,textAlign:"center",padding:24}}>
{loading ? "Loading..." : "No PoE data available — switch may be offline"}
</div>
</div>
</div></div>
);
const pct = poe.percent_used || 0;
const barColor = pct > 90 ? "#ff1744" : pct > 75 ? "#ff6d00" : "#00e676";
return (
<div className="main">
<div style={{flex:1}}>
<div className="panel">
<div className="ph">PoE Power Budget</div>
<div className="pb">
{/* Budget bar */}
<div style={{marginBottom:20}}>
<div style={{display:"flex",justifyContent:"space-between",fontSize:12,marginBottom:6}}>
<span>Used: <b style={{color:barColor}}>{poe.used_watts || "?"}W</b></span>
<span>Available: <b>{poe.total_watts || "?"}W</b></span>
<span>Remaining: <b style={{color:"#00e676"}}>{poe.remaining_watts || "?"}W</b></span>
</div>
<div style={{height:24,background:"var(--b1)",borderRadius:12,overflow:"hidden",position:"relative"}}>
<div style={{
height:"100%",width:`${Math.min(pct,100)}%`,background:barColor,
borderRadius:12,transition:"width 0.5s",
}}/>
<div style={{
position:"absolute",top:0,left:0,right:0,bottom:0,
display:"flex",alignItems:"center",justifyContent:"center",
fontSize:12,fontWeight:700,color:"var(--tx)",
}}>
{pct.toFixed(1)}%
</div>
</div>
{pct > 85 && (
<div style={{marginTop:8,fontSize:12,color:"#ff6d00",fontWeight:600}}>
Warning: PoE budget above 85%. New PoE devices may not power up.
</div>
)}
</div>
{/* Per-port table */}
{poe.ports?.length > 0 && (
<>
<div className="sect">Per-Port Power Draw</div>
<div style={{display:"flex",flexWrap:"wrap",gap:6}}>
{poe.ports.map(p => (
<div key={p.port} style={{
padding:"6px 10px",borderRadius:4,fontSize:11,minWidth:70,textAlign:"center",
background: p.watts > 0 ? barColor + "15" : "var(--bg)",
border: `1px solid ${p.watts > 0 ? barColor + "30" : "var(--b2)"}`,
}}>
<div style={{fontWeight:700,color:"var(--ac)"}}>Port {p.port}</div>
<div style={{color: p.watts > 0 ? barColor : "var(--dm)"}}>{p.watts}W</div>
<div style={{fontSize:9,color:"var(--dm)"}}>{p.status}</div>
</div>
))}
</div>
</>
)}
<button className="btn bd" onClick={load} disabled={loading}
style={{marginTop:16,fontSize:11,padding:"4px 12px"}}>
{loading ? "Loading..." : "Refresh"}
</button>
</div>
</div>
</div>
</div>
);
}
//
// TOPOLOGY TAB network diagram
//
function TopologyTab({ vlans, ports, backendOk }) {
const [topo, setTopo] = useState(null);
const [loading, setLoading] = useState(false);
const load = async () => {
setLoading(true);
try { setTopo(await API("/topology")); } catch(e) { console.error(e); }
setLoading(false);
};
useEffect(() => { if (backendOk) load(); }, [backendOk]);
const upPorts = (topo?.ports || []).filter(p => p.link === "up");
const downPorts = (topo?.ports || []).filter(p => p.link === "down");
return (
<div className="main">
<div style={{flex:1}}>
<div className="panel">
<div className="ph">Network Topology</div>
<div className="pb">
{/* Router */}
<div style={{textAlign:"center",marginBottom:20}}>
<div style={{
display:"inline-block",padding:"16px 32px",borderRadius:8,
background: topo?.router?.connected ? "rgba(0,230,118,0.1)" : "rgba(255,23,68,0.1)",
border: `2px solid ${topo?.router?.connected ? "#00e676" : "#ff1744"}`,
}}>
<div style={{fontSize:16,fontWeight:700,color:"var(--ac)"}}>OPNsense</div>
<div style={{fontSize:12,color:"var(--dm)"}}>{topo?.router?.ip || "not configured"}</div>
{topo?.router?.version && <div style={{fontSize:10,color:"var(--dm)"}}>v{topo.router.version}</div>}
<div style={{fontSize:11,marginTop:4,color:topo?.router?.connected?"#00e676":"#ff1744"}}>
{topo?.router?.connected ? "Online" : "Offline"}
</div>
</div>
</div>
{/* Trunk link */}
<div style={{textAlign:"center",marginBottom:20}}>
<div style={{width:2,height:30,background:"var(--ac)",margin:"0 auto"}}/>
<div style={{fontSize:10,color:"var(--dm)"}}>Trunk (all VLANs tagged)</div>
<div style={{width:2,height:30,background:"var(--ac)",margin:"0 auto"}}/>
</div>
{/* Switch */}
<div style={{textAlign:"center",marginBottom:20}}>
<div style={{
display:"inline-block",padding:"16px 32px",borderRadius:8,
background: topo?.switch?.connected ? "rgba(0,230,118,0.1)" : "rgba(255,23,68,0.1)",
border: `2px solid ${topo?.switch?.connected ? "#00e676" : "#ff1744"}`,
}}>
<div style={{fontSize:16,fontWeight:700,color:"var(--ac)"}}>{topo?.switch?.hostname || "ERS-5952"}</div>
<div style={{fontSize:12,color:"var(--dm)"}}>{topo?.switch?.ip}</div>
<div style={{fontSize:11,marginTop:4,color:topo?.switch?.connected?"#00e676":"#ff1744"}}>
{topo?.switch?.connected ? "Online" : "Offline"}
</div>
<div style={{fontSize:11,color:"var(--dm)",marginTop:4}}>
{upPorts.length} ports up, {downPorts.length} down
</div>
</div>
</div>
{/* VLANs fan out */}
<div style={{display:"flex",flexWrap:"wrap",gap:12,justifyContent:"center",marginTop:20}}>
{vlans.map(v => {
const vlanPorts = ports.filter(p =>
(p.mode === "access" && p.accessVlan === v.id) ||
(p.mode === "trunk" && p.taggedVlans?.includes(v.id))
);
const upCount = vlanPorts.filter(p => {
const tp = (topo?.ports || []).find(tp => tp.id === p.id);
return tp?.link === "up";
}).length;
const devices = (topo?.devices || []).filter(d => d.vlan === v.id);
return (
<div key={v.id} style={{
padding:"12px 16px",borderRadius:8,minWidth:150,textAlign:"center",
background: v.color + "10",border:`1px solid ${v.color}40`,
}}>
<div style={{fontWeight:700,color:v.color,fontSize:14}}>VLAN {v.id}</div>
<div style={{fontSize:12,color:"var(--tx)"}}>{v.name}</div>
<div style={{fontSize:11,color:"var(--dm)",marginTop:6}}>
{vlanPorts.length} ports ({upCount} up)
</div>
<div style={{fontSize:11,color:"var(--dm)"}}>
{devices.length} registered device{devices.length !== 1 ? "s" : ""}
</div>
<div style={{fontSize:10,color:"var(--dm)",marginTop:2}}>
192.168.{v.id}.0/24
</div>
</div>
);
})}
</div>
<button className="btn bd" onClick={load} disabled={loading}
style={{marginTop:20,fontSize:11,padding:"4px 12px"}}>
{loading ? "Loading..." : "Refresh"}
</button>
</div>
</div>
</div>
</div>
);
}