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:
+1
-1
File diff suppressed because one or more lines are too long
+267
-16
@@ -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([]);
|
||||
|
||||
Reference in New Issue
Block a user