Add Dashboard tab as landing page; fix VoIP/IoT template descriptions

Dashboard tab (now default landing page):
- Service status cards: Switch / OPNsense / ctrld DNS / WireGuard
  each clickable to navigate to the relevant tab
- Setup checklist with progress bar: 8 steps from switch connection
  through WireGuard, each uncompleted step is clickable and navigates
  to the tab where that step is configured
- VLAN health grid: per-VLAN card showing device count (from DHCP
  leases), Relay / DNS / ACL status dots, and inline "+ add" nudges
  for anything not yet configured
- Quick Access buttons to all tabs
- All data loaded in parallel from existing API endpoints — no new
  backend endpoints needed

ACL template fixes:
- VoIP template label updated: "local desk phones" clarifies when to use it
- VoIP description now explains: ACL is subnet-based (MAC randomization
  irrelevant), switch IS the L3 enforcement point (no OPNsense inter-VLAN
  rules needed), and gives the correct mobile-softphone recommendation:
  configure with public FQDN via OPNsense port forwarding + NAT reflection
- IoT template label/description updated to explicitly list smart TVs,
  printers, IoT as targets and mention it blocks PBX + all LAN services

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
This commit is contained in:
Claude
2026-03-23 16:47:16 +00:00
parent 2ebdb22e0a
commit 5c8536effd
2 changed files with 268 additions and 17 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+267 -16
View File
@@ -1029,8 +1029,8 @@ const ACL_TEMPLATES = [
},
{
id: "iot",
label: "IoT VLAN — internet only, no RFC1918",
description: "Blocks access to all private IP ranges (RFC1918). IoT devices get internet but cannot reach other VLANs, servers, or management.",
label: "IoT / TV / Printer VLAN — internet only, no RFC1918",
description: "Blocks all private IP ranges (RFC1918). Devices get internet but cannot reach any other VLAN servers, NAS, PBX, cameras, management, or any LAN service. Use for IoT, smart TVs, printers, and any device that should never initiate connections to internal resources.",
params: ["subnet"],
build: ({ subnet, vid }) => ({
name: `IOT-VLAN${vid}-POLICY`,
@@ -1101,22 +1101,34 @@ const ACL_TEMPLATES = [
},
{
id: "voip",
label: "SIP Phone VLAN — Asterisk / FreePBX access",
label: "SIP Phone VLAN — Asterisk / FreePBX (local desk phones)",
description: [
"Permits SIP phones to reach an Asterisk PBX on another VLAN.",
"Allows SIP signaling (UDP/TCP 5060), SIP/TLS (TCP 5061), and RTP audio (UDP 1000020000) to the PBX IP.",
"Blocks access to the management VLAN (99). All other traffic (internet, etc.) is permitted.",
"USE THIS TEMPLATE FOR: dedicated desk phones or ATAs that are always on a fixed VLAN",
"and register with Asterisk over the local network.",
"",
"NOTE — inter-VLAN routing is enforced by OPNsense, not the switch.",
"Also add these rules in OPNsense Firewall → Rules → VLAN interfaces:",
" • Pass: src=phone VLAN dst=PBX IP proto=UDP/TCP port=5060,5061",
" • Pass: src=phone VLAN dst=PBX IP proto=UDP port=10000-20000",
" • Pass: src=PBX IP dst=phone VLAN proto=UDP port=10000-20000 (return RTP)",
" • Block: src=phone VLAN dst=any other LAN VLAN (optional — defence in depth)",
"Allows SIP signaling (UDP/TCP 5060), SIP/TLS (TCP 5061), and RTP audio",
"(UDP 1000020000) to the PBX IP. Blocks management VLAN 99. Permits internet.",
"",
"For remote Asterisk access without VPN:",
" WebRTC (recommended) — add a Caddy reverse proxy for pbx.yourdomain.com → PBX:8089 (WSS)",
" SIP/TLS — NAT port 5061 to PBX, install fail2ban, use a TLS-capable SIP client",
"The ERS 5952 performs L3 routing between VLANs — this switch ACL IS the",
"enforcement point. No OPNsense inter-VLAN firewall rules needed for local SIP.",
"Note: the ACL is subnet-based, so phones get any DHCP IP on the VLAN and",
"it still works. For return RTP (PBX → phone), apply a matching permit on the",
"server VLAN ACL if you have one.",
"",
"FOR MOBILE SOFTPHONES: skip this template entirely.",
"Configure softphones with your public FQDN (e.g. pbx.yourdomain.com) and",
"connect via OPNsense port forwarding — same path whether on home WiFi or",
"cellular. This avoids VLAN roaming and MAC randomization issues completely.",
"",
" OPNsense setup for remote/FQDN softphone access:",
" • NAT port forward: WAN:5061 → Asterisk IP:5061 (SIP/TLS)",
" • NAT port forward: WAN:10000-20000 → Asterisk IP:10000-20000 (RTP)",
" • Enable NAT reflection so home WiFi phones work via the same FQDN",
" • Caddy reverse proxy for WebRTC: pbx.yourdomain.com → Asterisk:8089 (WSS)",
" • Asterisk fail2ban essential — SIP port 5061 will be scanned",
"",
"IoT/TV/Printer VLANs: use the IoT template on those VLANs — it blocks all",
"RFC1918 addresses including Asterisk. No additional config needed.",
].join("\n"),
params: ["subnet", "pbxIp"],
build: ({ subnet, vid, pbxIp }) => ({
@@ -1369,7 +1381,7 @@ function AclTab({ acls, setAcls, vlans }) {
// APP
// ══════════════════════════════════════════════════════════════════════════════
export default function App() {
const [tab, setTab] = useState("ports");
const [tab, setTab] = useState("dashboard");
const [ports, setPorts] = useState(DEFAULT_PORTS);
const [vlans, setVlans] = useState(DEFAULT_VLANS);
const [acls, setAcls] = useState([]);
@@ -1427,6 +1439,7 @@ export default function App() {
};
const TABS = [
{ id:"dashboard",label:"Dashboard" },
{ id:"ports", label:"Port Map" },
{ id:"vlans", label:"VLANs" },
{ id:"acls", label:"ACL Builder" },
@@ -1466,6 +1479,12 @@ export default function App() {
))}
</div>
{tab==="dashboard" && <DashboardTab
vlans={vlans} acls={acls} ports={ports}
backendOk={pollStatus!=="err"}
switchOk={pollStatus==="ok"}
onNavigate={setTab}
/>}
{tab==="ports" && <PortTab ports={ports} vlans={vlans} selected={selected} setSelected={setSelected} updatePort={updatePort} pollStatus={pollStatus==="ok"?"ok":"stale"}/>}
{tab==="vlans" && <VlanTab vlans={vlans} setVlans={setVlans} ports={ports}/>}
{tab==="acls" && <AclTab acls={acls} setAcls={setAcls} vlans={vlans}/>}
@@ -1799,6 +1818,238 @@ function QRModal({ config, name, onClose }) {
);
}
// ══════════════════════════════════════════════════════════════════════════════
// DASHBOARD TAB
// ══════════════════════════════════════════════════════════════════════════════
function DashboardTab({ vlans, acls, ports, backendOk, switchOk, onNavigate }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const load = async () => {
if (!backendOk) return;
setLoading(true);
try {
const [status, ctrld, wg, opnWg, relay, dhcp] = await Promise.allSettled([
API("/status"),
API("/ctrld/status"),
API("/wireguard/status"),
API("/opnsense/wireguard/status"),
API("/dhcp/relay/status"),
API("/dhcp/overview"),
]);
setData({
status: status.value || null,
ctrld: ctrld.value || null,
wg: wg.value || null,
opnWg: opnWg.value || null,
relay: relay.value || null,
dhcp: dhcp.value || null,
});
} catch(e) { /* partial data still rendered */ }
setLoading(false);
};
useEffect(() => { load(); }, [backendOk]);
// ── Derived health values ───────────────────────────────────────────
const opnConnected = data?.opnWg?.opnsense_configured;
const ctrldRunning = data?.ctrld?.running;
const ctrldMode = data?.ctrld?.mode;
const ctrldProfiles = data?.ctrld?.vlan_profiles || [];
const relayVlans = data?.relay?.vlans || {}; // {10: "192.168.99.1", ...}
const allLeases = data?.dhcp?.leases || [];
// Device count per VLAN (infer from IP third octet)
const devicesByVlan = {};
allLeases.forEach(l => {
const vid = l.ip ? parseInt(l.ip.split('.')[2], 10) : null;
if (vid) devicesByVlan[vid] = (devicesByVlan[vid] || 0) + 1;
});
// WireGuard: prefer OPNsense if configured + plugin installed
const opnWgReady = data?.opnWg?.plugin_installed && data?.opnWg?.server;
const localWgReady = data?.wg?.running;
const wgReady = opnWgReady || localWgReady;
const wgLabel = opnWgReady ? `OPNsense · ${(data.opnWg.peers||[]).length} peer(s)`
: localWgReady ? `Local · ${(data.wg.peers||[]).length} peer(s)`
: "not configured";
// Setup checklist items
const assignedPorts = ports.filter(p => p.mode !== "disabled" && (p.accessVlan !== 1 || p.mode === "trunk")).length;
const checklist = [
{ label:"Switch connected", done: switchOk, tab:"ports", detail: switchOk ? "online" : "check SSH key / IP" },
{ label:"VLANs defined", done: vlans.length > 1, tab:"vlans", detail: `${vlans.length} VLAN(s)` },
{ label:"Ports assigned", done: assignedPorts > 0, tab:"ports", detail: `${assignedPorts} port(s) configured` },
{ label:"ACL policies", done: acls.length > 0, tab:"acls", detail: acls.length > 0 ? `${acls.length} ACL(s)` : "none applied yet" },
{ label:"OPNsense connected", done: !!opnConnected, tab:"dhcp", detail: opnConnected ? "connected" : "add API key in DHCP tab" },
{ label:"DHCP relay", done: Object.keys(relayVlans).length > 0, tab:"dhcp", detail: Object.keys(relayVlans).length > 0 ? `${Object.keys(relayVlans).length} VLAN(s) relaying` : "not configured" },
{ label:"DNS filtering (ctrld)", done: !!ctrldRunning, tab:"dns", detail: ctrldRunning ? `${ctrldMode} mode` : "not configured" },
{ label:"WireGuard VPN", done: wgReady, tab:"vpn", detail: wgLabel },
];
const done = checklist.filter(c => c.done).length;
const total = checklist.length;
const pct = Math.round((done / total) * 100);
// Per-VLAN health row
const vlanHealth = vlans.filter(v => v.id !== 99).map(v => {
const hasDns = ctrldProfiles.some(p => p.vlan_id === v.id);
const hasRelay = !!relayVlans[v.id];
const hasAcl = acls.some(a => a.applyVlan === v.id);
const devices = devicesByVlan[v.id] || 0;
const score = (hasDns?1:0) + (hasRelay?1:0) + (hasAcl?1:0);
return { ...v, hasDns, hasRelay, hasAcl, devices, score };
});
const Dot = ({ on, warn }) => (
<span style={{
display:"inline-block",width:7,height:7,borderRadius:"50%",marginRight:4,
background: on ? "var(--ok)" : warn ? "var(--warn)" : "var(--b2)",
verticalAlign:"middle",
}}/>
);
const ServiceCard = ({ label, ok, detail, tab, icon }) => (
<div onClick={() => onNavigate(tab)} style={{
flex:"1 1 160px",background:"var(--bg2)",border:`1px solid ${ok?"var(--ok)":"var(--b2)"}`,
borderRadius:6,padding:"12px 14px",cursor:"pointer",transition:"border-color .15s",
minWidth:140,
}}>
<div style={{display:"flex",alignItems:"center",gap:6,marginBottom:6}}>
<span style={{fontSize:14}}>{icon}</span>
<span style={{fontSize:11,fontWeight:700,color: ok ? "var(--ok)" : "var(--dm)"}}>{label}</span>
<span style={{marginLeft:"auto",width:8,height:8,borderRadius:"50%",
background: ok ? "var(--ok)" : "var(--b2)"}}/>
</div>
<div style={{fontSize:10,color:"var(--dm)",fontFamily:"var(--mono)"}}>{detail || "—"}</div>
</div>
);
if (!backendOk) return (
<div className="main" style={{flexDirection:"column",gap:12}}>
<div className="panel"><div className="pb" style={{color:"var(--err)",fontSize:12}}>
Backend offline check that the switch manager service is running.
</div></div>
</div>
);
return (
<div className="main" style={{flexDirection:"column",gap:14}}>
{/* ── Service status row ──────────────────────────────────── */}
<div style={{display:"flex",flexWrap:"wrap",gap:10}}>
<ServiceCard icon="⬡" label="Switch" ok={switchOk} detail={switchOk?"connected · polling":"offline"} tab="ports"/>
<ServiceCard icon="◈" label="OPNsense" ok={!!opnConnected} detail={opnConnected?"connected":"not connected"} tab="dhcp"/>
<ServiceCard icon="◈" label="DNS / ctrld" ok={!!ctrldRunning} detail={ctrldRunning?`running · ${ctrldMode}`:"not running"} tab="dns"/>
<ServiceCard icon="◈" label="WireGuard" ok={wgReady} detail={wgLabel} tab="vpn"/>
</div>
{/* ── Setup checklist ─────────────────────────────────────── */}
<div className="panel">
<div className="ph" style={{display:"flex",alignItems:"center",gap:10}}>
Setup Progress
<div style={{flex:1,height:4,background:"var(--b1)",borderRadius:2,marginLeft:8,overflow:"hidden"}}>
<div style={{width:`${pct}%`,height:"100%",background:pct===100?"var(--ok)":"var(--ac)",transition:"width .4s"}}/>
</div>
<span style={{fontSize:11,fontFamily:"var(--mono)",color:"var(--dm)",whiteSpace:"nowrap"}}>
{done}/{total}
</span>
</div>
<div className="pb" style={{display:"flex",flexDirection:"column",gap:6}}>
{checklist.map((c,i) => (
<div key={i} style={{
display:"flex",alignItems:"center",gap:10,padding:"7px 10px",
borderRadius:5,cursor:c.done?"default":"pointer",
background: c.done ? "transparent" : "rgba(255,234,0,.04)",
border:`1px solid ${c.done?"transparent":"rgba(255,234,0,.12)"}`,
}} onClick={() => !c.done && onNavigate(c.tab)}>
<span style={{
width:18,height:18,borderRadius:"50%",display:"flex",alignItems:"center",
justifyContent:"center",fontSize:11,flexShrink:0,
background: c.done ? "var(--ok)" : "var(--b2)",
color: c.done ? "#000" : "var(--dm)",fontWeight:700,
}}>{c.done ? "✓" : (i+1)}</span>
<span style={{flex:1,fontSize:12,color:c.done?"var(--dm)":"var(--tx)",fontWeight:c.done?400:600}}>
{c.label}
</span>
<span style={{fontSize:11,color:"var(--dm)",fontFamily:"var(--mono)"}}>{c.detail}</span>
{!c.done && (
<span style={{fontSize:10,color:"var(--ac)",fontFamily:"var(--mono)",marginLeft:4}}>
{c.tab}
</span>
)}
</div>
))}
</div>
</div>
{/* ── Per-VLAN health grid ─────────────────────────────────── */}
<div className="panel">
<div className="ph"> VLAN Health</div>
<div className="pb" style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(260px,1fr))",gap:10}}>
{loading && <div style={{color:"var(--dm)",fontSize:12,gridColumn:"1/-1"}}>Loading</div>}
{vlanHealth.map(v => (
<div key={v.id} style={{
border:`1px solid ${v.color}44`,borderRadius:6,
padding:"10px 12px",background:`${v.color}08`,
}}>
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:8}}>
<div style={{width:10,height:10,borderRadius:"50%",background:v.color,flexShrink:0}}/>
<span style={{fontWeight:700,fontSize:12,color:v.color}}>VLAN {v.id}</span>
<span style={{fontSize:11,color:"var(--dm)"}}>· {v.name}</span>
<span style={{
marginLeft:"auto",fontFamily:"var(--mono)",fontSize:10,
color: v.devices>0 ? "var(--ok)" : "var(--dm)",
}}>{v.devices} device{v.devices!==1?"s":""}</span>
</div>
<div style={{display:"flex",gap:12,marginBottom:10,fontSize:11}}>
<span><Dot on={v.hasRelay}/> Relay</span>
<span><Dot on={v.hasDns}/> DNS</span>
<span><Dot on={v.hasAcl}/> ACL</span>
</div>
{/* Missing items nudge */}
{v.score < 3 && (
<div style={{fontSize:10,color:"var(--dm)",borderTop:"1px solid var(--b1)",paddingTop:6,lineHeight:1.8}}>
{!v.hasRelay && <span onClick={()=>onNavigate("dhcp")}
style={{cursor:"pointer",color:"var(--warn)",marginRight:8}}>+ relay</span>}
{!v.hasDns && <span onClick={()=>onNavigate("dns")}
style={{cursor:"pointer",color:"var(--warn)",marginRight:8}}>+ DNS profile</span>}
{!v.hasAcl && <span onClick={()=>onNavigate("acls")}
style={{cursor:"pointer",color:"var(--warn)"}}>+ ACL policy</span>}
</div>
)}
</div>
))}
</div>
</div>
{/* ── Quick links ──────────────────────────────────────────── */}
<div className="panel">
<div className="ph"> Quick Access</div>
<div className="pb" style={{display:"flex",flexWrap:"wrap",gap:8}}>
{[
{label:"Configure Ports", tab:"ports"},
{label:"Manage VLANs", tab:"vlans"},
{label:"Apply ACL Templates", tab:"acls"},
{label:"Review & Push", tab:"cli"},
{label:"DHCP / OPNsense", tab:"dhcp"},
{label:"DNS Filtering", tab:"dns"},
{label:"WireGuard VPN", tab:"vpn"},
{label:"Device Access", tab:"devices"},
].map(l=>(
<button key={l.tab} className="btn bg" style={{fontSize:11,padding:"5px 12px"}}
onClick={()=>onNavigate(l.tab)}>{l.label}</button>
))}
<button className="btn bg" style={{fontSize:11,padding:"5px 12px",marginLeft:"auto"}}
onClick={load}> Refresh</button>
</div>
</div>
</div>
);
}
function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
const [status, setStatus] = useState(null);
const [clients, setClients] = useState([]);