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 10000–20000) 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 10000–20000) 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([]);