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",
|
id: "iot",
|
||||||
label: "IoT VLAN — internet only, no RFC1918",
|
label: "IoT / TV / Printer 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.",
|
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"],
|
params: ["subnet"],
|
||||||
build: ({ subnet, vid }) => ({
|
build: ({ subnet, vid }) => ({
|
||||||
name: `IOT-VLAN${vid}-POLICY`,
|
name: `IOT-VLAN${vid}-POLICY`,
|
||||||
@@ -1101,22 +1101,34 @@ const ACL_TEMPLATES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "voip",
|
id: "voip",
|
||||||
label: "SIP Phone VLAN — Asterisk / FreePBX access",
|
label: "SIP Phone VLAN — Asterisk / FreePBX (local desk phones)",
|
||||||
description: [
|
description: [
|
||||||
"Permits SIP phones to reach an Asterisk PBX on another VLAN.",
|
"USE THIS TEMPLATE FOR: dedicated desk phones or ATAs that are always on a fixed VLAN",
|
||||||
"Allows SIP signaling (UDP/TCP 5060), SIP/TLS (TCP 5061), and RTP audio (UDP 10000–20000) to the PBX IP.",
|
"and register with Asterisk over the local network.",
|
||||||
"Blocks access to the management VLAN (99). All other traffic (internet, etc.) is permitted.",
|
|
||||||
"",
|
"",
|
||||||
"NOTE — inter-VLAN routing is enforced by OPNsense, not the switch.",
|
"Allows SIP signaling (UDP/TCP 5060), SIP/TLS (TCP 5061), and RTP audio",
|
||||||
"Also add these rules in OPNsense Firewall → Rules → VLAN interfaces:",
|
"(UDP 10000–20000) to the PBX IP. Blocks management VLAN 99. Permits internet.",
|
||||||
" • 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)",
|
|
||||||
"",
|
"",
|
||||||
"For remote Asterisk access without VPN:",
|
"The ERS 5952 performs L3 routing between VLANs — this switch ACL IS the",
|
||||||
" WebRTC (recommended) — add a Caddy reverse proxy for pbx.yourdomain.com → PBX:8089 (WSS)",
|
"enforcement point. No OPNsense inter-VLAN firewall rules needed for local SIP.",
|
||||||
" SIP/TLS — NAT port 5061 to PBX, install fail2ban, use a TLS-capable SIP client",
|
"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"),
|
].join("\n"),
|
||||||
params: ["subnet", "pbxIp"],
|
params: ["subnet", "pbxIp"],
|
||||||
build: ({ subnet, vid, pbxIp }) => ({
|
build: ({ subnet, vid, pbxIp }) => ({
|
||||||
@@ -1369,7 +1381,7 @@ function AclTab({ acls, setAcls, vlans }) {
|
|||||||
// APP
|
// APP
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [tab, setTab] = useState("ports");
|
const [tab, setTab] = useState("dashboard");
|
||||||
const [ports, setPorts] = useState(DEFAULT_PORTS);
|
const [ports, setPorts] = useState(DEFAULT_PORTS);
|
||||||
const [vlans, setVlans] = useState(DEFAULT_VLANS);
|
const [vlans, setVlans] = useState(DEFAULT_VLANS);
|
||||||
const [acls, setAcls] = useState([]);
|
const [acls, setAcls] = useState([]);
|
||||||
@@ -1427,6 +1439,7 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
|
{ id:"dashboard",label:"Dashboard" },
|
||||||
{ id:"ports", label:"Port Map" },
|
{ id:"ports", label:"Port Map" },
|
||||||
{ id:"vlans", label:"VLANs" },
|
{ id:"vlans", label:"VLANs" },
|
||||||
{ id:"acls", label:"ACL Builder" },
|
{ id:"acls", label:"ACL Builder" },
|
||||||
@@ -1466,6 +1479,12 @@ export default function App() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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==="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==="vlans" && <VlanTab vlans={vlans} setVlans={setVlans} ports={ports}/>}
|
||||||
{tab==="acls" && <AclTab acls={acls} setAcls={setAcls} vlans={vlans}/>}
|
{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 = [] }) {
|
function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
|
||||||
const [status, setStatus] = useState(null);
|
const [status, setStatus] = useState(null);
|
||||||
const [clients, setClients] = useState([]);
|
const [clients, setClients] = useState([]);
|
||||||
|
|||||||
Reference in New Issue
Block a user