Files
avaya_switch_management/ers5952-manager.jsx
T
Claude 7928f6f769 Add firewall policy matrix, service proxy, ntfy alerts, and scheduler
Firewall inter-VLAN policy matrix:
- Visual VLAN-to-VLAN matrix with click-to-set policies
- Policy types: block, allow, one-way, printer, service-ports
- Printer template: other VLANs reach ports 9100/631/443/515, printers
  cannot initiate back — solves the "printer VLAN" use case
- Generates both switch ACLs AND OPNsense firewall rules
- Preview commands before pushing, auto-backup before changes

Service proxy (LAN services via FQDN without inter-VLAN access):
- Register services with FQDN + backend URL + allowed VLANs
- Deploy generates Caddyfile entries, Unbound DNS overrides, and
  firewall rules allowing only port 443 to the proxy
- Pattern: device on VLAN 30 → DNS resolves to mgmt box → Caddy
  proxies to actual LAN server — no VLAN-to-VLAN access needed

ntfy push notifications:
- Configure ntfy.sh or self-hosted ntfy server
- Alert events: connectivity lost/restored, PoE budget >85%,
  backup failures, push failures
- Integrated into poll loop — alerts fire on state transitions
- Test notification button

Scheduled operations:
- Cron-like scheduler for automated backups and connectivity checks
- Background thread checks every 60 seconds
- Per-schedule: name, action, hour, minute, days (mon,wed,fri or *)
- Run-now button for manual trigger
- ntfy notifications on scheduled task completion/failure

https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
2026-03-28 00:55:29 +00:00

5439 lines
258 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useCallback, useEffect, useRef } from "react";
const FONT = `@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow:wght@300;400;500;600;700&display=swap');`;
const VLAN_COLORS = ["#00e5ff","#00e676","#ffea00","#ff6d00","#d500f9","#ff1744","#76ff03","#2979ff","#ff4081","#18ffff"];
const DEFAULT_VLANS = [
{ id: 1, name: "Management", color: VLAN_COLORS[0] },
{ id: 10, name: "Staff", color: VLAN_COLORS[1] },
{ id: 20, name: "Servers", color: VLAN_COLORS[2] },
{ id: 30, name: "IoT", color: VLAN_COLORS[3] },
{ id: 40, name: "Guest", color: VLAN_COLORS[4] },
{ id: 50, name: "Cameras", color: VLAN_COLORS[5] },
];
const mkPort = n => ({
id: n, mode: "access", accessVlan: 1, taggedVlans: [],
nativeVlan: 1, poe: true, poeLimit: 30000, description: "",
});
const DEFAULT_PORTS = Array.from({ length: 52 }, (_, i) => mkPort(i + 1));
DEFAULT_PORTS.forEach((p, i) => {
if (i >= 48) {
p.mode = "trunk"; p.taggedVlans = [1,10,20,30,40,50];
p.poe = false; p.description = `SFP+ Uplink ${i-47}`;
}
});
const VISITOR_ID = Math.random().toString(36).slice(2);
// ── CLI Generator with explanations ────────────────────────────────────────
// Each entry: { cmd: string, explain: string, group: string }
function generateAnnotatedCLI({ ports, vlans, acls, hostname }) {
const entries = [];
const h = hostname || "ERS-5952";
const add = (cmd, explain, group) => entries.push({ cmd, explain, group });
add(`hostname ${h}`, `Set switch hostname to "${h}"`, "System");
vlans.forEach(v => {
add(`vlan create ${v.id} name "${v.name}" type port`,
`Create VLAN ${v.id} named "${v.name}" — port-based VLAN`, "VLANs");
});
ports.forEach(p => {
const iface = p.id <= 48 ? `FastEthernet ${p.id}` : `GigabitEthernet ${p.id}`;
const portLabel = p.description ? `port ${p.id} (${p.description})` : `port ${p.id}`;
const vlanName = vlans.find(v => v.id === p.accessVlan)?.name || `VLAN ${p.accessVlan}`;
const group = `Port ${p.id}${p.description ? ` — ${p.description}` : ""}`;
if (p.description) {
add(`interface ${iface}`, `Select ${portLabel} for configuration`, group);
add(` name "${p.description}"`, `Label ${portLabel} as "${p.description}"`, group);
}
if (p.mode === "disabled") {
add(`interface ${iface}`, `Select ${portLabel}`, group);
add(` shutdown`, `Disable ${portLabel} — no traffic will pass`, group);
} else if (p.mode === "access") {
add(`vlan members add ${p.accessVlan} ${p.id}`,
`Assign ${portLabel} to ${vlanName} (VLAN ${p.accessVlan})`, group);
add(`vlan pvid ${p.id} ${p.accessVlan}`,
`Set ${portLabel} untagged VLAN to ${vlanName} — devices here join ${vlanName}`, group);
} else if (p.mode === "trunk") {
const tagged = p.taggedVlans;
const names = tagged.map(id => vlans.find(v=>v.id===id)?.name || `VLAN ${id}`).join(", ");
if (tagged.length) {
add(`vlan members add ${tagged.join(",")} ${p.id}`,
`Add ${portLabel} to VLANs: ${names}`, group);
add(`vlan tagging ${tagged.join(",")} ${p.id}`,
`Tag traffic on ${portLabel} for VLANs: ${names} — used for uplinks and inter-switch connections`, group);
}
const nativeName = vlans.find(v=>v.id===p.nativeVlan)?.name || `VLAN ${p.nativeVlan}`;
add(`vlan pvid ${p.id} ${p.nativeVlan}`,
`Set ${portLabel} native (untagged) VLAN to ${nativeName}`, group);
}
if (p.id <= 48) {
add(`interface ${iface}`, `Select ${portLabel} for PoE configuration`, group);
if (!p.poe) {
add(` no poe enable`, `Disable Power over Ethernet on ${portLabel}`, group);
} else {
add(` poe enable`, `Enable Power over Ethernet on ${portLabel}`, group);
add(` poe poe-limit ${p.poeLimit}`,
`Limit PoE draw on ${portLabel} to ${(p.poeLimit/1000).toFixed(1)}W — protects switch power budget`, group);
}
}
});
acls.forEach(acl => {
const vlanName = vlans.find(v=>v.id===acl.applyVlan)?.name || `VLAN ${acl.applyVlan}`;
const group = `ACL: ${acl.name}`;
add(`ip access-list extended ${acl.name}`,
`Create access control list named "${acl.name}"`, group);
acl.rules.forEach((r, i) => {
const src = r.srcAny ? "any source" : `source ${r.src}`;
const dst = r.dstAny ? "any destination" : `destination ${r.dst}`;
const port = r.port ? ` on port ${r.port}` : "";
add(` ${i+1} ${r.action} ${r.proto} ${r.srcAny?"any":`${r.src} ${r.srcMask||"0.0.0.255"}`} ${r.dstAny?"any":`${r.dst} ${r.dstMask||"0.0.0.255"}`}${r.port?` eq ${r.port}`:""}`,
`Rule ${i+1}: ${r.action.toUpperCase()} ${r.proto.toUpperCase()} from ${src} to ${dst}${port}`, group);
});
add(`interface vlan ${acl.applyVlan}`,
`Select ${vlanName} interface to apply the ACL`, group);
add(` ip access-group ${acl.name} ${acl.direction}`,
`Apply "${acl.name}" to ${vlanName} — filter traffic going ${acl.direction === "in" ? "INTO" : "OUT OF"} this VLAN`, group);
});
return entries;
}
function entriesToCLI(entries, hostname) {
const lines = [
`! ERS 5952 — ${hostname || "ERS-5952"}`,
"enable", "configure terminal", "",
];
entries.forEach(e => lines.push(e.cmd));
lines.push("", "end", "copy running-config nvram:config.cfg");
return lines.join("\n");
}
// ── API ─────────────────────────────────────────────────────────────────────
const API = async (path, opts = {}) => {
const r = await fetch(`/api${path}`, {
headers: { "Content-Type": "application/json" },
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.detail?.message || data.detail || `HTTP ${r.status}`);
return data;
};
// ══════════════════════════════════════════════════════════════════════════════
// CSS
// ══════════════════════════════════════════════════════════════════════════════
const css = `
${FONT}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0a0c10;--sf:#111318;--b1:#1e2230;--b2:#2a2f42;
--ac:#00e5ff;--ac2:#0077ff;--tx:#c8d0e0;--dm:#5a6070;
--err:#ff1744;--ok:#00e676;--warn:#ffea00;--warn2:#ff6d00;
--mono:'Share Tech Mono',monospace;--sans:'Barlow',sans-serif;
}
body{background:var(--bg);color:var(--tx);font-family:var(--sans);font-size:13px;line-height:1.5;min-height:100vh}
.app{display:flex;flex-direction:column;min-height:100vh}
/* banner */
.conn-banner{display:flex;align-items:center;gap:10px;padding:8px 20px;font-size:12px;font-family:var(--mono);border-bottom:1px solid;transition:all .4s}
.conn-banner.connecting{background:rgba(255,234,0,.06);border-color:rgba(255,234,0,.2);color:var(--warn)}
.conn-banner.connected{background:rgba(0,230,118,.06);border-color:rgba(0,230,118,.2);color:var(--ok)}
.conn-banner.error{background:rgba(255,23,68,.06);border-color:rgba(255,23,68,.2);color:var(--err)}
.conn-banner.hidden{display:none}
.conn-spin{width:12px;height:12px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;flex-shrink:0}
@keyframes spin{to{transform:rotate(360deg)}}
.conn-dot{width:8px;height:8px;border-radius:50%;background:currentColor;flex-shrink:0}
/* topbar */
.topbar{display:flex;align-items:center;gap:14px;padding:10px 20px;background:var(--sf);border-bottom:1px solid var(--b1);position:sticky;top:0;z-index:100;flex-wrap:wrap}
.logo{font-family:var(--mono);font-size:14px;color:var(--ac);letter-spacing:2px}
.logo-sub{font-size:10px;color:var(--dm);font-family:var(--mono)}
.sp{flex:1}
.ti{background:var(--bg);border:1px solid var(--b2);color:var(--tx);padding:4px 8px;font-family:var(--mono);font-size:12px;border-radius:3px;width:130px}
.ti:focus{outline:none;border-color:var(--ac)}
.tl{font-size:11px;color:var(--dm)}
/* session */
.sess-btn{display:flex;align-items:center;gap:8px;padding:5px 12px;border-radius:4px;border:1px solid;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;cursor:pointer;font-family:var(--sans);transition:all .15s}
.sess-btn.locked{border-color:var(--b2);color:var(--dm);background:transparent}
.sess-btn.locked:hover{border-color:var(--ac);color:var(--ac)}
.sess-btn.unlocked{border-color:var(--ok);color:var(--ok);background:rgba(0,230,118,.08)}
.sess-btn.unlocked:hover{background:rgba(255,23,68,.1);border-color:var(--err);color:var(--err)}
.sess-timer{font-family:var(--mono);font-size:12px;letter-spacing:1px}
/* settings icon */
.settings-btn{background:none;border:1px solid var(--b2);color:var(--dm);border-radius:4px;padding:5px 9px;cursor:pointer;font-size:14px;transition:all .15s;line-height:1}
.settings-btn:hover{border-color:var(--ac);color:var(--ac)}
/* settings panel */
.settings-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:150;display:flex;align-items:flex-start;justify-content:flex-end}
.settings-panel{background:var(--sf);border-left:1px solid var(--b2);width:320px;min-height:100vh;padding:20px}
.settings-panel h2{font-family:var(--mono);font-size:13px;color:var(--ac);letter-spacing:2px;margin-bottom:16px}
.settings-section{margin-bottom:20px;padding-bottom:20px;border-bottom:1px solid var(--b1)}
.settings-section:last-child{border-bottom:none}
.settings-section h3{font-size:10px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--dm);margin-bottom:10px}
.setting-row{display:flex;align-items:center;justify-content:space-between;padding:6px 0}
.setting-label{font-size:12px}
.setting-sub{font-size:11px;color:var(--dm);margin-top:2px}
/* poll pill */
.poll-pill{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--dm);font-family:var(--mono);padding:3px 8px;border:1px solid var(--b1);border-radius:20px}
.dot{width:6px;height:6px;border-radius:50%}
.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}.dot.err{background:var(--err)}.dot.idle{background:var(--dm)}
/* tabs */
.tabs{display:flex;gap:2px;padding:0 20px;background:var(--sf);border-bottom:1px solid var(--b1)}
.tab{padding:10px 18px;font-size:12px;font-weight:600;letter-spacing:1px;text-transform:uppercase;cursor:pointer;border-bottom:2px solid transparent;color:var(--dm);transition:all .15s;background:none;border-top:none;border-left:none;border-right:none;font-family:var(--sans)}
.tab:hover{color:var(--tx)}.tab.active{color:var(--ac);border-bottom-color:var(--ac)}
/* main */
.main{flex:1;padding:20px;display:flex;gap:16px;align-items:flex-start}
/* panel */
.panel{background:var(--sf);border:1px solid var(--b1);border-radius:6px;overflow:hidden}
.ph{padding:10px 14px;border-bottom:1px solid var(--b1);font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--ac);font-family:var(--mono);display:flex;align-items:center;gap:8px}
.pb{padding:14px}
/* chassis */
.chassis{background:#0d0f14;border:1px solid var(--b2);border-radius:8px;padding:16px;margin-bottom:12px}
.cl{font-family:var(--mono);font-size:10px;color:var(--dm);letter-spacing:3px;text-transform:uppercase;margin-bottom:10px}
.pgrid{display:grid;grid-template-columns:repeat(24,1fr);gap:4px}
.pgrid-sfp{display:grid;grid-template-columns:repeat(4,1fr);gap:4px;margin-top:8px;padding-top:8px;border-top:1px solid var(--b1);width:calc(4*(100%/24)+3*4px)}
.port{aspect-ratio:1;border-radius:3px;border:1px solid transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:8px;font-weight:700;transition:all .1s;position:relative}
.port:hover{filter:brightness(1.3);transform:scale(1.1);z-index:2}
.port.sel{border-color:white!important;box-shadow:0 0 0 2px white;z-index:3}
.poe-dot{position:absolute;top:1px;right:1px;width:4px;height:4px;border-radius:50%;background:var(--ok)}
.legend{display:flex;flex-wrap:wrap;gap:8px;padding:10px 14px;border-top:1px solid var(--b1)}
.li{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--dm)}
.ld{width:10px;height:10px;border-radius:2px}
/* fields */
.field{margin-bottom:12px}
.field label{display:block;font-size:10px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--dm);margin-bottom:4px}
.field input,.field select{width:100%;background:var(--bg);border:1px solid var(--b2);color:var(--tx);padding:6px 8px;font-family:var(--mono);font-size:12px;border-radius:3px}
.field input:focus,.field select:focus{outline:none;border-color:var(--ac)}
.field select option{background:var(--bg)}
.rgrp{display:flex;gap:6px}
.rbtn{flex:1;padding:5px;text-align:center;border:1px solid var(--b2);border-radius:3px;cursor:pointer;font-size:11px;font-weight:600;letter-spacing:1px;text-transform:uppercase;transition:all .1s;background:none;color:var(--dm);font-family:var(--sans)}
.rbtn:hover{border-color:var(--ac);color:var(--ac)}
.rbtn.ra{background:var(--ac);border-color:var(--ac);color:#000}
.rbtn.rt{background:var(--ac2);border-color:var(--ac2);color:#fff}
.rbtn.rd{background:var(--err);border-color:var(--err);color:#fff}
.trow{display:flex;align-items:center;justify-content:space-between;padding:6px 0}
.tog{width:36px;height:20px;background:var(--b2);border-radius:10px;cursor:pointer;position:relative;transition:background .2s;border:none}
.tog.on{background:var(--ok)}
.tog::after{content:'';position:absolute;top:3px;left:3px;width:14px;height:14px;background:#fff;border-radius:50%;transition:transform .2s}
.tog.on::after{transform:translateX(16px)}
.vtags{display:flex;flex-wrap:wrap;gap:4px;padding:6px;background:var(--bg);border:1px solid var(--b2);border-radius:3px;min-height:34px}
.vtag{padding:2px 7px;border-radius:2px;font-family:var(--mono);font-size:11px;cursor:pointer;font-weight:700;transition:opacity .1s}
.vtag.von{opacity:1}.vtag.voff{opacity:.25}
/* buttons */
.btn{padding:7px 14px;border-radius:3px;border:none;cursor:pointer;font-size:11px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;font-family:var(--sans);transition:all .1s;white-space:nowrap}
.bp{background:var(--ac);color:#000}.bp:hover{background:#33ecff}
.bd{background:var(--err);color:#fff}.bd:hover{background:#ff4569}
.bg{background:transparent;border:1px solid var(--b2);color:var(--tx)}.bg:hover{border-color:var(--ac);color:var(--ac)}
.bs{background:var(--ok);color:#000}.bs:hover{background:#33eb91}
.bw{background:var(--warn);color:#000}.bw:hover{filter:brightness(1.1)}
.btn:disabled{opacity:.4;cursor:not-allowed}
.btn-row{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap}
/* vlan table */
.vtbl{width:100%;border-collapse:collapse}
.vtbl th{text-align:left;padding:6px 10px;font-size:10px;letter-spacing:2px;text-transform:uppercase;color:var(--dm);border-bottom:1px solid var(--b1)}
.vtbl td{padding:8px 10px;border-bottom:1px solid var(--b1);font-family:var(--mono);font-size:12px}
.vtbl tr:last-child td{border-bottom:none}
.vtbl tr:hover td{background:rgba(255,255,255,.02)}
.sw{width:16px;height:16px;border-radius:3px;display:inline-block;vertical-align:middle}
.ii{background:var(--bg);border:1px solid var(--b2);color:var(--tx);padding:3px 6px;font-family:var(--mono);font-size:12px;border-radius:3px;width:100%}
.ii:focus{outline:none;border-color:var(--ac)}
/* acl */
.acl-card{background:var(--bg);border:1px solid var(--b2);border-radius:5px;overflow:hidden;margin-bottom:10px}
.acl-hd{padding:8px 12px;display:flex;align-items:center;gap:8px;background:rgba(255,255,255,.03);border-bottom:1px solid var(--b1)}
.acl-nm{font-family:var(--mono);font-size:13px;color:var(--ac);flex:1}
.acl-rules{padding:8px 12px}
.acl-rule{display:grid;grid-template-columns:60px 50px 1fr 1fr 60px 24px;gap:6px;align-items:center;margin-bottom:6px;font-size:11px}
.acl-rule select,.acl-rule input{background:var(--sf);border:1px solid var(--b2);color:var(--tx);padding:3px 5px;font-family:var(--mono);font-size:11px;border-radius:3px;width:100%}
/* ── ANNOTATED CLI TABLE ── */
.cli-table{width:100%;border-collapse:collapse;font-size:12px;margin-bottom:12px}
.cli-table th{text-align:left;padding:6px 10px;font-size:10px;letter-spacing:2px;text-transform:uppercase;color:var(--dm);border-bottom:1px solid var(--b1);font-family:var(--sans)}
.cli-group-header td{padding:8px 10px 4px;font-size:10px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--ac);font-family:var(--mono);border-top:1px solid var(--b1);background:rgba(0,229,255,.04)}
.cli-row{transition:background .1s}
.cli-row:hover{background:rgba(255,255,255,.03)}
.cli-row td{padding:5px 10px;border-bottom:1px solid rgba(255,255,255,.03);vertical-align:top}
.cli-cmd{font-family:var(--mono);color:var(--tx);white-space:pre}
.cli-explain{color:var(--dm);font-size:11px;padding-left:8px}
.cli-row.pending td{opacity:.5}
.cli-row.running td .cli-cmd{color:var(--warn)}
.cli-row.running td .cli-explain{color:var(--warn)}
.cli-row.success td .cli-cmd{color:var(--ok)}
.cli-row.success td .cli-explain{color:#3a6040}
.cli-row.failed td .cli-cmd{color:var(--err)}
.cli-row.failed td .cli-explain{color:#6a2030}
.cli-row.waiting td{opacity:.35}
.cli-status{width:20px;text-align:center;font-size:13px}
.cli-err-out{font-family:var(--mono);font-size:10px;color:var(--warn);padding:3px 10px 5px;background:rgba(255,23,68,.05);border-bottom:1px solid var(--b1)}
/* raw cli textarea (cli mode) */
.cli-raw{width:100%;background:#060809;border:1px solid var(--b1);color:#a0b0c0;padding:14px;font-family:var(--mono);font-size:12px;line-height:1.7;border-radius:4px;min-height:300px;resize:vertical}
.cli-raw:focus{outline:none;border-color:var(--ac)}
/* totp modal */
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;display:flex;align-items:center;justify-content:center}
.modal{background:var(--sf);border:1px solid var(--b2);border-radius:8px;padding:28px;width:360px;text-align:center}
.modal h2{font-family:var(--mono);color:var(--ac);font-size:14px;letter-spacing:2px;margin-bottom:8px}
.modal p{font-size:12px;color:var(--dm);margin-bottom:20px;line-height:1.7}
.totp-in{width:100%;background:var(--bg);border:2px solid var(--b2);color:var(--tx);padding:12px;font-family:var(--mono);font-size:28px;letter-spacing:10px;border-radius:4px;text-align:center;margin-bottom:12px}
.totp-in:focus{outline:none;border-color:var(--ac)}
/* danger */
.hard-box{background:rgba(255,23,68,.07);border:1px solid rgba(255,23,68,.3);border-radius:4px;padding:10px 14px;margin-bottom:10px}
.hard-box h4{color:var(--err);font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:6px}
.warn-box{background:rgba(255,234,0,.07);border:1px solid rgba(255,234,0,.3);border-radius:4px;padding:10px 14px;margin-bottom:10px}
.warn-box h4{color:var(--warn);font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:6px}
.danger-item{font-size:11px;margin-bottom:5px}
.danger-cmd{font-family:var(--mono);color:var(--tx)}
.danger-why{font-size:10px;color:var(--dm);margin-top:1px}
/* push mode choice */
.push-choice{display:flex;gap:10px;margin-bottom:14px}
.push-choice-btn{flex:1;padding:10px 12px;border-radius:5px;border:1px solid var(--b2);background:var(--bg);color:var(--tx);cursor:pointer;text-align:left;transition:all .15s;font-family:var(--sans)}
.push-choice-btn:hover{border-color:var(--ac)}
.push-choice-btn.chosen{border-color:var(--ac);background:rgba(0,229,255,.06)}
.push-choice-btn h4{font-size:12px;font-weight:700;margin-bottom:3px;color:var(--ac)}
.push-choice-btn p{font-size:11px;color:var(--dm);line-height:1.4}
/* push summary bar */
.push-summary{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:4px;font-size:12px;font-family:var(--mono);margin-bottom:10px}
.push-summary.ok{background:rgba(0,230,118,.08);border:1px solid rgba(0,230,118,.2);color:var(--ok)}
.push-summary.fail{background:rgba(255,23,68,.08);border:1px solid rgba(255,23,68,.2);color:var(--err)}
.push-summary.running{background:rgba(255,234,0,.05);border:1px solid rgba(255,234,0,.15);color:var(--warn)}
/* ── Device Access ── */
.device-card{background:var(--bg);border:1px solid var(--b2);border-radius:4px;padding:10px 12px;margin-bottom:8px}
.device-badges{display:flex;gap:6px;margin-top:6px;flex-wrap:wrap}
/* misc */
.sect{font-size:10px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--dm);margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--b1)}
.empty{text-align:center;padding:40px 20px;color:var(--dm);font-size:12px}
.badge{padding:1px 6px;border-radius:10px;font-size:10px;font-family:var(--mono);font-weight:700}
.poe-bar-wrap{height:4px;background:var(--b2);border-radius:2px;margin-top:4px;overflow:hidden}
.poe-bar{height:100%;border-radius:2px;background:var(--ok);transition:width .3s}
`;
// ══════════════════════════════════════════════════════════════════════════════
// CONNECTION BANNER
// ══════════════════════════════════════════════════════════════════════════════
function ConnBanner({ state, info }) {
if (state === "hidden") return null;
return (
<div className={`conn-banner ${state}`}>
{state === "connecting" && <span className="conn-spin"/>}
{state !== "connecting" && <span className="conn-dot"/>}
<span>
{state === "connecting" && "Connecting to switch..."}
{state === "connected" && (info || "Connected")}
{state === "error" && (info || "Switch unreachable — retrying...")}
</span>
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// TOTP MODAL
// ══════════════════════════════════════════════════════════════════════════════
function TotpModal({ onSuccess, onCancel, commandCount }) {
const [code, setCode] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const verify = async () => {
if (code.length !== 6) return;
setLoading(true); setError("");
try {
const data = await API("/auth/verify", { method: "POST", body: { code } });
onSuccess(data.token);
} catch(e) {
setError(e.message); setCode("");
} finally { setLoading(false); }
};
return (
<div className="modal-bg" onClick={onCancel}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h2> AUTHENTICATE</h2>
<p>
Enter your 6-digit TOTP code to authorize this push.<br/>
<span style={{color:"var(--ac)"}}>{commandCount} command{commandCount!==1?"s":""}</span> ready to send.<br/>
<span style={{color:"var(--dm)"}}>Session locks automatically when push completes.</span>
</p>
<input className="totp-in" value={code}
onChange={e => setCode(e.target.value.replace(/\D/g,"").slice(0,6))}
onKeyDown={e => e.key==="Enter" && verify()}
placeholder="000000" autoFocus maxLength={6}/>
{error && <div style={{color:"var(--err)",fontSize:12,marginBottom:10}}>{error}</div>}
<div style={{display:"flex",gap:8,justifyContent:"center"}}>
<button className="btn bg" onClick={onCancel}>Cancel</button>
<button className="btn bp" onClick={verify} disabled={code.length!==6||loading}>
{loading ? "Verifying..." : "Authorize Push"}
</button>
</div>
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// SESSION BUTTON — shows "Authenticate to Push" only, no timer
// Session is scoped to one push batch, lock happens automatically after push
// ══════════════════════════════════════════════════════════════════════════════
function SessionBtn({ session, onUnlock }) {
if (session) {
return (
<div className="sess-btn unlocked" style={{cursor:"default"}}>
🔓 <span>Session Active</span>
</div>
);
}
return (
<button className="sess-btn locked" onClick={onUnlock}>
🔒 <span>Authenticate to Push</span>
</button>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// SETTINGS PANEL
// ══════════════════════════════════════════════════════════════════════════════
function SettingsPanel({ settings, setSettings, onClose }) {
return (
<div className="settings-overlay" onClick={onClose}>
<div className="settings-panel" onClick={e => e.stopPropagation()}>
<div style={{display:"flex",alignItems:"center",justifyContent:"space-between",marginBottom:20}}>
<h2> SETTINGS</h2>
<button className="btn bg" style={{padding:"3px 10px",fontSize:11}} onClick={onClose}> Close</button>
</div>
<div className="settings-section">
<h3>Interface</h3>
<div className="setting-row">
<div>
<div className="setting-label">CLI Mode</div>
<div className="setting-sub">Advanced type raw ERS commands directly</div>
</div>
<button className={`tog ${settings.cliMode?"on":""}`}
onClick={() => setSettings(s => ({...s, cliMode: !s.cliMode}))}/>
</div>
</div>
<div className="settings-section">
<h3>Push Behaviour</h3>
<div className="setting-row">
<div>
<div className="setting-label">Default push mode</div>
<div className="setting-sub">Batch or step-by-step</div>
</div>
<select value={settings.defaultPushMode}
onChange={e => setSettings(s => ({...s, defaultPushMode: e.target.value}))}
style={{background:"var(--bg)",border:"1px solid var(--b2)",color:"var(--tx)",padding:"4px 6px",fontFamily:"var(--mono)",fontSize:11,borderRadius:3}}>
<option value="batch">Batch</option>
<option value="step">Step-by-step</option>
</select>
</div>
</div>
<div className="settings-section">
<h3>About</h3>
<div style={{fontSize:11,color:"var(--dm)",lineHeight:1.7}}>
ERS 5952 Switch Manager<br/>
Runs on your management computer on VLAN 99.<br/>
SSH key lives on the management computer only.<br/>
You never write CLI commands.<br/>
<span style={{color:"var(--ac)"}}>README.md</span> has full documentation.
</div>
</div>
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// ANNOTATED CLI TABLE — the main inspect view
// ══════════════════════════════════════════════════════════════════════════════
function AnnotatedCLITable({ entries, pushState, currentIdx, pushMode, onConfirmOne }) {
// Group entries by group field
const groups = [];
let lastGroup = null;
entries.forEach((e, i) => {
if (e.group !== lastGroup) {
groups.push({ label: e.group, entries: [] });
lastGroup = e.group;
}
groups[groups.length-1].entries.push({ ...e, globalIdx: i });
});
const getRowState = (globalIdx) => {
if (!pushState) return "pending";
if (globalIdx < currentIdx) return "success";
if (globalIdx === currentIdx) return "running";
return "waiting";
};
const getRowResult = (globalIdx) => {
if (!pushState?.results) return null;
return pushState.results.find(r => r.index === globalIdx);
};
return (
<table className="cli-table">
<thead>
<tr>
<th style={{width:24}}></th>
<th>Command</th>
<th>What it does</th>
{pushMode === "step" && !pushState?.done && <th style={{width:80}}></th>}
</tr>
</thead>
<tbody>
{groups.map((grp, gi) => (
<>
<tr key={`g${gi}`} className="cli-group-header">
<td colSpan={pushMode==="step" && !pushState?.done ? 4 : 3}>{grp.label}</td>
</tr>
{grp.entries.map((e, ei) => {
const rowState = getRowState(e.globalIdx);
const result = getRowResult(e.globalIdx);
const isCurrentStep = pushMode === "step" && pushState && !pushState.done && e.globalIdx === currentIdx;
return (
<>
<tr key={`e${gi}-${ei}`} className={`cli-row ${rowState}`}>
<td className="cli-status">
{rowState === "success" && "✓"}
{rowState === "failed" && "✗"}
{rowState === "running" && <span className="conn-spin" style={{display:"inline-block"}}/>}
{(rowState === "pending" || rowState === "waiting") && "·"}
</td>
<td className="cli-cmd">{e.cmd}</td>
<td className="cli-explain">{e.explain}</td>
{pushMode === "step" && !pushState?.done && (
<td>
{isCurrentStep && (
<button className="btn bs" style={{padding:"2px 10px",fontSize:10}}
onClick={() => onConfirmOne(e.globalIdx)}>Send </button>
)}
</td>
)}
</tr>
{result && !result.success && result.output && (
<tr key={`err${gi}-${ei}`}>
<td colSpan={4} className="cli-err-out">
Switch error: {result.output}
</td>
</tr>
)}
</>
);
})}
</>
))}
</tbody>
</table>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// CLI & PUSH TAB
// ══════════════════════════════════════════════════════════════════════════════
function CliTab({ ports, vlans, acls, hostname, session, setSession, onNeedAuth, backendOk, settings }) {
const annotated = generateAnnotatedCLI({ ports, vlans, acls, hostname });
const pushCommands = annotated.map(e => e.cmd).filter(c => {
const t = c.trim();
return t && !t.startsWith("!");
});
const [stage, setStage] = useState("idle"); // idle|checking|danger|choosePushMode|pushing|done
const [dangerResult, setDangerResult] = useState(null);
const [pushMode, setPushMode] = useState(settings.defaultPushMode || "batch");
const [pushState, setPushState] = useState(null);
const [currentIdx, setCurrentIdx] = useState(0);
const [rawCli, setRawCli] = useState("");
// Reset when ports/vlans/acls change
useEffect(() => { setStage("idle"); setPushState(null); setCurrentIdx(0); }, [ports, vlans, acls]);
const handleReviewPush = async () => {
setStage("checking");
try {
const check = await API("/check/danger", { method:"POST", body:{ commands: pushCommands } });
setDangerResult(check);
setStage("danger");
} catch(e) {
setDangerResult({ error: e.message }); setStage("danger");
}
};
const proceedToChoose = () => setStage("choosePushMode");
const startPush = async (mode, token) => {
const t = token || session?.token;
if (!t) { onNeedAuth(); return; }
setPushMode(mode);
setStage("pushing");
setCurrentIdx(0);
setPushState({ results: [], done: false });
if (mode === "batch") {
await executeBatch(t);
}
// step mode is driven by onConfirmOne
};
const executeBatch = async (token) => {
try {
const result = await API("/switch/push", {
method:"POST",
body: { token, commands: pushCommands }
});
setPushState({ ...result, done: true });
setCurrentIdx(result.commands_sent || pushCommands.length);
setStage("done");
setSession(null); // lock session after push completes
} catch(e) {
setPushState({ success:false, error:e.message, results:[], commands_sent:0, done:true });
setStage("done");
setSession(null);
}
};
const onConfirmOne = async (idx) => {
if (!session?.token) { onNeedAuth(); return; }
const cmd = pushCommands[idx];
try {
const result = await API("/switch/push", {
method:"POST",
body: { token: session.token, commands: [cmd] }
});
const r = result.results?.[0] || { index: idx, command: cmd, success: result.success, output: result.error || "", skipped: false };
r.index = idx;
setPushState(prev => {
const results = [...(prev?.results||[]), r];
if (!result.success) {
setStage("done");
setSession(null);
return { ...prev, results, done: true, success: false, error: result.error };
}
const nextIdx = idx + 1;
setCurrentIdx(nextIdx);
if (nextIdx >= pushCommands.length) {
// Save config
API("/switch/push", { method:"POST", body:{ token: session.token, commands:["copy running-config nvram:config.cfg"] } })
.catch(() => {});
setStage("done");
setSession(null);
return { ...prev, results, done:true, success:true, saved:true };
}
return { ...prev, results };
});
} catch(e) {
setStage("done"); setSession(null);
setPushState(prev => ({ ...prev, done:true, success:false, error:e.message }));
}
};
const handleRawPush = async () => {
if (!session?.token) { onNeedAuth(); return; }
const cmds = rawCli.split("\n").filter(l => l.trim() && !l.trim().startsWith("!"));
try {
setStage("pushing");
const result = await API("/switch/push", { method:"POST", body:{ token:session.token, commands:cmds } });
setPushState({ ...result, done:true });
setStage("done");
setSession(null);
} catch(e) {
setPushState({ success:false, error:e.message, results:[], done:true });
setStage("done"); setSession(null);
}
};
const fullCLI = entriesToCLI(annotated, hostname);
const cmdCount = pushCommands.length;
// ── CLI Mode (raw text) ──
if (settings.cliMode) {
return (
<div className="main" style={{flexDirection:"column"}}>
<div className="panel">
<div className="ph"> CLI Mode Advanced
<span style={{marginLeft:"auto",fontSize:10,color:"var(--dm)"}}>Danger check and allowlist still apply</span>
</div>
<div className="pb">
<div style={{marginBottom:8,fontSize:11,color:"var(--dm)"}}>
Type or paste ERS CLI commands. Do not include enable, configure terminal, end, or copy these are added automatically.
</div>
<textarea className="cli-raw" value={rawCli}
onChange={e => setRawCli(e.target.value)}
placeholder={"vlan create 100 name Finance type port\nvlan members add 100 22\nvlan pvid 22 100"}/>
<div className="btn-row">
<button className="btn bg" onClick={() => navigator.clipboard?.writeText(rawCli)}>Copy</button>
<button className="btn bp" onClick={handleRawPush} disabled={!rawCli.trim()||!backendOk||stage==="pushing"}>
{stage==="pushing" ? "Pushing..." : "Review & Push →"}
</button>
</div>
{pushState?.done && (
<div className={`push-summary ${pushState.success?"ok":"fail"}`} style={{marginTop:12}}>
{pushState.success ? `✓ All commands succeeded — config saved` : `✗ Failed: ${pushState.error}`}
</div>
)}
</div>
</div>
</div>
);
}
// ── GUI Mode ──
return (
<div className="main" style={{flexDirection:"column"}}>
<div className="panel">
<div className="ph">
Review & Push {cmdCount} command{cmdCount!==1?"s":""}
<span style={{marginLeft:"auto",display:"flex",gap:8}}>
<button className="btn bg" onClick={() => navigator.clipboard?.writeText(fullCLI)}>Copy CLI</button>
<button className="btn bg" onClick={() => {
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([fullCLI],{type:"text/plain"}));
a.download=`${hostname||"ers5952"}-config.txt`; a.click();
}}>Download</button>
{stage==="idle" && (
<button className="btn bp" onClick={handleReviewPush} disabled={!backendOk||cmdCount===0}>
{!backendOk ? "Backend Offline" : cmdCount===0 ? "No Changes" : "Review & Push →"}
</button>
)}
</span>
</div>
<div className="pb">
{/* ── Danger stage ── */}
{stage==="danger" && dangerResult && (
<div style={{marginBottom:14}}>
{dangerResult.hard_blocked?.length > 0 && (
<div className="hard-box">
<h4> Hard Blocked Run These at the Switch Console</h4>
{dangerResult.hard_blocked.map((d,i) => (
<div key={i} className="danger-item">
<div className="danger-cmd">{d.command}</div>
<div className="danger-why">{d.reason}</div>
</div>
))}
<button className="btn bg" style={{marginTop:10}} onClick={()=>setStage("idle")}>Back</button>
</div>
)}
{dangerResult.warnings?.length > 0 && (
<div className="warn-box">
<h4> Review These Commands</h4>
{dangerResult.warnings.map((d,i) => (
<div key={i} className="danger-item">
<div className="danger-cmd">{d.command}</div>
<div className="danger-why">{d.reason}</div>
</div>
))}
</div>
)}
{!dangerResult.has_hard_block && (
<div className="btn-row">
<button className="btn bg" onClick={()=>setStage("idle")}>Cancel</button>
<button className="btn bs" onClick={proceedToChoose}>
{dangerResult.has_warnings ? "Acknowledge & Continue →" : "Looks Good — Choose Push Mode →"}
</button>
</div>
)}
</div>
)}
{/* ── Push mode choice ── */}
{stage==="choosePushMode" && (
<div style={{marginBottom:14}}>
<div className="sect">How do you want to push these {cmdCount} commands?</div>
<div className="push-choice">
<button className={`push-choice-btn ${pushMode==="batch"?"chosen":""}`}
onClick={() => setPushMode("batch")}>
<h4> Send All at Once</h4>
<p>All commands sent in sequence. Results shown when complete. Faster good when you've reviewed and are confident.</p>
</button>
<button className={`push-choice-btn ${pushMode==="step"?"chosen":""}`}
onClick={() => setPushMode("step")}>
<h4>◈ Step by Step</h4>
<p>Send one command at a time. Confirm each before the next is sent. Good for complex changes or when you want full control.</p>
</button>
</div>
<div className="btn-row">
<button className="btn bg" onClick={()=>setStage("danger")}>Back</button>
{session
? <button className="btn bs" onClick={() => startPush(pushMode, session.token)}>
Start Push →
</button>
: <button className="btn bw" onClick={onNeedAuth}>Authenticate First →</button>
}
</div>
</div>
)}
{/* ── Push summary bar ── */}
{(stage==="pushing" || stage==="done") && pushState && (
<div className={`push-summary ${stage==="pushing"?"running":pushState.success?"ok":"fail"}`}>
{stage==="pushing" && `Sending commands... (${currentIdx}/${cmdCount})`}
{stage==="done" && pushState.success && `✓ All ${pushState.commands_sent} commands succeeded — config saved to NVRAM`}
{stage==="done" && !pushState.success && `✗ Stopped at command ${(pushState.stopped_at||0)+1} — config NOT saved`}
{stage==="done" && (
<button className="btn bg" style={{marginLeft:"auto",padding:"2px 10px",fontSize:10}}
onClick={() => { setStage("idle"); setPushState(null); setCurrentIdx(0); }}>
Reset
</button>
)}
</div>
)}
{/* ── Annotated command table ── */}
{(stage==="idle" || stage==="checking" || stage==="pushing" || stage==="done") && (
<AnnotatedCLITable
entries={annotated}
pushState={pushState}
currentIdx={currentIdx}
pushMode={pushMode}
onConfirmOne={onConfirmOne}
/>
)}
{stage==="done" && !pushState?.success && pushState?.error && (
<div style={{background:"rgba(255,23,68,.06)",border:"1px solid rgba(255,23,68,.2)",borderRadius:4,padding:"10px 14px",fontFamily:"var(--mono)",fontSize:11,color:"var(--err)"}}>
{pushState.error}
{pushState.hint && <div style={{color:"var(--dm)",marginTop:4}}>{pushState.hint}</div>}
</div>
)}
{stage==="done" && (
<div style={{marginTop:12,fontSize:11,color:"var(--dm)"}}>
Session locked. To make more changes, configure them in the tabs above then click Review & Push and authenticate again.
</div>
)}
</div>
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// PORT BUTTON + EDITOR (unchanged from previous version)
// ══════════════════════════════════════════════════════════════════════════════
function PortBtn({ port, vlans, selected, onClick }) {
const vlan = vlans.find(v => v.id === port.accessVlan);
let bg = "#1a1c22", color = "#3a4050";
if (port.mode==="disabled") { bg="#13151a"; color="#2a2f3a"; }
else if (port.mode==="trunk") { bg="#0a1a2e"; color="#2979ff"; }
else if (vlan) { bg=vlan.color+"22"; color=vlan.color; }
return (
<button className={`port ${selected?"sel":""}`}
style={{background:bg,color,borderColor:selected?"white":color+"66"}}
onClick={() => onClick(port.id)}
title={`Port ${port.id}${port.description?` — ${port.description}`:""}`}>
{port.id}
{port.poe && port.id<=48 && port.mode!=="disabled" && <span className="poe-dot"/>}
</button>
);
}
function PortEditor({ port, vlans, onChange }) {
if (!port) return <div className="empty"><div style={{fontSize:28,marginBottom:8}}>◈</div>Select a port</div>;
const isSFP = port.id > 48;
const up = (k,v) => onChange({...port,[k]:v});
const toggleTagged = vid => {
const cur = port.taggedVlans||[];
up("taggedVlans", cur.includes(vid) ? cur.filter(v=>v!==vid) : [...cur,vid]);
};
return (
<div>
<div className="sect">Port {port.id}{isSFP?" (SFP+)":""}</div>
<div className="field">
<label>Description</label>
<input value={port.description} onChange={e=>up("description",e.target.value)} placeholder="e.g. AP-Corridor-1"/>
</div>
<div className="field">
<label>Mode</label>
<div className="rgrp">
{["access","trunk","disabled"].map(m=>(
<button key={m} className={`rbtn ${port.mode===m?m==="trunk"?"rt":m==="disabled"?"rd":"ra":""}`}
onClick={()=>up("mode",m)}>{m}</button>
))}
</div>
</div>
{port.mode==="access" && (
<div className="field">
<label>Access VLAN</label>
<select value={port.accessVlan} onChange={e=>up("accessVlan",+e.target.value)}>
{vlans.map(v=><option key={v.id} value={v.id}>{v.id} — {v.name}</option>)}
</select>
</div>
)}
{port.mode==="trunk" && (<>
<div className="field">
<label>Native VLAN</label>
<select value={port.nativeVlan} onChange={e=>up("nativeVlan",+e.target.value)}>
{vlans.map(v=><option key={v.id} value={v.id}>{v.id} — {v.name}</option>)}
</select>
</div>
<div className="field">
<label>Tagged VLANs</label>
<div className="vtags">
{vlans.map(v=>(
<span key={v.id} className={`vtag ${port.taggedVlans?.includes(v.id)?"von":"voff"}`}
style={{background:v.color+"33",color:v.color,border:`1px solid ${v.color}66`}}
onClick={()=>toggleTagged(v.id)}>{v.id}</span>
))}
</div>
</div>
</>)}
{!isSFP && (<>
<div className="trow">
<span>PoE+</span>
<button className={`tog ${port.poe?"on":""}`} onClick={()=>up("poe",!port.poe)}/>
</div>
{port.poe && (
<div className="field" style={{marginTop:8}}>
<label>PoE Limit (mW)</label>
<input type="number" value={port.poeLimit} min={1000} max={30000} step={1000}
onChange={e=>up("poeLimit",+e.target.value)}/>
<div className="poe-bar-wrap"><div className="poe-bar" style={{width:`${port.poeLimit/300}%`}}/></div>
</div>
)}
</>)}
</div>
);
}
function PortTab({ ports, vlans, selected, setSelected, updatePort, pollStatus }) {
const port = ports.find(p=>p.id===selected);
return (
<div className="main">
<div style={{flex:1}}>
<div className="panel">
<div className="ph">◈ ERS 5952 — Port Map
{pollStatus==="stale" && <span style={{marginLeft:"auto",fontSize:10,color:"var(--warn)",fontFamily:"var(--mono)"}}>⚠ Data stale</span>}
{pollStatus==="ok" && <span style={{marginLeft:"auto",fontSize:10,color:"var(--ok)",fontFamily:"var(--mono)"}}>● Live</span>}
</div>
<div className="pb">
<div className="chassis">
<div className="cl">48× GigE PoE+</div>
<div className="pgrid">
{ports.slice(0,48).map(p=><PortBtn key={p.id} port={p} vlans={vlans} selected={selected===p.id} onClick={setSelected}/>)}
</div>
<div className="cl" style={{marginTop:12}}>4× SFP+ Uplinks</div>
<div className="pgrid-sfp">
{ports.slice(48,52).map(p=><PortBtn key={p.id} port={p} vlans={vlans} selected={selected===p.id} onClick={setSelected}/>)}
</div>
</div>
<div className="legend">
{vlans.map(v=>(
<div key={v.id} className="li"><span className="ld" style={{background:v.color}}/><span>VLAN {v.id} — {v.name}</span></div>
))}
<div className="li"><span className="ld" style={{background:"#0a1a2e",border:"1px solid #2979ff"}}/><span>Trunk</span></div>
<div className="li"><span className="ld" style={{background:"#13151a"}}/><span>Disabled</span></div>
</div>
</div>
</div>
</div>
<div className="panel" style={{width:280,flexShrink:0}}>
<div className="ph">◈ Port Config</div>
<div className="pb"><PortEditor port={port} vlans={vlans} onChange={updatePort}/></div>
</div>
</div>
);
}
function VlanTab({ vlans, setVlans, ports }) {
const [nid,setNid]=useState(""); const [nm,setNm]=useState("");
const add = () => {
const id=parseInt(nid);
if(!id||id<1||id>4094||vlans.find(v=>v.id===id)) return;
setVlans([...vlans,{id,name:nm||`VLAN-${id}`,color:VLAN_COLORS[vlans.length%VLAN_COLORS.length]}]);
setNid(""); setNm("");
};
const cnt = vid => ports.filter(p=>p.mode==="access"?p.accessVlan===vid:p.taggedVlans?.includes(vid)).length;
return (
<div className="main">
<div style={{flex:1}}>
<div className="panel">
<div className="ph">◈ VLAN Manager</div>
<div className="pb">
<table className="vtbl">
<thead><tr><th>ID</th><th>Color</th><th>Name</th><th>Ports</th><th>Subnet</th><th></th></tr></thead>
<tbody>
{vlans.map(v=>(
<tr key={v.id}>
<td style={{color:"var(--ac)",fontWeight:700}}>{v.id}</td>
<td><span className="sw" style={{background:v.color}}/></td>
<td><input className="ii" value={v.name} onChange={e=>setVlans(vlans.map(x=>x.id===v.id?{...x,name:e.target.value}:x))}/></td>
<td><span className="badge" style={{background:"var(--b2)"}}>{cnt(v.id)}</span></td>
<td style={{color:"var(--dm)"}}>192.168.{v.id}.0/24</td>
<td>{v.id!==1&&<button className="btn bd" style={{padding:"2px 8px",fontSize:10}} onClick={()=>setVlans(vlans.filter(x=>x.id!==v.id))}>✕</button>}</td>
</tr>
))}
</tbody>
</table>
<div style={{marginTop:16,paddingTop:12,borderTop:"1px solid var(--b1)"}}>
<div className="sect">Add VLAN</div>
<div style={{display:"flex",gap:8,alignItems:"flex-end"}}>
<div className="field" style={{margin:0,width:90}}><label>ID</label><input value={nid} onChange={e=>setNid(e.target.value)} type="number" placeholder="100"/></div>
<div className="field" style={{margin:0,flex:1}}><label>Name</label><input value={nm} onChange={e=>setNm(e.target.value)} placeholder="e.g. POS"/></div>
<button className="btn bp" onClick={add}>Add</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
// ── ACL Templates ──────────────────────────────────────────────────────────
// Each template is a factory function: takes { subnet, ctrldIp, nvrIp } and
// returns { name, direction, rules[] } ready to paste into the ACL card list.
const ACL_TEMPLATES = [
{
id: "staff",
label: "Staff VLAN — full internet, no management",
description: "Allows everything except access to the management VLAN (99). Use on a staff or office VLAN.",
params: ["subnet"],
build: ({ subnet, vid }) => ({
name: `STAFF-VLAN${vid}-POLICY`,
direction: "in",
rules: [
{ action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:"192.168.99.0", dstMask:"0.0.0.255", dstAny:false, port:"",
_comment: "Block access to management VLAN 99" },
{ action:"permit",proto:"ip", src:"", srcMask:"", srcAny:true,
dst:"", dstMask:"", dstAny:true, port:"",
_comment: "Permit everything else" },
],
}),
},
{
id: "iot",
label: "IoT / TV / Printer VLAN — internet only, strict isolation",
description: "Blocks ALL private IP ranges (RFC1918). Devices get internet only — cannot reach any other VLAN, server, NAS, PBX, camera, or management network. Use for devices that need zero LAN access and have no local services to reach.",
params: ["subnet"],
build: ({ subnet, vid }) => ({
name: `IOT-VLAN${vid}-POLICY`,
direction: "in",
rules: [
{ action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:"192.168.0.0", dstMask:"0.255.255.255", dstAny:false, port:"",
_comment: "Block all 192.168.x.x (other VLANs, management)" },
{ action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:"10.0.0.0", dstMask:"0.255.255.255", dstAny:false, port:"",
_comment: "Block 10.x.x.x" },
{ action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:"172.16.0.0",dstMask:"0.15.255.255", dstAny:false, port:"",
_comment: "Block 172.16-31.x.x" },
{ action:"permit",proto:"ip", src:"", srcMask:"", srcAny:true,
dst:"", dstMask:"", dstAny:true, port:"",
_comment: "Permit internet" },
],
}),
},
{
id: "iot-local-services",
label: "IoT VLAN — isolated + access local services via FQDN",
description: [
"For IoT devices that need to reach local servers (NAS, Home Assistant, etc.)",
"by FQDN while remaining isolated from all other VLANs.",
"",
"What this ACL does:",
" • Allows DNS queries to your resolver (so FQDNs resolve to LAN IPs)",
" • Allows traffic to your servers VLAN subnet only",
" • Blocks all other RFC1918 (user devices, cameras, management, etc.)",
" • Allows internet",
"",
"How FQDN access works:",
" Device queries 'nas.lan' → DNS returns 192.168.20.X → ACL permits that IP",
" The device never needs to know the IP. Works transparently.",
"",
"OFFLINE RESILIENCE — critical:",
" If ctrld is running on the management PC, DNS fails when that PC is off",
" or when internet is down (ctrld requires DoH3 connectivity to Control D).",
" → Deploy ctrld on OPNsense (DNS tab → OPNsense mode) so it stays up",
" independent of internet and independent of the management PC.",
" → Enable split-horizon in DNS tab so *.lan queries go to local dnsmasq,",
" which resolves from local-hostnames.json without any internet dependency.",
" Result: 'nas.lan' resolves correctly even with internet completely down.",
"",
"SERVERS VLAN security:",
" Granting IoT access to the servers VLAN subnet means ALL servers on that",
" subnet are reachable from IoT. Lock down the server VLAN inbound ACL to",
" only permit the specific ports each service needs (e.g. TCP 8123 for Home",
" Assistant, TCP 443 for internal HTTPS). Apply the ACL on the servers VLAN,",
" not here — that way it applies regardless of which VLAN initiates.",
].join("\n"),
params: ["subnet", "ctrldIp", "serverSubnet"],
build: ({ subnet, vid, ctrldIp, serverSubnet }) => {
const srvNet = serverSubnet || "192.168.20.0";
const dnsDst = ctrldIp || "RESOLVER_IP";
return {
name: `IOT-SVC-VLAN${vid}-POLICY`,
direction: "in",
rules: [
{ action:"permit", proto:"udp", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:dnsDst, dstMask:"", dstAny:false, port:"53",
_comment: "DNS to resolver (UDP) — FQDNs resolve to LAN IPs" },
{ action:"permit", proto:"tcp", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:dnsDst, dstMask:"", dstAny:false, port:"53",
_comment: "DNS to resolver (TCP)" },
{ action:"permit", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:srvNet, dstMask:"0.0.0.255", dstAny:false, port:"",
_comment: `Allow traffic to servers VLAN (${srvNet}/24)` },
{ action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:"192.168.0.0", dstMask:"0.255.255.255", dstAny:false, port:"",
_comment: "Block all other 192.168.x.x (users, cameras, management)" },
{ action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:"10.0.0.0", dstMask:"0.255.255.255", dstAny:false, port:"",
_comment: "Block 10.x.x.x" },
{ action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:"172.16.0.0", dstMask:"0.15.255.255", dstAny:false, port:"",
_comment: "Block 172.16-31.x.x" },
{ action:"permit", proto:"ip", src:"", srcMask:"", srcAny:true,
dst:"", dstMask:"", dstAny:true, port:"",
_comment: "Permit internet" },
],
};
},
},
{
id: "guest",
label: "Guest VLAN — internet only, DNS must work first",
description: "Like IoT but DNS to ctrld is explicitly permitted first. Prevents guests from bypassing DNS filtering while still blocking all RFC1918 access.",
params: ["subnet", "ctrldIp"],
build: ({ subnet, vid, ctrldIp }) => ({
name: `GUEST-VLAN${vid}-POLICY`,
direction: "in",
rules: [
{ action:"permit",proto:"udp", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:ctrldIp||"CTRLD_IP", dstMask:"", dstAny:false, port:"53",
_comment: "Permit DNS to ctrld (DHCP-assigned resolver)" },
{ action:"permit",proto:"tcp", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:ctrldIp||"CTRLD_IP", dstMask:"", dstAny:false, port:"53",
_comment: "Permit DNS/TCP to ctrld" },
{ action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:"192.168.0.0",dstMask:"0.255.255.255",dstAny:false,port:"",
_comment: "Block 192.168.x.x" },
{ action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:"10.0.0.0", dstMask:"0.255.255.255",dstAny:false,port:"",
_comment: "Block 10.x.x.x" },
{ action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:"172.16.0.0",dstMask:"0.15.255.255", dstAny:false,port:"",
_comment: "Block 172.16-31.x.x" },
{ action:"permit",proto:"ip", src:"", srcMask:"", srcAny:true,
dst:"", dstMask:"", dstAny:true, port:"",
_comment: "Permit internet" },
],
}),
},
{
id: "camera",
label: "Camera VLAN — NVR only",
description: "Cameras can only talk to one NVR/DVR IP. All other traffic is dropped. Prevents cameras from phoning home or scanning the network.",
params: ["subnet", "nvrIp"],
build: ({ subnet, vid, nvrIp }) => ({
name: `CAMERA-VLAN${vid}-POLICY`,
direction: "in",
rules: [
{ action:"permit",proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:nvrIp||"NVR_IP", dstMask:"", dstAny:false, port:"",
_comment: "Permit traffic to NVR/DVR only" },
{ action:"deny", proto:"ip", src:"", srcMask:"", srcAny:true,
dst:"", dstMask:"", dstAny:true, port:"",
_comment: "Drop everything else" },
],
}),
},
{
id: "voip",
label: "SIP Phone VLAN — Asterisk / FreePBX (local desk phones)",
description: [
"USE THIS TEMPLATE FOR: dedicated desk phones or ATAs that are always on a fixed VLAN",
"and register with Asterisk over the local network.",
"",
"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.",
"",
"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 }) => ({
name: `VOIP-VLAN${vid}-POLICY`,
direction: "in",
rules: [
{ action:"permit", proto:"udp", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:pbxIp||"PBX_IP", dstMask:"", dstAny:false, port:"5060", portEnd:"",
_comment: "SIP signaling to PBX (UDP)" },
{ action:"permit", proto:"tcp", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:pbxIp||"PBX_IP", dstMask:"", dstAny:false, port:"5060", portEnd:"",
_comment: "SIP signaling to PBX (TCP)" },
{ action:"permit", proto:"tcp", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:pbxIp||"PBX_IP", dstMask:"", dstAny:false, port:"5061", portEnd:"",
_comment: "SIP/TLS to PBX (TCP)" },
{ action:"permit", proto:"udp", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:pbxIp||"PBX_IP", dstMask:"", dstAny:false, port:"10000", portEnd:"20000",
_comment: "RTP audio to PBX (UDP 10000-20000)" },
{ action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:"192.168.99.0", dstMask:"0.0.0.255", dstAny:false, port:"",
_comment: "Block management VLAN 99" },
{ action:"permit", proto:"ip", src:"", srcMask:"", srcAny:true,
dst:"", dstMask:"", dstAny:true, port:"",
_comment: "Permit internet and all other traffic" },
],
}),
},
];
function AclTemplateModal({ vlans, onApply, onClose }) {
const [tpl, setTpl] = useState(ACL_TEMPLATES[0].id);
const [vid, setVid] = useState(vlans[0]?.id || 1);
const [ctrldIp, setCtrldIp] = useState("");
const [nvrIp, setNvrIp] = useState("");
const [pbxIp, setPbxIp] = useState("");
const [serverSubnet, setServerSubnet] = useState("");
const tmpl = ACL_TEMPLATES.find(t => t.id === tpl);
const vlan = vlans.find(v => v.id === vid);
const subnet = `192.168.${vid}.0`;
// Auto-suggest servers subnet from vlans list
const serversVlan = vlans.find(v => v.name?.toLowerCase().includes("server") && v.id !== 99);
const serverSubnetPlaceholder = serversVlan ? `192.168.${serversVlan.id}.0` : "e.g. 192.168.20.0";
const apply = () => {
const srvSubnet = serverSubnet || serverSubnetPlaceholder;
const acl = tmpl.build({ subnet, vid, ctrldIp, nvrIp, pbxIp, serverSubnet: srvSubnet });
// Strip _comment keys — they are just for display here
acl.rules = acl.rules.map(({ _comment, ...r }) => r);
acl.applyVlan = vid;
onApply(acl);
onClose();
};
return (
<div style={{
position:"fixed",inset:0,background:"rgba(0,0,0,.65)",
display:"flex",alignItems:"center",justifyContent:"center",zIndex:1000,
}} onClick={onClose}>
<div style={{
background:"var(--bg2)",border:"1px solid var(--b2)",borderRadius:8,
padding:24,maxWidth:560,width:"90%",
}} onClick={e=>e.stopPropagation()}>
<div style={{fontWeight:700,fontSize:14,marginBottom:14}}>
ACL Template
<button className="btn bg" style={{float:"right",fontSize:10,padding:"2px 8px"}}
onClick={onClose}>✕</button>
</div>
{/* Template selector */}
<div className="field" style={{margin:"0 0 12px"}}>
<label>Template</label>
<select value={tpl} onChange={e=>setTpl(e.target.value)}>
{ACL_TEMPLATES.map(t=><option key={t.id} value={t.id}>{t.label}</option>)}
</select>
</div>
{tmpl && (
<div style={{fontSize:11,color:"var(--dm)",lineHeight:1.7,marginBottom:12,
padding:"8px 12px",background:"var(--bg)",borderRadius:4,
border:"1px solid var(--b1)",whiteSpace:"pre-wrap",maxHeight:140,overflowY:"auto"}}>
{tmpl.description}
</div>
)}
{/* VLAN selector */}
<div className="field" style={{margin:"0 0 10px"}}>
<label>Apply to VLAN</label>
<select value={vid} onChange={e=>setVid(+e.target.value)}>
{vlans.filter(v=>v.id!==99).map(v=>
<option key={v.id} value={v.id}>{v.id} — {v.name}</option>)}
</select>
</div>
{/* Extra params */}
{tmpl?.params.includes("ctrldIp") && (
<div className="field" style={{margin:"0 0 10px"}}>
<label>ctrld IP address</label>
<input value={ctrldIp} onChange={e=>setCtrldIp(e.target.value)}
placeholder="e.g. 192.168.99.50"
style={{fontFamily:"var(--mono)",maxWidth:200}}/>
<div style={{fontSize:10,color:"var(--dm)",marginTop:3}}>
IP of the machine running ctrld — shown in DNS tab after install
</div>
</div>
)}
{tmpl?.params.includes("nvrIp") && (
<div className="field" style={{margin:"0 0 10px"}}>
<label>NVR / DVR IP address</label>
<input value={nvrIp} onChange={e=>setNvrIp(e.target.value)}
placeholder="e.g. 192.168.30.10"
style={{fontFamily:"var(--mono)",maxWidth:200}}/>
</div>
)}
{tmpl?.params.includes("serverSubnet") && (
<div className="field" style={{margin:"0 0 10px"}}>
<label>Servers VLAN subnet</label>
<input value={serverSubnet} onChange={e=>setServerSubnet(e.target.value)}
placeholder={serverSubnetPlaceholder}
style={{fontFamily:"var(--mono)",maxWidth:200}}/>
<div style={{fontSize:10,color:"var(--dm)",marginTop:3}}>
Network address of the VLAN your servers live on (e.g. NAS, Home Assistant).
Leave blank to use <span style={{fontFamily:"var(--mono)"}}>{serverSubnetPlaceholder}</span>.
</div>
</div>
)}
{/* Preview */}
{tmpl?.params.includes("pbxIp") && (
<div className="field" style={{margin:"0 0 10px"}}>
<label>Asterisk / PBX IP address</label>
<input value={pbxIp} onChange={e=>setPbxIp(e.target.value)}
placeholder="e.g. 192.168.20.10"
style={{fontFamily:"var(--mono)",maxWidth:200}}/>
<div style={{fontSize:10,color:"var(--dm)",marginTop:3}}>
IP of your Asterisk server. SIP and RTP will only be permitted to this exact IP.
</div>
</div>
)}
{/* Preview */}
{tmpl && (
<div style={{
background:"#060809",borderRadius:4,padding:"10px 12px",
fontFamily:"var(--mono)",fontSize:10,color:"#7090a0",
lineHeight:1.7,marginBottom:14,maxHeight:180,overflowY:"auto",
}}>
{tmpl.build({subnet,vid,ctrldIp,nvrIp,pbxIp,serverSubnet:serverSubnet||serverSubnetPlaceholder}).rules.map((r,i)=>(
<div key={i}>
<span style={{color:"#566"}}>{` ${i+1} `}</span>
<span style={{color:r.action==="permit"?"#0e7":"#f55"}}>{r.action}</span>
{` ${r.proto} `}
<span style={{color:"#a0c0d0"}}>
{r.srcAny?"any":`${r.src||"?"} ${r.srcMask||""}`}
</span>
{" → "}
<span style={{color:"#a0c0d0"}}>
{r.dstAny?"any":`${r.dst||"?"} ${r.dstMask||""}`}
</span>
{r.portEnd
? <span style={{color:"#fa0"}}>{` range ${r.port} ${r.portEnd}`}</span>
: r.port
? <span style={{color:"#fa0"}}>{` eq ${r.port}`}</span>
: null}
{r._comment && <span style={{color:"#445"}}>{` # ${r._comment}`}</span>}
</div>
))}
</div>
)}
<div style={{display:"flex",gap:8,justifyContent:"flex-end"}}>
<button className="btn bg" onClick={onClose}>Cancel</button>
<button className="btn bp" onClick={apply}>Apply Template</button>
</div>
</div>
</div>
);
}
function AclTab({ acls, setAcls, vlans }) {
const [nn,setNn]=useState(""); const [nv,setNv]=useState(vlans[0]?.id||1); const [nd,setNd]=useState("in");
const [showTplModal,setShowTplModal]=useState(false);
const addAcl=()=>{if(!nn)return;setAcls([...acls,{name:nn,applyVlan:nv,direction:nd,rules:[]}]);setNn("");};
const addRule=name=>setAcls(acls.map(a=>a.name!==name?a:{...a,rules:[...a.rules,{action:"deny",proto:"ip",src:"",srcMask:"0.0.0.255",srcAny:true,dst:"",dstMask:"0.0.0.255",dstAny:true,port:""}]}));
const upRule=(name,idx,k,v)=>setAcls(acls.map(a=>a.name!==name?a:{...a,rules:a.rules.map((r,i)=>i===idx?{...r,[k]:v}:r)}));
const delRule=(name,idx)=>setAcls(acls.map(a=>a.name!==name?a:{...a,rules:a.rules.filter((_,i)=>i!==idx)}));
const applyTemplate=acl=>setAcls(prev=>{
// Replace if same name already exists, otherwise append
const idx=prev.findIndex(a=>a.name===acl.name);
return idx>=0?prev.map((a,i)=>i===idx?acl:a):[...prev,acl];
});
return (
<div className="main">
<div style={{flex:1}}>
{showTplModal && (
<AclTemplateModal vlans={vlans} onApply={applyTemplate} onClose={()=>setShowTplModal(false)}/>
)}
<div className="panel">
<div className="ph">◈ ACL Builder
<button className="btn bg" style={{marginLeft:"auto",fontSize:10,padding:"3px 10px"}}
onClick={()=>setShowTplModal(true)}>
Use Template
</button>
</div>
<div className="pb">
{/* Templates hint when empty */}
{acls.length===0&&(
<div style={{marginBottom:12}}>
<div style={{
padding:"10px 14px",background:"rgba(0,229,255,.04)",
border:"1px solid rgba(0,229,255,.12)",borderRadius:4,
fontSize:11,color:"var(--dm)",lineHeight:1.7,
}}>
<span style={{color:"var(--ac)",fontWeight:700}}>Templates available: </span>
Click <strong>Use Template</strong> to pre-fill rules for common
patterns: Staff (full internet, no management), IoT (internet only),
Guest (internet only + DNS enforcement), or Camera (NVR only).
All rules are editable before pushing.
</div>
</div>
)}
{acls.length===0&&<div className="empty">No ACLs defined.</div>}
{acls.map(acl=>(
<div key={acl.name} className="acl-card">
<div className="acl-hd">
<span className="acl-nm">{acl.name}</span>
<span style={{fontSize:11,color:"var(--dm)"}}>VLAN {acl.applyVlan} / {acl.direction.toUpperCase()}</span>
<button className="btn bd" style={{padding:"2px 8px",fontSize:10}} onClick={()=>setAcls(acls.filter(a=>a.name!==acl.name))}>✕</button>
</div>
<div className="acl-rules">
{acl.rules.map((r,i)=>(
<div key={i} className="acl-rule">
<select value={r.action} onChange={e=>upRule(acl.name,i,"action",e.target.value)}>{["permit","deny"].map(a=><option key={a}>{a}</option>)}</select>
<select value={r.proto} onChange={e=>upRule(acl.name,i,"proto",e.target.value)}>{["ip","tcp","udp","icmp"].map(p=><option key={p}>{p}</option>)}</select>
<div style={{display:"flex",gap:4,alignItems:"center"}}>
<input placeholder={r.srcAny?"any":"10.0.0.0"} value={r.src} disabled={r.srcAny} onChange={e=>upRule(acl.name,i,"src",e.target.value)} style={{flex:1}}/>
<label style={{fontSize:10,color:"var(--dm)",display:"flex",gap:3,alignItems:"center",whiteSpace:"nowrap"}}><input type="checkbox" checked={r.srcAny} onChange={e=>upRule(acl.name,i,"srcAny",e.target.checked)}/>any</label>
</div>
<div style={{display:"flex",gap:4,alignItems:"center"}}>
<input placeholder={r.dstAny?"any":"10.0.0.0"} value={r.dst} disabled={r.dstAny} onChange={e=>upRule(acl.name,i,"dst",e.target.value)} style={{flex:1}}/>
<label style={{fontSize:10,color:"var(--dm)",display:"flex",gap:3,alignItems:"center",whiteSpace:"nowrap"}}><input type="checkbox" checked={r.dstAny} onChange={e=>upRule(acl.name,i,"dstAny",e.target.checked)}/>any</label>
</div>
<input placeholder="port" value={r.port} onChange={e=>upRule(acl.name,i,"port",e.target.value)}/>
<button className="btn bd" style={{padding:"2px 6px"}} onClick={()=>delRule(acl.name,i)}>✕</button>
</div>
))}
<button className="btn bg" style={{marginTop:6,fontSize:10}} onClick={()=>addRule(acl.name)}>+ Add Rule</button>
</div>
</div>
))}
<div style={{marginTop:16,paddingTop:12,borderTop:"1px solid var(--b1)"}}>
<div className="sect">New ACL</div>
<div style={{display:"flex",gap:8,alignItems:"flex-end"}}>
<div className="field" style={{margin:0,flex:1}}><label>Name</label><input value={nn} onChange={e=>setNn(e.target.value)} placeholder="BLOCK-IOT"/></div>
<div className="field" style={{margin:0,width:130}}><label>VLAN</label><select value={nv} onChange={e=>setNv(+e.target.value)}>{vlans.map(v=><option key={v.id} value={v.id}>{v.id} — {v.name}</option>)}</select></div>
<div className="field" style={{margin:0,width:75}}><label>Direction</label><select value={nd} onChange={e=>setNd(e.target.value)}><option value="in">in</option><option value="out">out</option></select></div>
<button className="btn bp" onClick={addAcl}>Create</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// APP
// ══════════════════════════════════════════════════════════════════════════════
export default function App() {
const [tab, setTab] = useState("dashboard");
const [ports, setPorts] = useState(DEFAULT_PORTS);
const [vlans, setVlans] = useState(DEFAULT_VLANS);
const [acls, setAcls] = useState([]);
const [selected, setSelected] = useState(null);
const [hostname, setHostname] = useState("ERS-5952");
const [switchIP, setSwitchIP] = useState("192.168.99.1");
const [settings, setSettings] = useState({ cliMode: false, defaultPushMode: "batch" });
const [showSettings, setShowSettings] = useState(false);
const [connState, setConnState] = useState("connecting");
const [connInfo, setConnInfo] = useState("");
const connTimer = useRef(null);
const [pollStatus, setPollStatus] = useState("idle");
const [session, setSession] = useState(null);
const [showTotp, setShowTotp] = useState(false);
const updatePort = useCallback(p => setPorts(prev => prev.map(x => x.id===p.id?p:x)), []);
// Heartbeat
useEffect(() => {
let firstPoll = true;
const beat = async () => {
const mode = document.hidden ? "background" : "active";
try {
const data = await API("/heartbeat", { method:"POST", body:{ visitor_id: VISITOR_ID, mode } });
if (firstPoll) {
firstPoll = false;
setConnState("connected");
setConnInfo(`Connected — ${switchIP}`);
if (connTimer.current) clearTimeout(connTimer.current);
connTimer.current = setTimeout(() => setConnState("hidden"), 3000);
} else if (connState === "error") {
setConnState("connected");
setConnInfo(`Reconnected`);
connTimer.current = setTimeout(() => setConnState("hidden"), 3000);
}
setPollStatus(data.poll_error ? "warn" : data.last_poll ? "ok" : "idle");
} catch {
setConnState("error");
setPollStatus("err");
firstPoll = true;
}
};
beat();
const t = setInterval(beat, 30000);
const onVis = () => beat();
document.addEventListener("visibilitychange", onVis);
return () => { clearInterval(t); document.removeEventListener("visibilitychange", onVis); };
}, [switchIP]);
const handleTotpSuccess = (token) => {
setShowTotp(false);
setSession({ token });
};
const TABS = [
{ id:"dashboard",label:"Dashboard" },
{ id:"network", label:"Network" },
{ id:"firewall", label:"Firewall" },
{ id:"services", label:"Services" },
{ id:"ports", label:"Port Map" },
{ id:"vlans", label:"VLANs" },
{ id:"acls", label:"ACL Builder" },
{ id:"cli", label:"Review & Push" },
{ id:"devices", label:"Device Access" },
{ id:"dhcp", label:"DHCP" },
{ id:"dns", label:"DNS Filtering" },
{ id:"vpn", label:"VPN" },
{ id:"backups", label:"Backups" },
{ id:"alerts", label:"Alerts" },
];
return (
<>
<style>{css}</style>
<div className="app">
<ConnBanner state={connState} info={connInfo}/>
<div className="topbar">
<div>
<div className="logo">ERS-5952 MANAGER</div>
<div className="logo-sub">Extreme Networks / Avaya ERS 5900 Series</div>
</div>
<div className="sp"/>
<span className="tl">Hostname</span>
<input className="ti" value={hostname} onChange={e=>setHostname(e.target.value)} placeholder="ERS-5952"/>
<span className="tl">Switch IP</span>
<input className="ti" value={switchIP} onChange={e=>setSwitchIP(e.target.value)} placeholder="192.168.99.1"/>
<div className="poll-pill">
<span className={`dot ${pollStatus}`}/>
{pollStatus==="ok"?"Live":pollStatus==="warn"?"Poll error":pollStatus==="err"?"Offline":"Idle"}
</div>
<SessionBtn session={session} onUnlock={() => setShowTotp(true)}/>
<button className="settings-btn" onClick={() => setShowSettings(true)} title="Settings">⚙</button>
</div>
<div className="tabs">
{TABS.map(t=>(
<button key={t.id} className={`tab ${tab===t.id?"active":""}`} onClick={()=>setTab(t.id)}>{t.label}</button>
))}
</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}/>}
{tab==="cli" && <CliTab
ports={ports} vlans={vlans} acls={acls} hostname={hostname}
session={session} setSession={setSession}
onNeedAuth={() => setShowTotp(true)}
backendOk={pollStatus!=="err"}
settings={settings}
/>}
{tab==="devices" && <DeviceAccessTab
session={session}
onNeedAuth={() => setShowTotp(true)}
backendOk={pollStatus!=="err"}
/>}
{tab==="dhcp" && <DHCPTab
session={session}
onNeedAuth={() => setShowTotp(true)}
backendOk={pollStatus!=="err"}
/>}
{tab==="dns" && <DNSTab
vlans={vlans}
session={session}
onNeedAuth={() => setShowTotp(true)}
backendOk={pollStatus!=="err"}
acls={acls}
setAcls={setAcls}
/>}
{tab==="network" && <NetworkTab
vlans={vlans} setVlans={setVlans}
ports={ports} updatePort={updatePort}
session={session}
onNeedAuth={() => setShowTotp(true)}
backendOk={pollStatus!=="err"}
/>}
{tab==="firewall" && <FirewallTab
vlans={vlans}
session={session}
onNeedAuth={() => setShowTotp(true)}
backendOk={pollStatus!=="err"}
/>}
{tab==="services" && <ServicesTab
vlans={vlans}
session={session}
onNeedAuth={() => setShowTotp(true)}
backendOk={pollStatus!=="err"}
/>}
{tab==="alerts" && <AlertsTab
session={session}
onNeedAuth={() => setShowTotp(true)}
backendOk={pollStatus!=="err"}
/>}
{tab==="vpn" && <WireGuardTab
session={session}
onNeedAuth={() => setShowTotp(true)}
backendOk={pollStatus!=="err"}
vlans={vlans}
/>}
{tab==="backups" && <BackupTab
session={session}
onNeedAuth={() => setShowTotp(true)}
backendOk={pollStatus!=="err"}
/>}
{showTotp && <TotpModal
onSuccess={handleTotpSuccess}
onCancel={() => setShowTotp(false)}
commandCount={generateAnnotatedCLI({ports,vlans,acls,hostname}).length}
/>}
{showSettings && <SettingsPanel
settings={settings} setSettings={setSettings}
onClose={() => setShowSettings(false)}
/>}
</div>
</>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// DEVICE ACCESS TAB
// ══════════════════════════════════════════════════════════════════════════════
const MAC_TIPS = {
ios: "Settings → Wi-Fi → your network → Private Wi-Fi Address → OFF\nThen reconnect. Find real MAC: Settings → General → About → Wi-Fi Address",
android: "Settings → Network → Wi-Fi → your network → Privacy → Use device MAC\nThen reconnect.",
macos: "System Settings → Network → your connection → Details → Hardware → Manually set MAC",
windows: "Settings → Network → your adapter → Hardware properties → Random hardware addresses → OFF",
};
function DeviceAccessTab({ session, onNeedAuth, backendOk }) {
const [devices, setDevices] = useState({ saved: [], live: [], mgmt_ip: "" });
const [loading, setLoading] = useState(false);
const [showForm, setShowForm] = useState(false);
const [editDevice, setEditDevice] = useState(null);
const [showMacTip, setShowMacTip] = useState(null);
const [pushResult, setPushResult] = useState(null);
const [form, setForm] = useState({ name:"", mac:"", ip:"", vlan:10, management_access:false, static_ip:false, notes:"" });
const load = async () => {
setLoading(true);
try { setDevices(await API("/devices")); } catch(e) { console.error(e); }
setLoading(false);
};
useEffect(() => { if (backendOk) load(); }, [backendOk]);
const openAdd = () => { setForm({ name:"", mac:"", ip:"", vlan:10, management_access:false, static_ip:false, notes:"" }); setEditDevice(null); setShowForm(true); };
const openEdit = (d) => { setForm({...d}); setEditDevice(d); setShowForm(true); };
const save = async () => {
if (!session) { onNeedAuth(); return; }
try {
const r = await API("/devices/save", { method:"POST", body:{ token: session.token, device: form } });
setDevices(d => ({ ...d, saved: r.devices }));
setShowForm(false);
} catch(e) { alert(e.message); }
};
const del = async (mac) => {
if (!session) { onNeedAuth(); return; }
if (!confirm("Remove this device?")) return;
try {
const r = await API("/devices/delete", { method:"POST", body:{ token: session.token, mac } });
setDevices(d => ({ ...d, saved: r.devices }));
} catch(e) { alert(e.message); }
};
const pushReservation = async (device) => {
if (!session) { onNeedAuth(); return; }
try {
const r = await API("/devices/push-reservation", { method:"POST", body:{ token: session.token, device } });
setPushResult(r);
} catch(e) { setPushResult({ success:false, error:e.message }); }
};
const pushPinhole = async (mac, allow) => {
if (!session) { onNeedAuth(); return; }
try {
const r = await API("/devices/push-pinhole", { method:"POST", body:{ token: session.token, mac, allow } });
setPushResult(r);
} catch(e) { setPushResult({ success:false, error:e.message }); }
};
const adoptLive = (live) => {
setForm({ name: live.hostname||"", mac: live.mac, ip: live.ip, vlan:10, management_access:false, static_ip:false, notes:"" });
setEditDevice(null); setShowForm(true);
};
const savedMacs = new Set(devices.saved.map(d => d.mac));
const unregistered = (devices.live||[]).filter(l => !savedMacs.has(l.mac));
return (
<div className="main" style={{flexDirection:"column",gap:12}}>
{/* MAC randomization warning */}
<div className="panel">
<div className="ph">◈ Device Access — Management Network Pinhole</div>
<div className="pb">
<div style={{fontSize:12,color:"var(--dm)",marginBottom:12,lineHeight:1.7}}>
Devices listed here can reach the switch manager UI directly from their normal VLAN
without needing to be on VLAN 99 or connected to VPN. The switch enforces access
via an ACL pinhole. TOTP still gates any changes.
</div>
<div style={{background:"rgba(255,234,0,.06)",border:"1px solid rgba(255,234,0,.2)",borderRadius:4,padding:"10px 14px",marginBottom:12}}>
<div style={{color:"var(--warn)",fontSize:11,fontWeight:700,letterSpacing:1,textTransform:"uppercase",marginBottom:6}}>
⚠ MAC Address Randomization
</div>
<div style={{fontSize:11,color:"var(--tx)",marginBottom:8,lineHeight:1.6}}>
Modern phones and laptops use random MAC addresses per network by default.
This breaks DHCP reservations and makes stable IP assignment impossible.
Disable it on each device before adding it here.
</div>
<div style={{display:"flex",gap:6,flexWrap:"wrap"}}>
{Object.entries({iOS:"ios",Android:"android",macOS:"macos",Windows:"windows"}).map(([label,key])=>(
<button key={key} className="btn bg" style={{padding:"3px 10px",fontSize:10}}
onClick={()=>setShowMacTip(showMacTip===key?null:key)}>
{label}
</button>
))}
</div>
{showMacTip && (
<div style={{marginTop:8,background:"var(--bg)",borderRadius:3,padding:"8px 10px",fontFamily:"var(--mono)",fontSize:11,color:"var(--tx)",whiteSpace:"pre-line"}}>
{MAC_TIPS[showMacTip]}
</div>
)}
</div>
<button className="btn bp" onClick={openAdd}>+ Add Device</button>
<button className="btn bg" style={{marginLeft:8}} onClick={load}>↻ Refresh</button>
</div>
</div>
{/* Saved devices */}
<div className="panel">
<div className="ph">◈ Registered Devices ({devices.saved.length})</div>
<div className="pb">
{devices.saved.length === 0 && <div className="empty">No devices registered yet.</div>}
{devices.saved.map(d => (
<div key={d.mac} style={{background:"var(--bg)",border:"1px solid var(--b2)",borderRadius:4,padding:"10px 12px",marginBottom:8}}>
<div style={{display:"flex",alignItems:"center",gap:10,flexWrap:"wrap"}}>
<div style={{flex:1}}>
<div style={{fontWeight:700,marginBottom:2}}>{d.name}</div>
<div style={{fontFamily:"var(--mono)",fontSize:11,color:"var(--dm)"}}>
{d.mac} · {d.ip} · VLAN {d.vlan}
</div>
{d.notes && <div style={{fontSize:11,color:"var(--dm)",marginTop:2}}>{d.notes}</div>}
</div>
<div style={{display:"flex",gap:6,flexWrap:"wrap"}}>
{d.static_ip && (
<button className="btn bg" style={{fontSize:10,padding:"3px 8px"}}
onClick={() => pushReservation(d)}>
Push DHCP Reservation
</button>
)}
<button
className={`btn ${d.management_access?"bd":"bs"}`}
style={{fontSize:10,padding:"3px 8px"}}
onClick={() => pushPinhole(d.mac, !d.management_access)}>
{d.management_access ? "Revoke Access" : "Grant Access"}
</button>
<button className="btn bg" style={{fontSize:10,padding:"3px 8px"}} onClick={()=>openEdit(d)}>Edit</button>
<button className="btn bd" style={{fontSize:10,padding:"3px 8px"}} onClick={()=>del(d.mac)}>✕</button>
</div>
</div>
<div style={{display:"flex",gap:8,marginTop:6}}>
<span className={`badge ${d.management_access?"":"" }`}
style={{background:d.management_access?"rgba(0,230,118,.15)":"rgba(90,96,112,.15)",
color:d.management_access?"var(--ok)":"var(--dm)"}}>
{d.management_access ? "✓ Management access" : "✗ No management access"}
</span>
<span className="badge" style={{background:d.static_ip?"rgba(0,229,255,.12)":"rgba(90,96,112,.12)",
color:d.static_ip?"var(--ac)":"var(--dm)"}}>
{d.static_ip ? "Static IP reserved" : "Dynamic IP"}
</span>
</div>
</div>
))}
</div>
</div>
{/* Live DHCP leases */}
{unregistered.length > 0 && (
<div className="panel">
<div className="ph">◈ Live DHCP Leases — Unregistered ({unregistered.length})</div>
<div className="pb">
<div style={{fontSize:11,color:"var(--dm)",marginBottom:10}}>
These devices are on the network but not registered. Click to add them.
</div>
{unregistered.map(l => (
<div key={l.mac} style={{display:"flex",alignItems:"center",gap:10,padding:"8px 0",borderBottom:"1px solid var(--b1)"}}>
<div style={{flex:1,fontFamily:"var(--mono)",fontSize:11}}>
<span style={{color:"var(--tx)"}}>{l.ip}</span>
<span style={{color:"var(--dm)",margin:"0 8px"}}>·</span>
<span style={{color:"var(--dm)"}}>{l.mac}</span>
{l.hostname && l.hostname !== "unknown" && (
<span style={{color:"var(--ac)",marginLeft:8}}>{l.hostname}</span>
)}
</div>
<button className="btn bg" style={{fontSize:10,padding:"3px 8px"}} onClick={()=>adoptLive(l)}>
+ Register
</button>
</div>
))}
</div>
</div>
)}
{/* Push result */}
{pushResult && (
<div className="panel">
<div className={`ph ${pushResult.success?"":"" }`}
style={{color:pushResult.success?"var(--ok)":"var(--err)"}}>
{pushResult.success ? "✓ Push successful — config saved" : `✗ Push failed: ${pushResult.error}`}
<button className="btn bg" style={{marginLeft:"auto",padding:"2px 8px",fontSize:10}}
onClick={()=>setPushResult(null)}>✕</button>
</div>
</div>
)}
{/* Add/Edit form modal */}
{showForm && (
<div className="modal-bg" onClick={()=>setShowForm(false)}>
<div className="modal" style={{width:440,textAlign:"left"}} onClick={e=>e.stopPropagation()}>
<h2 style={{marginBottom:16}}>{editDevice?"EDIT DEVICE":"ADD DEVICE"}</h2>
<div className="field"><label>Device Name</label>
<input value={form.name} onChange={e=>setForm(f=>({...f,name:e.target.value}))} placeholder="e.g. Dev Laptop, iPhone"/></div>
<div className="field"><label>MAC Address</label>
<input value={form.mac} onChange={e=>setForm(f=>({...f,mac:e.target.value}))}
placeholder="aa:bb:cc:dd:ee:ff" style={{fontFamily:"var(--mono)"}}/></div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:8}}>
<div className="field"><label>IP Address</label>
<input value={form.ip} onChange={e=>setForm(f=>({...f,ip:e.target.value}))}
placeholder="192.168.10.50" style={{fontFamily:"var(--mono)"}}/></div>
<div className="field"><label>VLAN</label>
<input type="number" value={form.vlan} onChange={e=>setForm(f=>({...f,vlan:+e.target.value}))}/></div>
</div>
<div className="field"><label>Notes (optional)</label>
<input value={form.notes} onChange={e=>setForm(f=>({...f,notes:e.target.value}))} placeholder="e.g. Main dev laptop"/></div>
<div style={{display:"flex",gap:16,marginBottom:14}}>
<label style={{display:"flex",alignItems:"center",gap:6,fontSize:12,cursor:"pointer"}}>
<input type="checkbox" checked={form.static_ip}
onChange={e=>setForm(f=>({...f,static_ip:e.target.checked}))}/>
Reserve static IP (DHCP binding)
</label>
<label style={{display:"flex",alignItems:"center",gap:6,fontSize:12,cursor:"pointer"}}>
<input type="checkbox" checked={form.management_access}
onChange={e=>setForm(f=>({...f,management_access:e.target.checked}))}/>
Grant management access
</label>
</div>
<div style={{display:"flex",gap:8,justifyContent:"flex-end"}}>
<button className="btn bg" onClick={()=>setShowForm(false)}>Cancel</button>
<button className="btn bp" onClick={save}>Save Device</button>
</div>
</div>
</div>
)}
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// WIREGUARD TAB
// ══════════════════════════════════════════════════════════════════════════════
function QRModal({ config, name, onClose }) {
// Render QR using a simple API since we can't use native qrencode in browser
const [qrUrl, setQrUrl] = useState('');
useEffect(() => {
// Use Google Charts QR API (works offline-ish, data URI approach)
const encoded = encodeURIComponent(config);
setQrUrl(`https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encoded}`);
}, [config]);
return (
<div className="modal-bg" onClick={onClose}>
<div className="modal" onClick={e=>e.stopPropagation()}>
<h2> {name.toUpperCase()}</h2>
<p style={{marginBottom:12}}>Scan with WireGuard app<br/>
<span style={{fontSize:10,color:"var(--dm)"}}>iOS App Store / Android Play Store: search "WireGuard"<br/>
Desktop: wireguard.com/install</span>
</p>
{qrUrl && <img src={qrUrl} alt="WireGuard QR" style={{width:220,height:220,margin:"0 auto 12px",display:"block",borderRadius:4}}/>}
<div style={{background:"var(--bg)",borderRadius:3,padding:8,fontFamily:"var(--mono)",fontSize:10,
color:"var(--dm)",textAlign:"left",whiteSpace:"pre",overflowX:"auto",
maxHeight:120,overflowY:"auto",marginBottom:12}}>
{config}
</div>
<div style={{display:"flex",gap:8,justifyContent:"center"}}>
<button className="btn bg" onClick={()=>{
const a=document.createElement('a');
a.href=URL.createObjectURL(new Blob([config],{type:'text/plain'}));
a.download=`${name}.conf`; a.click();
}}>Download .conf</button>
<button className="btn bg" onClick={()=>navigator.clipboard?.writeText(config)}>Copy</button>
<button className="btn bp" onClick={onClose}>Done</button>
</div>
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// 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([]);
const [newName, setNewName] = useState('');
const [adding, setAdding] = useState(false);
const [qrModal, setQrModal] = useState(null); // { config, name }
const [error, setError] = useState('');
// ── OPNsense WireGuard state ────────────────────────────────────
const [opnWg, setOpnWg] = useState(null); // status response
const [opnLoading, setOpnLoading] = useState(false);
const [opnError, setOpnError] = useState('');
const [opnSetup, setOpnSetup] = useState({
server_name: 'switch-mgmt-vpn', listen_port: 51820,
tunnel_subnet: '10.99.2.0/24', public_endpoint: '',
});
const [opnPeerName, setOpnPeerName] = useState('');
const [opnVlans, setOpnVlans] = useState([]); // checked VLAN IDs
const [opnAdding, setOpnAdding] = useState(false);
const [opnQr, setOpnQr] = useState(null); // { config, name }
const load = async () => {
try {
const [s, c] = await Promise.all([API("/wireguard/status"), API("/wireguard/clients")]);
setStatus(s); setClients(c.clients||[]);
} catch(e) { setError(e.message); }
};
useEffect(() => { if (backendOk) load(); }, [backendOk]);
const addClient = async () => {
if (!newName.trim()) return;
if (!session) { onNeedAuth(); return; }
setAdding(true); setError('');
try {
const r = await API("/wireguard/add-client", {
method:"POST", body:{ token: session.token, name: newName.trim() }
});
setQrModal({ config: r.config, name: r.name });
setNewName('');
await load();
} catch(e) { setError(e.message); }
setAdding(false);
};
const showQR = async (name) => {
try {
const r = await API(`/wireguard/client-qr/${name}`);
setQrModal({ config: r.config, name: r.name });
} catch(e) { setError(e.message); }
};
const revoke = async (name) => {
if (!session) { onNeedAuth(); return; }
if (!confirm(`Revoke access for "${name}"? They will be disconnected immediately.`)) return;
try {
await API("/wireguard/revoke-client", { method:"POST", body:{ token: session.token, name } });
await load();
} catch(e) { setError(e.message); }
};
const isRunning = status?.running;
// ── OPNsense WireGuard handlers ──────────────────────────────────
const loadOpnWg = async () => {
setOpnLoading(true); setOpnError('');
try { setOpnWg(await API("/opnsense/wireguard/status")); }
catch(e) { setOpnError(e.message); }
setOpnLoading(false);
};
useEffect(() => { if (backendOk) loadOpnWg(); }, [backendOk]);
const opnSetupServer = async () => {
if (!session) { onNeedAuth(); return; }
setOpnLoading(true); setOpnError('');
try {
await API("/opnsense/wireguard/setup-server", {
method:"POST", body:{ token: session.token, ...opnSetup }
});
await loadOpnWg();
} catch(e) { setOpnError(e.message); }
setOpnLoading(false);
};
const opnDeleteServer = async () => {
if (!session) { onNeedAuth(); return; }
if (!confirm("Remove the WireGuard server from OPNsense? All peers will be disconnected.")) return;
setOpnLoading(true); setOpnError('');
try {
await API(`/opnsense/wireguard/server?token=${session.token}`, { method:"DELETE" });
await loadOpnWg();
} catch(e) { setOpnError(e.message); }
setOpnLoading(false);
};
const opnToggleVlan = (vid) => {
setOpnVlans(prev => prev.includes(vid) ? prev.filter(v=>v!==vid) : [...prev, vid]);
};
const opnAddPeer = async () => {
if (!opnPeerName.trim()) return;
if (opnVlans.length === 0) { setOpnError("Select at least one VLAN for this peer."); return; }
if (!session) { onNeedAuth(); return; }
setOpnAdding(true); setOpnError('');
// Build vlan_subnets map from the vlans prop
const vlan_subnets = {};
vlans.forEach(v => { vlan_subnets[v.id] = `192.168.${v.id}.0/24`; });
try {
const r = await API("/opnsense/wireguard/add-peer", {
method:"POST",
body:{ token: session.token, name: opnPeerName.trim(),
allowed_vlans: opnVlans, vlan_subnets }
});
setOpnQr({ config: r.config, name: r.name });
setOpnPeerName(''); setOpnVlans([]);
await loadOpnWg();
} catch(e) { setOpnError(e.message); }
setOpnAdding(false);
};
const opnRevokePeer = async (uuid, name) => {
if (!session) { onNeedAuth(); return; }
if (!confirm(`Revoke VPN access for "${name}"?`)) return;
setOpnError('');
try {
await API(`/opnsense/wireguard/peer/${uuid}?token=${session.token}`, { method:"DELETE" });
await loadOpnWg();
} catch(e) { setOpnError(e.message); }
};
const opnShowConf = async (name) => {
try {
const r = await API(`/opnsense/wireguard/peer-config/${name}`);
setOpnQr({ config: r.config, name: r.name });
} catch(e) { setOpnError(e.message); }
};
// Pre-select servers VLAN when OPNsense WG server becomes available
useEffect(() => {
if (opnWg?.server && opnVlans.length === 0) {
const serversVlan = vlans.find(v =>
v.name?.toLowerCase().includes("server") && v.id !== 99
);
if (serversVlan) setOpnVlans([serversVlan.id]);
}
}, [opnWg?.server]);
const opnAvailable = opnWg?.opnsense_configured && opnWg?.plugin_installed;
return (
<div className="main" style={{flexDirection:"column",gap:12}}>
{/* ── Recommendation banner when OPNsense WG is available ──── */}
{opnAvailable && (
<div style={{
background:"rgba(0,229,255,.06)",border:"1px solid rgba(0,229,255,.25)",
borderRadius:6,padding:"12px 16px",display:"flex",gap:12,alignItems:"flex-start",
}}>
<div style={{fontSize:18,lineHeight:1,color:"var(--ac)"}}></div>
<div style={{flex:1}}>
<div style={{fontWeight:700,color:"var(--ac)",marginBottom:4}}>
OPNsense WireGuard available recommended
</div>
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7}}>
Your OPNsense has the WireGuard plugin. Running VPN on the router is
better than here: it stays on when this PC is off, your firewall and VPN
live in the same place, and every VLAN can be reached remotely.
The local option below still works as a backup.
</div>
</div>
</div>
)}
{/* ── OPNsense WG first when available ─────────────────────── */}
{opnAvailable && (
<OPNsenseWGSection
opnWg={opnWg} opnLoading={opnLoading} opnError={opnError}
opnSetup={opnSetup} setOpnSetup={setOpnSetup}
opnPeerName={opnPeerName} setOpnPeerName={setOpnPeerName}
opnVlans={opnVlans} opnAdding={opnAdding}
session={session} onNeedAuth={onNeedAuth} vlans={vlans}
onToggleVlan={opnToggleVlan} onSetupServer={opnSetupServer}
onDeleteServer={opnDeleteServer} onAddPeer={opnAddPeer}
onRevokePeer={opnRevokePeer} onShowConf={opnShowConf} onRefresh={loadOpnWg}
/>
)}
{/* ── Local WireGuard ──────────────────────────────────────── */}
<div className="panel">
<div className="ph">
{opnAvailable ? "◈ Local WireGuard — backup / this machine only" : "◈ WireGuard VPN"}
<span style={{marginLeft:"auto",fontFamily:"var(--mono)",fontSize:10,
color:isRunning?"var(--ok)":"var(--err)"}}>
{isRunning ? "● Running" : "○ Not running"}
</span>
</div>
<div className="pb">
{opnAvailable ? (
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:12}}>
Run WireGuard on this management PC instead of OPNsense.
Use this as a backup when OPNsense is being reconfigured, or if you
prefer not to touch the router. Clients can only reach VLAN 99
(management network) from here they cannot reach other VLANs
unless this PC also routes that traffic.
</div>
) : (
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:12}}>
WireGuard lets you connect from anywhere coffee shop, hotel, cellular
and reach this switch manager as if you were on VLAN 99.
Each device gets its own key; revoking a key disconnects it immediately.
<br/><br/>
<span style={{color:"var(--warn)"}}>
Tip: Connect OPNsense in the DHCP tab to unlock router-level WireGuard
always on, handles all VLANs, no dependency on this machine.
</span>
</div>
)}
{!isRunning && (
<div style={{background:"rgba(255,23,68,.07)",border:"1px solid rgba(255,23,68,.2)",
borderRadius:4,padding:"10px 14px",marginBottom:12,fontSize:11,color:"var(--err)"}}>
WireGuard is not running on this machine. Run setup again and choose the WireGuard option,
or run: <span style={{fontFamily:"var(--mono)"}}>sudo systemctl start wg-quick@wg0</span>
</div>
)}
{error && <div style={{color:"var(--err)",fontSize:11,marginBottom:10}}>{error}</div>}
</div>
</div>
{/* Connected peers */}
{isRunning && status?.peers?.length > 0 && (
<div className="panel">
<div className="ph"> Local WG Connected Peers ({status.peers.length})</div>
<div className="pb">
{status.peers.map((p,i) => (
<div key={i} style={{padding:"8px 0",borderBottom:"1px solid var(--b1)",
display:"flex",alignItems:"center",gap:12}}>
<div style={{flex:1}}>
<div style={{fontWeight:700,marginBottom:2}}>{p.name}</div>
<div style={{fontFamily:"var(--mono)",fontSize:10,color:"var(--dm)"}}>
{p.allowed_ips} · {p.endpoint||"not connected"}
</div>
{p.last_handshake && (
<div style={{fontSize:10,color:"var(--dm)"}}>Last seen: {p.last_handshake}</div>
)}
{p.transfer && (
<div style={{fontSize:10,color:"var(--dm)"}}>Transfer: {p.transfer}</div>
)}
</div>
<div style={{width:8,height:8,borderRadius:"50%",
background:p.last_handshake&&!p.last_handshake.includes("never")?"var(--ok)":"var(--dm)"}}/>
</div>
))}
</div>
</div>
)}
{/* Client management */}
<div className="panel">
<div className="ph"> Local WG Clients</div>
<div className="pb">
{clients.length === 0 && <div className="empty" style={{padding:"16px 0"}}>No clients configured yet.</div>}
{clients.map(c => (
<div key={c.name} style={{display:"flex",alignItems:"center",gap:8,
padding:"8px 0",borderBottom:"1px solid var(--b1)"}}>
<span style={{flex:1,fontWeight:600}}>{c.name}</span>
<button className="btn bg" style={{fontSize:10,padding:"3px 8px"}} onClick={()=>showQR(c.name)}>
QR / .conf
</button>
<button className="btn bd" style={{fontSize:10,padding:"3px 8px"}} onClick={()=>revoke(c.name)}>
Revoke
</button>
</div>
))}
<div style={{marginTop:14,paddingTop:12,borderTop:"1px solid var(--b1)",display:"flex",gap:8,alignItems:"flex-end"}}>
<div className="field" style={{margin:0,flex:1}}>
<label>Add New Client</label>
<input value={newName} onChange={e=>setNewName(e.target.value)}
onKeyDown={e=>e.key==="Enter"&&addClient()}
placeholder="e.g. laptop, phone, tablet"/>
</div>
<button className="btn bp" onClick={addClient} disabled={!newName.trim()||adding||!isRunning}>
{adding?"Adding...":"Add Client"}
</button>
</div>
{!session && (
<div style={{fontSize:11,color:"var(--dm)",marginTop:8}}>
<button className="btn bg" style={{fontSize:10,padding:"3px 8px",marginRight:6}}
onClick={onNeedAuth}>Authenticate</button>
to add or revoke clients
</div>
)}
</div>
</div>
{/* SSH tunnel fallback */}
<div className="panel">
<div className="ph"> SSH Tunnel Emergency Fallback</div>
<div className="pb">
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:8}}>
If WireGuard is down or misconfigured, SSH port forwarding reaches the
switch manager in one command no daemon, no keys to manage.
</div>
<div style={{background:"#060809",borderRadius:4,padding:"10px 14px",
fontFamily:"var(--mono)",fontSize:12,color:"#a0b0c0",marginBottom:8}}>
ssh -L 8765:localhost:8765 user@your-management-computer-ip
</div>
<div style={{fontSize:11,color:"var(--dm)"}}>
Then open <span style={{color:"var(--ac)",fontFamily:"var(--mono)"}}>http://localhost:8765</span>.
Requires SSH accessible from outside (key auth only consider fail2ban).
</div>
</div>
</div>
{qrModal && <QRModal config={qrModal.config} name={qrModal.name} onClose={()=>setQrModal(null)}/>}
{/* OPNsense WG shown below local when not yet available (nudge to set up) */}
{!opnAvailable && (
<OPNsenseWGSection
opnWg={opnWg}
opnLoading={opnLoading}
opnError={opnError}
opnSetup={opnSetup}
setOpnSetup={setOpnSetup}
opnPeerName={opnPeerName}
setOpnPeerName={setOpnPeerName}
opnVlans={opnVlans}
opnAdding={opnAdding}
session={session}
onNeedAuth={onNeedAuth}
vlans={vlans}
onToggleVlan={opnToggleVlan}
onSetupServer={opnSetupServer}
onDeleteServer={opnDeleteServer}
onAddPeer={opnAddPeer}
onRevokePeer={opnRevokePeer}
onShowConf={opnShowConf}
onRefresh={loadOpnWg}
/>
)}
{opnQr && <QRModal config={opnQr.config} name={opnQr.name} onClose={()=>setOpnQr(null)}/>}
</div>
);
}
// ── OPNsense WireGuard section (separate component for readability) ───────────
function OPNsenseWGSection({
opnWg, opnLoading, opnError, opnSetup, setOpnSetup,
opnPeerName, setOpnPeerName, opnVlans, opnAdding,
session, onNeedAuth, vlans,
onToggleVlan, onSetupServer, onDeleteServer,
onAddPeer, onRevokePeer, onShowConf, onRefresh,
}) {
const panelHeader = (
<div className="ph" style={{display:"flex",alignItems:"center",gap:8}}>
OPNsense WireGuard Router-Level VPN
<span style={{marginLeft:"auto",fontSize:10,fontFamily:"var(--mono)",
color:"var(--dm)",cursor:"pointer"}} onClick={onRefresh}>
{opnLoading ? "loading…" : "↻ refresh"}
</span>
</div>
);
// ── OPNsense not connected ──────────────────────────────────────
if (!opnWg || !opnWg.opnsense_configured) {
return (
<div className="panel">
{panelHeader}
<div className="pb" style={{color:"var(--dm)",fontSize:12,lineHeight:1.8}}>
<div style={{marginBottom:8}}>
Move WireGuard off your management computer and onto OPNsense.
Each peer can be restricted to specific VLANs e.g. a phone
gets access to VLAN 10 only, while a laptop gets VLAN 10 + 20.
</div>
<div style={{background:"rgba(255,234,0,.07)",border:"1px solid rgba(255,234,0,.2)",
borderRadius:4,padding:"10px 14px",fontSize:11,color:"var(--warn)"}}>
OPNsense is not connected. Go to the <strong>DHCP tab</strong> and
add your OPNsense API credentials first.
</div>
</div>
</div>
);
}
// ── Plugin not installed ────────────────────────────────────────
if (!opnWg.plugin_installed) {
return (
<div className="panel">
{panelHeader}
<div className="pb">
<div style={{background:"rgba(255,23,68,.07)",border:"1px solid rgba(255,23,68,.2)",
borderRadius:4,padding:"10px 14px",fontSize:11,color:"var(--err)",marginBottom:12}}>
<strong>WireGuard plugin not installed on OPNsense.</strong>
{opnWg.error && <div style={{marginTop:4,opacity:.7}}>{opnWg.error}</div>}
</div>
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.9}}>
Install it in OPNsense:
<ol style={{margin:"6px 0 0 16px",padding:0}}>
<li>System Firmware Plugins</li>
<li>Search <span style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>wireguard</span></li>
<li>Install <strong>os-wireguard</strong></li>
<li>Reload this page or click refresh above</li>
</ol>
</div>
</div>
</div>
);
}
const server = opnWg.server;
const peers = opnWg.peers || [];
// ── Server not yet created ──────────────────────────────────────
if (!server) {
return (
<div className="panel">
{panelHeader}
<div className="pb">
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:14}}>
Create a WireGuard server on OPNsense. It runs as <code>wg1</code> so it
does not conflict with the local <code>wg0</code> on this machine.
Peer private keys are generated here and stored only on this management PC
OPNsense only ever receives the public key.
</div>
{opnError && (
<div style={{color:"var(--err)",fontSize:11,marginBottom:10}}>{opnError}</div>
)}
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:10,marginBottom:14}}>
<div className="field" style={{margin:0}}>
<label>Server Name</label>
<input value={opnSetup.server_name}
onChange={e=>setOpnSetup(s=>({...s,server_name:e.target.value}))}
placeholder="switch-mgmt-vpn"/>
</div>
<div className="field" style={{margin:0}}>
<label>Listen Port</label>
<input type="number" value={opnSetup.listen_port}
onChange={e=>setOpnSetup(s=>({...s,listen_port:parseInt(e.target.value)||51820}))}/>
</div>
<div className="field" style={{margin:0}}>
<label>Tunnel Subnet</label>
<input value={opnSetup.tunnel_subnet}
onChange={e=>setOpnSetup(s=>({...s,tunnel_subnet:e.target.value}))}
placeholder="10.99.2.0/24"/>
</div>
<div className="field" style={{margin:0}}>
<label>Your Public IP / DDNS</label>
<input value={opnSetup.public_endpoint}
onChange={e=>setOpnSetup(s=>({...s,public_endpoint:e.target.value}))}
placeholder="home.example.com or 1.2.3.4"/>
</div>
</div>
<div style={{fontSize:11,color:"var(--dm)",marginBottom:14,lineHeight:1.7}}>
The tunnel subnet (default <span style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>10.99.2.0/24</span>)
is separate from your LAN VLANs. VPN clients get IPs from this range.
Your public IP / DDNS is what clients connect to from the internet.
</div>
<div style={{display:"flex",gap:8,alignItems:"center"}}>
{!session && (
<button className="btn bg" style={{fontSize:11,padding:"4px 10px"}} onClick={onNeedAuth}>
Authenticate
</button>
)}
<button className="btn bp" onClick={onSetupServer}
disabled={opnLoading || !session || !opnSetup.server_name}>
{opnLoading ? "Creating…" : "Create Server on OPNsense"}
</button>
</div>
</div>
</div>
);
}
// ── Server active — show peers and add-peer form ────────────────
const tunnelNet = opnSetup.tunnel_subnet || "10.99.2.0/24";
// Determine which VLANs have firewall rules to show
const allUsedVlans = [...new Set(peers.flatMap(p => p.allowed_vlans || []))];
return (
<div style={{display:"flex",flexDirection:"column",gap:12}}>
{/* Server status card */}
<div className="panel">
{panelHeader}
<div className="pb">
<div style={{display:"flex",alignItems:"flex-start",gap:16,flexWrap:"wrap"}}>
<div style={{flex:1,minWidth:200}}>
<div style={{fontWeight:700,marginBottom:6,color:"var(--ok)"}}>
{server.name}
</div>
<div style={{fontFamily:"var(--mono)",fontSize:11,color:"var(--dm)",lineHeight:2}}>
<span style={{color:"var(--tx)"}}>Tunnel:</span> {server.tunnel_ip}<br/>
<span style={{color:"var(--tx)"}}>Port:</span> {server.listen_port}<br/>
<span style={{color:"var(--tx)"}}>Endpoint:</span> {server.public_endpoint||<em>not set</em>}
</div>
</div>
<div style={{flex:2,minWidth:240}}>
<div style={{fontSize:10,color:"var(--dm)",marginBottom:4}}>Server Public Key</div>
<div style={{fontFamily:"var(--mono)",fontSize:10,background:"var(--bg)",
borderRadius:4,padding:"6px 10px",wordBreak:"break-all",
border:"1px solid var(--b1)",color:"var(--ac)"}}>
{server.pubkey || "— generating —"}
</div>
</div>
<button className="btn bd" style={{fontSize:10,padding:"3px 10px",alignSelf:"flex-start"}}
onClick={onDeleteServer}>
Remove Server
</button>
</div>
{opnError && (
<div style={{color:"var(--err)",fontSize:11,marginTop:10}}>{opnError}</div>
)}
</div>
</div>
{/* Peer list */}
<div className="panel">
<div className="ph"> VPN Peers ({peers.length})</div>
<div className="pb">
{peers.length === 0 && (
<div className="empty" style={{padding:"12px 0"}}>No peers yet. Add one below.</div>
)}
{peers.map(p => (
<div key={p.uuid} style={{display:"flex",alignItems:"center",gap:10,
padding:"8px 0",borderBottom:"1px solid var(--b1)"}}>
<div style={{width:8,height:8,borderRadius:"50%",flexShrink:0,
background:p.enabled?"var(--ok)":"var(--dm)"}}/>
<div style={{flex:1}}>
<div style={{fontWeight:700,marginBottom:3}}>{p.name}</div>
<div style={{fontFamily:"var(--mono)",fontSize:10,color:"var(--dm)"}}>
{p.tunnel_ip}
</div>
<div style={{display:"flex",flexWrap:"wrap",gap:4,marginTop:4}}>
{(p.allowed_vlans||[]).map(vid => {
const v = vlans.find(x=>x.id===vid)||{name:`VLAN ${vid}`,color:"var(--dm)"};
return (
<span key={vid} style={{
background:`${v.color}22`,color:v.color,
borderRadius:10,padding:"1px 7px",fontSize:10,
fontFamily:"var(--mono)",fontWeight:700,
}}>VLAN {vid} · {v.name}</span>
);
})}
</div>
</div>
<button className="btn bg" style={{fontSize:10,padding:"3px 8px"}}
onClick={()=>onShowConf(p.name)}>
.conf / QR
</button>
<button className="btn bd" style={{fontSize:10,padding:"3px 8px"}}
onClick={()=>onRevokePeer(p.uuid, p.name)}>
Revoke
</button>
</div>
))}
{/* Add peer form */}
<div style={{marginTop:16,paddingTop:14,borderTop:"1px solid var(--b1)"}}>
<div style={{fontWeight:700,marginBottom:10,fontSize:12}}>Add Peer</div>
<div style={{display:"flex",gap:10,alignItems:"flex-start",flexWrap:"wrap"}}>
<div className="field" style={{margin:0,flex:"0 0 180px"}}>
<label>Peer Name</label>
<input value={opnPeerName}
onChange={e=>setOpnPeerName(e.target.value)}
onKeyDown={e=>e.key==="Enter"&&onAddPeer()}
placeholder="e.g. phone, laptop"/>
</div>
<div style={{flex:1,minWidth:220}}>
<label style={{display:"block",fontSize:11,color:"var(--dm)",
marginBottom:6,fontFamily:"var(--mono)"}}>VLAN Access</label>
<div style={{display:"flex",flexWrap:"wrap",gap:6}}>
{vlans.filter(v=>v.id!==99).map(v => {
const on = opnVlans.includes(v.id);
return (
<div key={v.id} onClick={()=>onToggleVlan(v.id)}
style={{
cursor:"pointer",userSelect:"none",borderRadius:5,
padding:"5px 10px",fontSize:11,fontFamily:"var(--mono)",
border:`1px solid ${on?v.color:"var(--b2)"}`,
background:on?`${v.color}22`:"transparent",
color:on?v.color:"var(--dm)",fontWeight:on?700:400,
transition:"all .15s",
}}>
{on?"✓ ":""}{v.id} · {v.name}
</div>
);
})}
</div>
</div>
</div>
<div style={{marginTop:12,display:"flex",gap:8,alignItems:"center"}}>
{!session && (
<button className="btn bg" style={{fontSize:11,padding:"4px 10px"}}
onClick={onNeedAuth}>Authenticate</button>
)}
<button className="btn bp" onClick={onAddPeer}
disabled={opnAdding||!opnPeerName.trim()||opnVlans.length===0||!session}>
{opnAdding?"Adding…":"Add Peer"}
</button>
<span style={{fontSize:11,color:"var(--dm)"}}>
Generates keypair here · sends only pubkey to OPNsense
</span>
</div>
</div>
</div>
</div>
{/* Firewall rules guidance */}
{peers.length > 0 && (
<div className="panel">
<div className="ph"> Required OPNsense Firewall Rules</div>
<div className="pb">
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:10}}>
WireGuard handles the tunnel, but OPNsense still enforces firewall rules
between the tunnel and your VLANs. Add these rules under
<span style={{fontFamily:"var(--mono)",color:"var(--ac)",margin:"0 4px"}}>
Firewall Rules WireGuard
</span>
(the interface is <span style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>wg1</span>
if this is instance 1):
</div>
<div style={{background:"#060809",borderRadius:4,padding:"10px 14px",
fontFamily:"var(--mono)",fontSize:11,color:"#a0b0c0",lineHeight:2}}>
{/* One rule per unique VLAN across all peers */}
{[...new Set(peers.flatMap(p=>p.allowed_vlans||[]))].sort((a,b)=>a-b).map(vid => {
const v = vlans.find(x=>x.id===vid)||{name:`VLAN ${vid}`};
return (
<div key={vid}>
<span style={{color:"#4fc"}}># Allow WireGuard VLAN {vid} ({v.name})</span><br/>
<span style={{color:"#ffa"}}>pass </span>
<span>in interface wg1</span><br/>
<span style={{paddingLeft:16}}>src: {tunnelNet}</span><br/>
<span style={{paddingLeft:16}}>dst: 192.168.{vid}.0/24</span><br/>
<br/>
</div>
);
})}
<span style={{color:"#4fc"}}># Block everything else from the tunnel</span><br/>
<span style={{color:"#f88"}}>block </span>
<span>in interface wg1 src: {tunnelNet} dst: any</span>
</div>
<div style={{fontSize:11,color:"var(--dm)",marginTop:8,lineHeight:1.7}}>
Also open UDP {server?.listen_port||51820} inbound on your WAN interface
so peers can reach OPNsense from the internet.
</div>
</div>
</div>
)}
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// DHCP MANAGEMENT TAB
// ══════════════════════════════════════════════════════════════════════════════
// VLAN definitions — matches the network plan
const VLAN_MAP = {
99: { name:"Management", color:"var(--dm)", note:"Switch recovery — always local" },
10: { name:"House", color:"var(--ok)", note:"Devices get hostname.lan via OPNsense DNS" },
20: { name:"Servers", color:"var(--ac)", note:"Server names resolve via OPNsense Unbound" },
30: { name:"IoT", color:"var(--warn)",note:"Isolated devices, predictable IPs for firewall rules" },
40: { name:"Guest", color:"#b388ff", note:"Internet only — printer access via firewall rule" },
50: { name:"Cameras", color:"#ff80ab", note:"NVR-only access, no internet" },
};
// Infer VLAN from IP address third octet
function vlanFromIp(ip) {
if (!ip) return null;
const third = parseInt(ip.split('.')[2], 10);
return isNaN(third) ? null : third;
}
function VlanBadge({ vlan }) {
const info = VLAN_MAP[vlan];
const color = info?.color || "var(--dm)";
const label = info ? `VLAN ${vlan} · ${info.name}` : (vlan ? `VLAN ${vlan}` : "—");
return (
<span style={{
background: `${color}18`, color, borderRadius:10,
padding:"1px 7px", fontSize:10, fontFamily:"var(--mono)", fontWeight:700,
whiteSpace:"nowrap",
}}>{label}</span>
);
}
// Relay panel — shows per-VLAN relay status and lets you push config
function RelayPanel({ overview, opnsenseHost, session, onNeedAuth, onRefresh }) {
const [pushing, setPushing] = useState(false);
const [result, setResult] = useState(null);
const [opIp, setOpIp] = useState(opnsenseHost || "");
useEffect(() => { if (opnsenseHost && !opIp) setOpIp(opnsenseHost); }, [opnsenseHost]);
const relay = overview?.relay || {};
const relayMap = relay.vlans || {}; // { "10": "192.168.99.1", ... }
const relayVlans = [10, 20, 30, 40, 50];
const push = async () => {
if (!session) { onNeedAuth(); return; }
if (!opIp) return;
setPushing(true); setResult(null);
try {
const r = await API("/dhcp/relay/configure", {
method:"POST", body:{ token:session.token, opnsense_ip:opIp, vlans:relayVlans }
});
setResult(r);
onRefresh();
} catch(e) { setResult({ success:false, error:e.message }); }
setPushing(false);
};
const allConfigured = relayVlans.every(v => relayMap[v] === opIp && opIp);
return (
<div className="panel">
<div className="ph"> DHCP Relay Switch OPNsense</div>
<div className="pb">
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:14}}>
The switch relays DHCP requests from each VLAN to OPNsense, which assigns
IPs, records leases, and registers hostnames in Unbound DNS automatically.
VLAN 99 always stays local it's the recovery path if OPNsense is unreachable.
</div>
{/* Per-VLAN status grid */}
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(200px,1fr))",gap:8,marginBottom:14}}>
{/* VLAN 99 — always local */}
<div style={{background:"var(--bg)",border:"1px solid var(--b2)",borderRadius:4,padding:"10px 12px"}}>
<div style={{display:"flex",alignItems:"center",gap:6,marginBottom:4}}>
<VlanBadge vlan={99}/>
<span style={{fontSize:10,marginLeft:"auto",color:"var(--dm)"}}>🔒 local</span>
</div>
<div style={{fontSize:10,color:"var(--dm)",lineHeight:1.5}}>{VLAN_MAP[99].note}</div>
</div>
{relayVlans.map(vid => {
const configured = relayMap[vid];
const isOk = configured && configured === opIp;
const stale = configured && configured !== opIp;
return (
<div key={vid} style={{background:"var(--bg)",border:`1px solid ${isOk?"rgba(0,230,118,.3)":stale?"rgba(255,234,0,.3)":"var(--b2)"}`,borderRadius:4,padding:"10px 12px"}}>
<div style={{display:"flex",alignItems:"center",gap:6,marginBottom:4}}>
<VlanBadge vlan={vid}/>
<span style={{fontSize:10,marginLeft:"auto",
color: isOk?"var(--ok)":stale?"var(--warn)":"var(--dm)"}}>
{isOk ? `→ ${configured}` : stale ? `→ ${configured} ⚠` : "not set"}
</span>
</div>
<div style={{fontSize:10,color:"var(--dm)",lineHeight:1.5}}>{VLAN_MAP[vid]?.note}</div>
</div>
);
})}
</div>
{/* Push controls */}
<div style={{display:"flex",gap:8,alignItems:"flex-end",flexWrap:"wrap"}}>
<div className="field" style={{margin:0}}>
<label>OPNsense IP</label>
<input value={opIp} onChange={e=>setOpIp(e.target.value)}
placeholder="192.168.99.1"
style={{fontFamily:"var(--mono)",maxWidth:160}}/>
</div>
<button className="btn bp" onClick={push}
disabled={pushing||!opIp||!session}
style={{fontSize:11}}>
{pushing ? "Pushing…" : allConfigured ? "✓ Re-apply Relay Config" : "Push Relay Config to Switch"}
</button>
</div>
{result && (
<div style={{marginTop:8,fontSize:11,
color:result.success?"var(--ok)":"var(--err)"}}>
{result.success ? "✓ Relay config pushed — save running-config to make permanent" : `✗ ${result.error||"Push failed"}`}
</div>
)}
</div>
</div>
);
}
function ConflictBadge({ conflict }) {
return (
<div style={{
background: conflict.ip_conflict
? "rgba(255,23,68,.1)" : "rgba(255,234,0,.08)",
border: `1px solid ${conflict.ip_conflict ? "rgba(255,23,68,.3)" : "rgba(255,234,0,.25)"}`,
borderRadius: 4, padding: "8px 12px", marginBottom: 8,
}}>
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:4}}>
<span style={{
color: conflict.ip_conflict ? "var(--err)" : "var(--warn)",
fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: 1
}}>
{conflict.ip_conflict ? "✗ IP Conflict" : "⚠ Duplicate Entry"}
</span>
<span style={{fontFamily:"var(--mono)",fontSize:11,color:"var(--dm)"}}>{conflict.mac}</span>
{conflict.hostname && <span style={{fontSize:11,color:"var(--tx)"}}>{conflict.hostname}</span>}
</div>
<div style={{fontSize:11,color:"var(--dm)",fontFamily:"var(--mono)",marginBottom:8}}>
Switch: <span style={{color:"var(--ac)"}}>{conflict.switch_ip}</span>
<span style={{margin:"0 8px"}}>·</span>
OPNsense: <span style={{color:"var(--ac)"}}>{conflict.opnsense_ip}</span>
</div>
{conflict.ip_conflict
? <div style={{fontSize:11,color:"var(--err)",marginBottom:8}}>
Same device has different IPs on switch and OPNsense. One will win — decide which is correct.
</div>
: <div style={{fontSize:11,color:"var(--warn)",marginBottom:8}}>
Same reservation exists in both places. Not harmful but messy — consider removing one.
</div>
}
</div>
);
}
function OPNsenseSetup({ onConfigured }) {
const [detecting, setDetecting] = useState(false);
const [detected, setDetected] = useState(null);
const [host, setHost] = useState('');
const [key, setKey] = useState('');
const [secret, setSecret] = useState('');
const [testing, setTesting] = useState(false);
const [error, setError] = useState('');
const detect = async () => {
setDetecting(true); setError('');
try {
const r = await API("/dhcp/detect-opnsense");
setDetected(r);
if (r.detected) setHost(r.host);
} catch(e) { setError(e.message); }
setDetecting(false);
};
const connect = async () => {
if (!host || !key || !secret) return;
setTesting(true); setError('');
try {
const r = await API("/dhcp/configure-opnsense", {
method: "POST", body: { host, key, secret }
});
onConfigured(r);
} catch(e) { setError(e.message); }
setTesting(false);
};
return (
<div style={{background:"var(--bg)",border:"1px solid var(--b2)",borderRadius:5,padding:14}}>
<div style={{fontSize:12,fontWeight:700,marginBottom:8,color:"var(--ac)"}}>Connect OPNsense</div>
<div style={{fontSize:11,color:"var(--dm)",lineHeight:1.7,marginBottom:12}}>
Optional — provides a unified view of all DHCP reservations across your network.
The switch manager will show reservations from both the switch and OPNsense side-by-side,
flag conflicts, and let you sync between them.<br/><br/>
In OPNsense: System → Access → Users → your user → API keys → Create key
</div>
<div style={{display:"flex",gap:8,marginBottom:10}}>
<button className="btn bg" onClick={detect} disabled={detecting} style={{fontSize:11}}>
{detecting ? "Detecting..." : "Auto-detect OPNsense"}
</button>
{detected && !detected.detected && (
<span style={{fontSize:11,color:"var(--dm)",alignSelf:"center"}}>
Not found at {detected.gateway}
</span>
)}
{detected?.detected && (
<span style={{fontSize:11,color:"var(--ok)",alignSelf:"center"}}>
✓ Found at {detected.host}
</span>
)}
</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gap:8,marginBottom:10}}>
<div className="field" style={{margin:0}}>
<label>OPNsense IP</label>
<input value={host} onChange={e=>setHost(e.target.value)} placeholder="192.168.99.1"
style={{fontFamily:"var(--mono)"}}/>
</div>
<div className="field" style={{margin:0}}>
<label>API Key</label>
<input value={key} onChange={e=>setKey(e.target.value)} placeholder="key"
style={{fontFamily:"var(--mono)"}}/>
</div>
<div className="field" style={{margin:0}}>
<label>API Secret</label>
<input type="password" value={secret} onChange={e=>setSecret(e.target.value)}
placeholder="secret"/>
</div>
</div>
{error && <div style={{color:"var(--err)",fontSize:11,marginBottom:8}}>{error}</div>}
<button className="btn bp" onClick={connect} disabled={!host||!key||!secret||testing}>
{testing ? "Testing..." : "Connect & Save"}
</button>
</div>
);
}
function DHCPRow({ res, source, onSync, session, onNeedAuth }) {
const [syncing, setSyncing] = useState(false);
const doSync = async (direction) => {
if (!session) { onNeedAuth(); return; }
setSyncing(true);
try {
await onSync(res.mac, direction);
} finally { setSyncing(false); }
};
const sourceColor = source === "switch" ? "var(--ac)" : "var(--warn)";
const sourceName = source === "switch" ? "Switch" : "OPNsense";
// Derive VLAN: prefer explicit vlan field, fall back to IP third octet
const vid = res.vlan || vlanFromIp(res.ip);
const purpose = res.descr || res.notes || VLAN_MAP[vid]?.note || "";
return (
<tr style={{borderBottom:"1px solid var(--b1)"}}>
<td style={{padding:"7px 10px",fontFamily:"var(--mono)",fontSize:11,color:"var(--dm)"}}>
{res.mac}
</td>
<td style={{padding:"7px 10px",fontFamily:"var(--mono)",fontSize:11,color:"var(--ac)"}}>
{res.ip}
</td>
<td style={{padding:"7px 10px",fontSize:12}}>
{res.hostname || res.name || <span style={{color:"var(--dm)"}}>—</span>}
{purpose && <div style={{fontSize:10,color:"var(--dm)",marginTop:2}}>{purpose}</div>}
</td>
<td style={{padding:"7px 10px"}}>
{vid ? <VlanBadge vlan={vid}/> : <span style={{color:"var(--dm)",fontSize:10}}>—</span>}
</td>
<td style={{padding:"7px 10px"}}>
<span style={{
background: `${sourceColor}18`, color: sourceColor,
borderRadius: 10, padding: "1px 7px", fontSize: 10,
fontFamily: "var(--mono)", fontWeight: 700
}}>{sourceName}</span>
</td>
<td style={{padding:"7px 10px"}}>
<div style={{display:"flex",gap:4}}>
{source === "switch" && (
<button className="btn bg" style={{fontSize:10,padding:"2px 7px"}} disabled={syncing}
onClick={() => doSync("to_opnsense")}>→ OPNsense</button>
)}
{source === "opnsense" && (
<button className="btn bg" style={{fontSize:10,padding:"2px 7px"}} disabled={syncing}
onClick={() => doSync("to_switch")}>→ Switch</button>
)}
<button className="btn bd" style={{fontSize:10,padding:"2px 7px"}} disabled={syncing}
onClick={() => doSync(source === "switch" ? "remove_switch" : "remove_opnsense")}>
Remove
</button>
</div>
</td>
</tr>
);
}
function DHCPTab({ session, onNeedAuth, backendOk }) {
const [overview, setOverview] = useState(null);
const [loading, setLoading] = useState(false);
const [showSetup, setShowSetup] = useState(false);
const [syncResult, setSyncResult] = useState(null);
const [showConflictSync, setShowConflictSync] = useState(null); // conflict obj
const load = async () => {
setLoading(true);
try { setOverview(await API("/dhcp/overview")); }
catch(e) { console.error(e); }
setLoading(false);
};
useEffect(() => { if (backendOk) load(); }, [backendOk]);
const handleSync = async (mac, direction) => {
if (!session) { onNeedAuth(); return; }
try {
const r = await API("/dhcp/sync", { method:"POST", body:{ token:session.token, mac, direction } });
setSyncResult({ success: true, mac, direction });
await load();
} catch(e) {
setSyncResult({ success: false, error: e.message });
}
};
const disconnectOPNsense = async () => {
if (!confirm("Remove OPNsense connection?")) return;
await API("/dhcp/configure-opnsense", { method:"DELETE" });
await load();
};
const sw = overview?.switch;
const ops = overview?.opnsense;
const conflicts = overview?.conflicts || [];
// Merge all reservations for the unified table
const allRes = [
...(sw?.reservations||[]).map(r=>({...r,source:"switch"})),
...(ops?.reservations||[]).map(r=>({...r,source:"opnsense"})),
];
// Find MACs that appear in both (for conflict highlighting)
const conflictMacs = new Set(conflicts.map(c=>c.mac));
return (
<div className="main" style={{flexDirection:"column",gap:12}}>
{/* Header + status */}
<div className="panel">
<div className="ph">◈ DHCP Management
<button className="btn bg" style={{marginLeft:"auto",fontSize:10,padding:"3px 8px"}}
onClick={load}>↻ Refresh</button>
</div>
<div className="pb">
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:14}}>
DHCP reservations tie a MAC address to a permanent IP so device access rules
stay stable. Setting them in two places causes confusion — this panel shows
everything in one view and helps you keep it consistent.
</div>
{/* DHCP server status */}
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:10,marginBottom:14}}>
<div style={{background:"var(--bg)",border:"1px solid var(--b2)",borderRadius:4,padding:"10px 12px"}}>
<div style={{fontSize:10,fontWeight:700,letterSpacing:2,textTransform:"uppercase",
color:"var(--dm)",marginBottom:6}}>Switch DHCP</div>
<div style={{display:"flex",alignItems:"center",gap:6}}>
<span style={{width:8,height:8,borderRadius:"50%",
background:sw?.status?.running?"var(--ok)":"var(--dm)"}}/>
<span style={{fontSize:12}}>
{sw?.status?.running
? `Active — VLAN${sw.status.vlans?.length>1?"s":""} ${sw.status.vlans?.join(", ")||"?"}`
: "Not running"}
</span>
</div>
{sw?.status?.running && (
<div style={{fontSize:10,color:"var(--dm)",marginTop:4}}>
Use for VLAN 99 management — devices get IPs before OPNsense is reachable
</div>
)}
</div>
<div style={{background:"var(--bg)",border:"1px solid var(--b2)",borderRadius:4,padding:"10px 12px"}}>
<div style={{fontSize:10,fontWeight:700,letterSpacing:2,textTransform:"uppercase",
color:"var(--dm)",marginBottom:6}}>OPNsense DHCP</div>
<div style={{display:"flex",alignItems:"center",gap:6}}>
<span style={{width:8,height:8,borderRadius:"50%",
background:ops?.configured?"var(--ok)":"var(--dm)"}}/>
<span style={{fontSize:12}}>
{ops?.configured ? `Connected — ${ops.host}` : "Not connected"}
</span>
</div>
<div style={{marginTop:6,display:"flex",gap:6}}>
{!ops?.configured && (
<button className="btn bg" style={{fontSize:10,padding:"2px 8px"}}
onClick={()=>setShowSetup(s=>!s)}>
{showSetup?"Hide":"Connect OPNsense"}
</button>
)}
{ops?.configured && (
<button className="btn bg" style={{fontSize:10,padding:"2px 8px"}}
onClick={disconnectOPNsense}>Disconnect</button>
)}
</div>
</div>
</div>
{showSetup && (
<OPNsenseSetup onConfigured={() => { setShowSetup(false); load(); }}/>
)}
{/* Recommendation */}
<div style={{background:"rgba(0,229,255,.05)",border:"1px solid rgba(0,229,255,.15)",
borderRadius:4,padding:"10px 14px",fontSize:11,color:"var(--dm)",lineHeight:1.7}}>
<span style={{color:"var(--ac)",fontWeight:700}}>Recommendation: </span>
Use switch DHCP for VLAN 99 (management) only.
Let OPNsense handle DHCP for all other VLANs — it integrates with DNS,
firewall rules, and shows everything in one place.
Never run both for the same VLAN.
</div>
</div>
</div>
{/* Relay configuration */}
<RelayPanel
overview={overview}
opnsenseHost={ops?.host||""}
session={session}
onNeedAuth={onNeedAuth}
onRefresh={load}
/>
{/* Conflicts */}
{conflicts.length > 0 && (
<div className="panel">
<div className="ph" style={{color:"var(--err)"}}>
✗ Conflicts Detected ({conflicts.length})
</div>
<div className="pb">
<div style={{fontSize:11,color:"var(--dm)",marginBottom:12}}>
The same MAC address has reservations in both the switch and OPNsense.
Decide which one is correct and remove the other.
</div>
{conflicts.map(c => (
<div key={c.mac}>
<ConflictBadge conflict={c}/>
<div style={{display:"flex",gap:6,marginBottom:12,flexWrap:"wrap"}}>
<button className="btn bw" style={{fontSize:11}}
onClick={()=>setShowConflictSync(showConflictSync?.mac===c.mac?null:c)}>
{showConflictSync?.mac===c.mac ? "Hide options" : "Resolve →"}
</button>
</div>
{showConflictSync?.mac === c.mac && (
<div style={{background:"var(--bg)",border:"1px solid var(--b2)",
borderRadius:4,padding:"10px 14px",marginBottom:12}}>
<div style={{fontSize:11,fontWeight:700,marginBottom:8}}>Choose which IP wins:</div>
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
<button className="btn bs" style={{fontSize:11}}
onClick={()=>{handleSync(c.mac,"to_opnsense");setShowConflictSync(null);}}>
Switch wins ({c.switch_ip}) — update OPNsense
</button>
<button className="btn bs" style={{fontSize:11}}
onClick={()=>{handleSync(c.mac,"to_switch");setShowConflictSync(null);}}>
OPNsense wins ({c.opnsense_ip}) — update switch
</button>
<button className="btn bd" style={{fontSize:11}}
onClick={()=>{handleSync(c.mac,"remove_switch");setShowConflictSync(null);}}>
Remove from switch only
</button>
<button className="btn bd" style={{fontSize:11}}
onClick={()=>{handleSync(c.mac,"remove_opnsense");setShowConflictSync(null);}}>
Remove from OPNsense only
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Sync result */}
{syncResult && (
<div className="panel">
<div className="ph" style={{color:syncResult.success?"var(--ok)":"var(--err)"}}>
{syncResult.success ? "✓ Sync complete" : `✗ Sync failed: ${syncResult.error}`}
<button className="btn bg" style={{marginLeft:"auto",fontSize:10,padding:"2px 8px"}}
onClick={()=>setSyncResult(null)}>✕</button>
</div>
</div>
)}
{/* Unified reservations table */}
<div className="panel">
<div className="ph">◈ All Reservations ({allRes.length})</div>
<div className="pb" style={{padding:0}}>
{allRes.length === 0 && (
<div className="empty">No reservations found. Add devices in the Device Access tab.</div>
)}
{allRes.length > 0 && (
<table style={{width:"100%",borderCollapse:"collapse"}}>
<thead>
<tr style={{borderBottom:"1px solid var(--b1)"}}>
{["MAC","IP","Name / Description","VLAN","Source","Actions"].map(h=>(
<th key={h} style={{textAlign:"left",padding:"6px 10px",fontSize:10,
letterSpacing:2,textTransform:"uppercase",color:"var(--dm)"}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{allRes.map((r,i) => (
<tr key={`${r.mac}-${r.source}`}
style={{
background: conflictMacs.has(r.mac) ? "rgba(255,234,0,.03)" : "transparent",
borderLeft: conflictMacs.has(r.mac) ? "2px solid var(--warn)" : "2px solid transparent",
}}>
<DHCPRow
res={r} source={r.source}
onSync={handleSync}
session={session} onNeedAuth={onNeedAuth}
/>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Active leases */}
{((sw?.leases||[]).length > 0 || (ops?.leases||[]).length > 0) && (
<div className="panel">
<div className="ph">◈ Active Leases
<span style={{marginLeft:6,fontSize:10,color:"var(--dm)"}}>
({(sw?.leases||[]).length + (ops?.leases||[]).length} total)
</span>
</div>
<div className="pb" style={{padding:0}}>
<table style={{width:"100%",borderCollapse:"collapse"}}>
<thead>
<tr style={{borderBottom:"1px solid var(--b1)"}}>
{["MAC","IP","Hostname","Source"].map(h=>(
<th key={h} style={{textAlign:"left",padding:"6px 10px",fontSize:10,
letterSpacing:2,textTransform:"uppercase",color:"var(--dm)"}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{[...(sw?.leases||[]).map(l=>({...l,source:"switch"})),
...(ops?.leases||[]).map(l=>({...l,source:"opnsense"}))
].map((l,i)=>(
<tr key={i} style={{borderBottom:"1px solid var(--b1)"}}>
<td style={{padding:"6px 10px",fontFamily:"var(--mono)",fontSize:11,color:"var(--dm)"}}>{l.mac}</td>
<td style={{padding:"6px 10px",fontFamily:"var(--mono)",fontSize:11,color:"var(--ac)"}}>{l.ip}</td>
<td style={{padding:"6px 10px",fontSize:11}}>{l.hostname||<span style={{color:"var(--dm)"}}>—</span>}</td>
<td style={{padding:"6px 10px"}}>
<span style={{
background:l.source==="switch"?"rgba(0,229,255,.12)":"rgba(255,234,0,.12)",
color:l.source==="switch"?"var(--ac)":"var(--warn)",
borderRadius:10,padding:"1px 7px",fontSize:10,fontFamily:"var(--mono)",fontWeight:700
}}>{l.source==="switch"?"Switch":"OPNsense"}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// LOCAL HOSTNAME PANEL — dnsmasq .lan resolution
// ══════════════════════════════════════════════════════════════════════════════
function LocalHostnamesPanel({ session, onNeedAuth }) {
const [entries, setEntries] = useState([]); // [{ name, ip }]
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [result, setResult] = useState(null);
const [showConf, setShowConf] = useState(false);
const [localDomain, setLocalDomain] = useState("lan");
const load = async () => {
try {
const r = await API("/dns/local-hostnames");
setEntries(r.entries || []);
setLoaded(true);
} catch(e) { setLoaded(true); }
};
useEffect(() => { load(); }, []);
const addEntry = () => setEntries(prev => [...prev, { name:"", ip:"" }]);
const upEntry = (i, k, v) => setEntries(prev => prev.map((e,idx) => idx===i?{...e,[k]:v}:e));
const delEntry = i => setEntries(prev => prev.filter((_,idx)=>idx!==i));
const save = async () => {
if (!session) { onNeedAuth(); return; }
setSaving(true); setResult(null);
try {
const r = await API("/dns/local-hostnames", {
method:"POST",
body: { token:session.token, entries, local_domain:localDomain }
});
setResult(r);
} catch(e) {
setResult({ success:false, message:e.message });
}
setSaving(false);
};
return (
<div className="panel">
<div className="ph">◈ Local Hostnames (.lan resolution)
<span style={{marginLeft:"auto",fontSize:10,color:"var(--dm)"}}>
Optional — needs dnsmasq Docker service
</span>
</div>
<div className="pb">
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.8,marginBottom:12}}>
Add a <strong>dnsmasq</strong> container to resolve <code>.lan</code> hostnames
for all devices. ctrld forwards <code>*.lan</code> queries to dnsmasq on port 5353;
all other queries go through Control D as normal.
<br/>
<code style={{fontFamily:"var(--mono)",color:"var(--ac)",fontSize:11}}>
switch.mgmt.lan
</code>{" "}and{" "}
<code style={{fontFamily:"var(--mono)",color:"var(--ac)",fontSize:11}}>
management.lan
</code>{" "}
always resolve to the management computer's IP.
</div>
<div style={{display:"flex",gap:10,marginBottom:12,alignItems:"flex-end"}}>
<div className="field" style={{margin:0}}>
<label>Local domain suffix</label>
<input value={localDomain} onChange={e=>setLocalDomain(e.target.value)}
placeholder="lan"
style={{fontFamily:"var(--mono)",maxWidth:120}}/>
</div>
<div style={{fontSize:10,color:"var(--dm)",paddingBottom:4}}>
Queries for <code>*.{localDomain}</code> and <code>*.local</code> are
forwarded to dnsmasq (port 5353).
</div>
</div>
{/* Hostname table */}
{entries.length > 0 && (
<table style={{width:"100%",borderCollapse:"collapse",marginBottom:10}}>
<thead>
<tr style={{borderBottom:"1px solid var(--b1)"}}>
{["Hostname","IP Address",""].map(h=>(
<th key={h} style={{textAlign:"left",padding:"4px 8px",fontSize:10,
letterSpacing:2,textTransform:"uppercase",color:"var(--dm)"}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{entries.map((e,i)=>(
<tr key={i} style={{borderBottom:"1px solid var(--b1)"}}>
<td style={{padding:"4px 8px"}}>
<input value={e.name} onChange={ev=>upEntry(i,"name",ev.target.value)}
placeholder={`printer.${localDomain}`}
style={{
width:"100%",background:"var(--bg)",border:"1px solid var(--b2)",
color:"var(--tx)",padding:"3px 6px",fontFamily:"var(--mono)",
fontSize:11,borderRadius:3,
}}/>
</td>
<td style={{padding:"4px 8px"}}>
<input value={e.ip} onChange={ev=>upEntry(i,"ip",ev.target.value)}
placeholder="192.168.10.50"
style={{
width:"100%",background:"var(--bg)",border:"1px solid var(--b2)",
color:"var(--tx)",padding:"3px 6px",fontFamily:"var(--mono)",
fontSize:11,borderRadius:3,
}}/>
</td>
<td style={{padding:"4px 8px"}}>
<button className="btn bd" style={{padding:"2px 6px",fontSize:10}}
onClick={()=>delEntry(i)}></button>
</td>
</tr>
))}
</tbody>
</table>
)}
<div style={{display:"flex",gap:8,flexWrap:"wrap",marginBottom: result?12:0}}>
<button className="btn bg" style={{fontSize:10}} onClick={addEntry}>+ Add Hostname</button>
<button className="btn bp" style={{fontSize:10}}
onClick={save} disabled={saving||!session}>
{saving?"Saving...":"Save & Generate dnsmasq.conf"}
</button>
{!session && (
<button className="btn bg" style={{fontSize:10}} onClick={onNeedAuth}>Authenticate</button>
)}
</div>
{/* Result */}
{result && result.success && (
<div style={{marginTop:10}}>
<div style={{
padding:"8px 12px",borderRadius:4,marginBottom:10,fontSize:11,
background:"rgba(0,230,118,.06)",border:"1px solid rgba(0,230,118,.2)",
color:"var(--dm)",lineHeight:1.7,
}}>
<span style={{color:"var(--ok)",fontWeight:700}}> </span>
{result.message}
</div>
{/* dnsmasq.conf */}
<div style={{marginBottom:10}}>
<div style={{display:"flex",gap:6,marginBottom:6,alignItems:"center"}}>
<span style={{fontSize:11,color:"var(--dm)"}}>
dnsmasq.conf written to{" "}
<code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>{result.conf_path}</code>
</span>
<button className="btn bg" style={{fontSize:10,padding:"1px 7px"}}
onClick={()=>setShowConf(s=>!s)}>{showConf?"Hide":"Show"} config</button>
<button className="btn bg" style={{fontSize:10,padding:"1px 7px"}}
onClick={()=>navigator.clipboard?.writeText(result.dnsmasq_conf)}>Copy</button>
</div>
{showConf && (
<div style={{
background:"#060809",borderRadius:4,padding:"10px 12px",
fontFamily:"var(--mono)",fontSize:10,color:"#a0b0c0",
whiteSpace:"pre",overflowX:"auto",maxHeight:180,overflowY:"auto",
}}>{result.dnsmasq_conf}</div>
)}
</div>
{/* docker-compose snippet */}
<div style={{marginBottom:10}}>
<div style={{fontSize:11,color:"var(--dm)",marginBottom:4}}>
Add to docker-compose.yml then run:{" "}
<code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>docker compose up -d dnsmasq</code>
</div>
<div style={{
background:"#060809",borderRadius:4,padding:"10px 12px",
fontFamily:"var(--mono)",fontSize:10,color:"#a0b0c0",
whiteSpace:"pre",overflowX:"auto",
}}>{result.docker_compose_snippet}</div>
<button className="btn bg" style={{marginTop:6,fontSize:10}}
onClick={()=>navigator.clipboard?.writeText(result.docker_compose_snippet)}>
Copy compose snippet
</button>
</div>
{/* ctrld.toml split-horizon block */}
<div>
<div style={{fontSize:11,color:"var(--dm)",marginBottom:4}}>
Append to ctrld.toml (before the fallback upstream):
</div>
<div style={{
background:"#060809",borderRadius:4,padding:"10px 12px",
fontFamily:"var(--mono)",fontSize:10,color:"#a0b0c0",
whiteSpace:"pre",overflowX:"auto",
}}>{result.split_horizon}</div>
<button className="btn bg" style={{marginTop:6,fontSize:10}}
onClick={()=>navigator.clipboard?.writeText(result.split_horizon)}>
Copy toml block
</button>
</div>
</div>
)}
{result && !result.success && (
<div style={{
marginTop:10,padding:"8px 12px",borderRadius:4,fontSize:11,
background:"rgba(255,100,100,.06)",border:"1px solid rgba(255,100,100,.2)",
color:"var(--err)",
}}> {result.message}</div>
)}
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// DNS FILTERING TAB — Control D / ctrld
// ══════════════════════════════════════════════════════════════════════════════
const CTRLD_MODES = {
local: {
label: "Option A — ctrld on this management computer",
color: "var(--ok)",
badge: "Fully automated",
description: `Installs the ctrld daemon directly on this machine alongside the
switch manager. One command downloads and installs it, writes a
per-VLAN config, and starts it as a system service.
How it works:
• ctrld listens on port 53 on this machine's IP
• The switch DHCP hands out this machine's IP as DNS for each VLAN
• ctrld sees the source VLAN subnet and routes to the right profile
• Each VLAN gets filtered by its own Control D profile via DoH3
Port 53 note: On Ubuntu/Debian, systemd-resolved holds port 53.
The installer automatically disables its stub listener (DNSStubListener=no)
before starting ctrld. The systemd-resolved service itself stays running.
Best for: most setups. Self-contained, no OPNsense required.
Requires: a Resolver ID per VLAN from your Control D dashboard.`,
docsUrl: "https://docs.controld.com/docs/ctrld",
},
opnsense: {
label: "Option B — ctrld on OPNsense",
color: "var(--warn)",
badge: "Semi-automated",
description: `Installs ctrld on your OPNsense router instead of this machine.
The setup generates a single SSH command you paste into OPNsense shell.
OPNsense then becomes the DNS resolver for your network.
How it works:
• You run one command in OPNsense shell (via SSH or console)
• ctrld installs as a service on OPNsense
• OPNsense's IP becomes the DNS server for each VLAN
• Per-VLAN routing uses source IP matching in ctrld config
Unbound conflict: OPNsense runs Unbound on port 53. The correct fix is:
1. Move Unbound to listen on 127.0.0.1:5353 (keep it for .lan names)
2. Run ctrld on port 53
3. Tell ctrld to forward *.lan / *.local to 127.0.0.1:5353
Instructions are shown in the result panel after generating the command.
Best for: setups where OPNsense is already the DNS server, or where
you want DNS handled at the router rather than the switch manager machine.
Requires: SSH access to OPNsense and a Resolver ID per VLAN.`,
docsUrl: "https://docs.controld.com/docs/routers-platform",
},
manual: {
label: "Option C — manual / existing setup",
color: "var(--dm)",
badge: "Config generated",
description: `Generates the ctrld.toml config file and install command for you
to apply manually wherever you choose to run ctrld.
This option is for:
• Running ctrld on a separate dedicated machine
• Integrating with an existing DNS setup
• Using dnscrypt-proxy or another DoH3 proxy instead of ctrld
• Advanced users who want full control
The site generates the correct ctrld.toml and DHCP option 6 values.
You install and configure ctrld yourself.
Install command (any Linux/Mac/OPNsense):
sh -c 'sh -c "$(curl -sL https://api.controld.com/dl)" -s RESOLVER_ID forced'`,
docsUrl: "https://docs.controld.com/docs/ctrld",
},
};
function CtrldVlanRow({ vlan, profile, onChange }) {
return (
<tr style={{borderBottom:"1px solid var(--b1)"}}>
<td style={{padding:"8px 10px"}}>
<div style={{display:"flex",alignItems:"center",gap:8}}>
<span style={{width:10,height:10,borderRadius:2,background:vlan.color,flexShrink:0}}/>
<span style={{fontWeight:600}}>VLAN {vlan.id}</span>
<span style={{color:"var(--dm)",fontSize:11}}>{vlan.name}</span>
</div>
</td>
<td style={{padding:"8px 10px",fontFamily:"var(--mono)",fontSize:11,color:"var(--dm)"}}>
192.168.{vlan.id}.0/24
</td>
<td style={{padding:"8px 10px"}}>
<input
value={profile?.resolver_id || ""}
onChange={e => onChange(vlan.id, "resolver_id", e.target.value)}
placeholder="Resolver ID from Control D dashboard"
style={{
width:"100%", background:"var(--bg)", border:"1px solid var(--b2)",
color:"var(--tx)", padding:"4px 8px", fontFamily:"var(--mono)",
fontSize:11, borderRadius:3,
}}
/>
</td>
<td style={{padding:"8px 10px"}}>
{profile?.resolver_id
? <span style={{color:"var(--ok)",fontSize:11}}> configured</span>
: <span style={{color:"var(--dm)",fontSize:11}}>not set will use fallback</span>
}
</td>
</tr>
);
}
function DNSTab({ vlans, session, onNeedAuth, backendOk, acls, setAcls }) {
const [status, setStatus] = useState(null);
const [mode, setMode] = useState(null);
const [profiles, setProfiles] = useState({});
const [opnsenseHost, setOpnsenseHost] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [result, setResult] = useState(null);
const [showToml, setShowToml] = useState(false);
// DNS enforcement state
const [enforceIp, setEnforceIp] = useState("");
const [enforceLoading, setEnforceLoading] = useState(false);
const [enforceResult, setEnforceResult] = useState(null);
// Local domain split-horizon state
const [localDomain, setLocalDomain] = useState("lan");
const [showLocalDomain, setShowLocalDomain] = useState(false);
const load = async () => {
setLoading(true);
try {
const s = await API("/ctrld/status");
setStatus(s);
if (s.mode) setMode(s.mode);
if (s.vlan_profiles?.length) {
const p = {};
s.vlan_profiles.forEach(vp => { p[vp.vlan_id] = vp; });
setProfiles(p);
}
} catch(e) { console.error(e); }
setLoading(false);
};
useEffect(() => { if (backendOk) load(); }, [backendOk]);
// Initialise profiles from VLANs
useEffect(() => {
if (vlans.length && Object.keys(profiles).length === 0) {
const p = {};
vlans.forEach(v => {
p[v.id] = {
vlan_id: v.id,
name: v.name,
subnet: `192.168.${v.id}.0/24`,
resolver_id: "",
};
});
setProfiles(p);
}
}, [vlans]);
const updateProfile = (vlanId, field, value) => {
setProfiles(prev => ({
...prev,
[vlanId]: {
...prev[vlanId],
vlan_id: vlanId,
name: vlans.find(v=>v.id===vlanId)?.name || `VLAN ${vlanId}`,
subnet: `192.168.${vlanId}.0/24`,
[field]: value,
}
}));
};
const save = async () => {
if (!session) { onNeedAuth(); return; }
if (!mode) { alert("Choose an option first"); return; }
setSaving(true); setResult(null);
try {
const vlan_profiles = Object.values(profiles).filter(p => p.resolver_id);
const r = await API("/ctrld/save-config", {
method: "POST",
body: {
token: session.token,
config: { mode, vlan_profiles, opnsense_host: opnsenseHost },
}
});
setResult(r);
await load();
} catch(e) {
setResult({ success: false, message: e.message });
}
setSaving(false);
};
const updateProfiles = async () => {
if (!session) { onNeedAuth(); return; }
setSaving(true);
try {
const vlan_profiles = Object.values(profiles).filter(p => p.resolver_id);
const r = await API("/ctrld/update-profiles", {
method: "POST",
body: { token: session.token, vlan_profiles }
});
setResult(r);
} catch(e) {
setResult({ success: false, message: e.message });
}
setSaving(false);
};
// Generate DNS enforcement ACLs for all non-management VLANs
const generateEnforceAcls = async () => {
if (!session) { onNeedAuth(); return; }
const ip = enforceIp || status?.dns_ip;
if (!ip) { alert("Enter the ctrld IP address first"); return; }
setEnforceLoading(true); setEnforceResult(null);
try {
const vlan_ids = vlans.filter(v => v.id !== 99).map(v => v.id);
const r = await API("/ctrld/dns-enforce-acls", {
method: "POST",
body: { token: session.token, ctrld_ip: ip, vlan_ids }
});
setEnforceResult(r);
// Pre-load commands into Review & Push by storing as a special ACL marker
// (user will copy them to the Review & Push tab)
} catch(e) {
setEnforceResult({ success: false, message: e.message });
}
setEnforceLoading(false);
};
const configuredProfiles = Object.values(profiles).filter(p => p.resolver_id);
const isInstalled = status?.installed;
const isRunning = status?.running;
return (
<div className="main" style={{flexDirection:"column",gap:12}}>
{/* Status header */}
<div className="panel">
<div className="ph"> DNS Filtering Control D
<span style={{marginLeft:"auto",display:"flex",alignItems:"center",gap:6,
fontFamily:"var(--mono)",fontSize:10}}>
{isRunning
? <><span style={{width:7,height:7,borderRadius:"50%",background:"var(--ok)"}}/> ctrld running</>
: isInstalled
? <><span style={{width:7,height:7,borderRadius:"50%",background:"var(--warn)"}}/> ctrld installed, not running</>
: <><span style={{width:7,height:7,borderRadius:"50%",background:"var(--dm)"}}/> not configured</>
}
</span>
</div>
<div className="pb">
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.8,marginBottom:14}}>
Control D filters DNS queries per VLAN ads, malware, adult content,
social media, and more. Each VLAN gets its own profile with different rules.
The <code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>ctrld</code> daemon
acts as a local DNS proxy, accepting plain DNS from devices and forwarding
upstream via DoH3 to Control D. Your switch DHCP points each VLAN to it.
<br/><br/>
<span style={{color:"var(--ac)"}}>
You need a Control D account and a Resolver ID per VLAN.
</span>
{" "}Get them at{" "}
<a href="https://controld.com" target="_blank"
style={{color:"var(--ac)"}}>controld.com</a>
{" "} Add Device Router copy the Resolver ID shown.
</div>
{/* Mode selection */}
{!isInstalled && (
<div style={{marginBottom:14}}>
<div className="sect">Choose how to run ctrld</div>
<div style={{display:"flex",flexDirection:"column",gap:8}}>
{Object.entries(CTRLD_MODES).map(([key, m]) => (
<div key={key}
onClick={() => setMode(key)}
style={{
background: mode===key ? "rgba(0,229,255,.05)" : "var(--bg)",
border: `1px solid ${mode===key ? "var(--ac)" : "var(--b2)"}`,
borderRadius: 5, padding:"12px 14px", cursor:"pointer",
transition:"all .15s",
}}>
<div style={{display:"flex",alignItems:"center",gap:10,marginBottom:6}}>
<div style={{width:16,height:16,borderRadius:"50%",border:`2px solid ${m.color}`,
background:mode===key?m.color:"transparent",flexShrink:0}}/>
<span style={{fontWeight:700,fontSize:12}}>{m.label}</span>
<span style={{
background:`${m.color}20`,color:m.color,
fontSize:10,fontFamily:"var(--mono)",fontWeight:700,
padding:"1px 7px",borderRadius:10,marginLeft:"auto"
}}>{m.badge}</span>
</div>
<pre style={{
fontFamily:"var(--sans)",fontSize:11,color:"var(--dm)",
lineHeight:1.7,margin:0,whiteSpace:"pre-wrap",
paddingLeft:26,
}}>{m.description}</pre>
<div style={{paddingLeft:26,marginTop:6}}>
<a href={m.docsUrl} target="_blank"
style={{fontSize:10,color:"var(--ac)"}}
onClick={e=>e.stopPropagation()}>
Documentation
</a>
</div>
</div>
))}
</div>
{mode === "opnsense" && (
<div className="field" style={{marginTop:12}}>
<label>OPNsense IP address</label>
<input value={opnsenseHost}
onChange={e=>setOpnsenseHost(e.target.value)}
placeholder="192.168.99.1"
style={{fontFamily:"var(--mono)",maxWidth:220}}/>
</div>
)}
</div>
)}
{/* Already installed — show current mode */}
{isInstalled && (
<div style={{
background:"rgba(0,230,118,.06)",border:"1px solid rgba(0,230,118,.2)",
borderRadius:4,padding:"10px 14px",marginBottom:14,
display:"flex",alignItems:"center",gap:10,
}}>
<span style={{color:"var(--ok)",fontSize:13}}></span>
<div style={{fontSize:12}}>
ctrld is installed <strong>{CTRLD_MODES[status?.mode||"local"]?.label || status?.mode}</strong>
<div style={{fontSize:10,color:"var(--dm)",fontFamily:"var(--mono)",marginTop:2}}>
{status?.output}
</div>
</div>
</div>
)}
</div>
</div>
{/* Per-VLAN Resolver IDs */}
<div className="panel">
<div className="ph"> Control D Resolver IDs per VLAN
<span style={{marginLeft:"auto",fontSize:10,color:"var(--dm)"}}>
From controld.com Add Device Router Resolver ID
</span>
</div>
<div className="pb" style={{padding:0}}>
<table style={{width:"100%",borderCollapse:"collapse"}}>
<thead>
<tr style={{borderBottom:"1px solid var(--b1)"}}>
{["VLAN","Subnet","Resolver ID","Status"].map(h=>(
<th key={h} style={{textAlign:"left",padding:"6px 10px",fontSize:10,
letterSpacing:2,textTransform:"uppercase",color:"var(--dm)"}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{vlans.map(v => (
<CtrldVlanRow
key={v.id}
vlan={v}
profile={profiles[v.id]}
onChange={updateProfile}
/>
))}
</tbody>
</table>
<div style={{padding:"12px 14px",borderTop:"1px solid var(--b1)",
display:"flex",gap:8,alignItems:"center",flexWrap:"wrap"}}>
<div style={{fontSize:11,color:"var(--dm)",flex:1}}>
{configuredProfiles.length} of {vlans.length} VLANs have a Resolver ID.
{configuredProfiles.length < vlans.length &&
" VLANs without a Resolver ID will use the first configured profile as fallback."}
</div>
{isInstalled
? <button className="btn bp" onClick={updateProfiles} disabled={saving||!session}>
{saving ? "Saving..." : "Update Profiles"}
</button>
: <button className="btn bp" onClick={save}
disabled={saving||!session||!mode||configuredProfiles.length===0}>
{saving ? "Installing..." : mode==="local" ? "Install & Start ctrld" :
mode==="opnsense" ? "Generate OPNsense Command" : "Save & Generate Config"}
</button>
}
{!session && (
<button className="btn bg" style={{fontSize:10}} onClick={onNeedAuth}>
Authenticate
</button>
)}
</div>
</div>
</div>
{/* Result panel */}
{result && (
<div className="panel">
<div className="ph" style={{color:result.success?"var(--ok)":"var(--err)"}}>
{result.success ? "✓ " : "✗ "}
{result.message}
<button className="btn bg" style={{marginLeft:"auto",fontSize:10,padding:"2px 8px"}}
onClick={()=>setResult(null)}></button>
</div>
{result.success && (
<div className="pb">
{/* Option A result */}
{result.mode === "local" && result.dns_ip && (
<div style={{fontSize:12,lineHeight:1.8}}>
<div style={{marginBottom:8}}>
<span style={{color:"var(--ac)",fontFamily:"var(--mono)"}}>
DNS IP for DHCP option 6: {result.dns_ip}
</span>
</div>
<div style={{color:"var(--dm)",marginBottom:12}}>{result.dhcp_action}</div>
<div style={{fontSize:11,color:"var(--dm)"}}>
Go to the <strong>DHCP tab</strong> select each VLAN pool
set DNS server to <code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>{result.dns_ip}</code>
</div>
</div>
)}
{/* Option B result */}
{result.mode === "opnsense" && (
<div style={{fontSize:12,lineHeight:1.9}}>
<div className="sect">Run this in OPNsense shell</div>
<div style={{
background:"#060809",borderRadius:4,padding:"10px 14px",
fontFamily:"var(--mono)",fontSize:12,color:"#a0b0c0",marginBottom:12,
overflowX:"auto",whiteSpace:"pre",
}}>{result.ssh_cmd || result.install_cmd}</div>
<div style={{color:"var(--dm)",fontSize:11,lineHeight:1.8}}>
<div>After install:</div>
<div>1. {result.step2}</div>
<div>2. {result.step3}</div>
<div>3. {result.step4}</div>
</div>
<button className="btn bg" style={{marginTop:10,fontSize:10}}
onClick={()=>navigator.clipboard?.writeText(result.ssh_cmd||result.install_cmd)}>
Copy Command
</button>
{/* OPNsense Unbound conflict resolution */}
<div style={{
marginTop:14,padding:"12px 14px",
background:"rgba(255,193,7,.06)",border:"1px solid rgba(255,193,7,.2)",
borderRadius:4,fontSize:11,lineHeight:1.8,
}}>
<div style={{fontWeight:700,color:"var(--warn)",marginBottom:6}}>
OPNsense Unbound conflict port 53
</div>
<div style={{color:"var(--dm)"}}>
OPNsense runs Unbound DNS on port 53. ctrld needs port 53.
The right fix is to keep Unbound running (it resolves <code>.lan</code> hostnames)
but move it to <code>127.0.0.1:5353</code>, then run ctrld on <code>:53</code>.
</div>
<div style={{marginTop:8,fontWeight:600}}>Steps in OPNsense UI:</div>
<ol style={{margin:"4px 0 0 18px",color:"var(--dm)"}}>
<li>Services Unbound DNS General change "Listen Port" to <code>5353</code>
and "Listen Interface" to <code>Loopback (lo0)</code>. Save + Apply.</li>
<li>Add a forwarding rule in ctrld.toml (shown in the TOML preview below)
to send <code>*.{localDomain}</code> and <code>*.local</code> to
<code>127.0.0.1:5353</code>.</li>
<li>Run <code>ctrld restart</code> on OPNsense after placing the new config.</li>
</ol>
<div style={{marginTop:8,display:"flex",alignItems:"center",gap:8}}>
<span style={{color:"var(--dm)"}}>Local domain:</span>
<input value={localDomain} onChange={e=>setLocalDomain(e.target.value)}
style={{
fontFamily:"var(--mono)",fontSize:11,maxWidth:120,
background:"var(--bg)",border:"1px solid var(--b2)",
color:"var(--tx)",padding:"2px 6px",borderRadius:3,
}}/>
<span style={{color:"var(--dm)",fontSize:10}}>
(default: lan queries for *.{localDomain} forwarded to Unbound)
</span>
</div>
</div>
</div>
)}
{/* Option C result */}
{result.mode === "manual" && (
<div style={{fontSize:12,lineHeight:1.8}}>
<div style={{color:"var(--dm)",marginBottom:10}}>
Install command (run on whichever machine will run ctrld):
</div>
<div style={{
background:"#060809",borderRadius:4,padding:"10px 14px",
fontFamily:"var(--mono)",fontSize:11,color:"#a0b0c0",marginBottom:12,
}}>{result.install_cmd}</div>
<div style={{color:"var(--dm)",marginBottom:6}}>
Then place the config below at: <code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>{result.config_path}</code>
</div>
</div>
)}
{/* TOML config — shown for B and C, toggleable for A */}
{result.toml && (
<div>
<button className="btn bg" style={{fontSize:10,marginBottom:8}}
onClick={()=>setShowToml(s=>!s)}>
{showToml ? "Hide" : "Show"} ctrld.toml config
</button>
<button className="btn bg" style={{fontSize:10,marginBottom:8,marginLeft:6}}
onClick={()=>navigator.clipboard?.writeText(result.toml)}>
Copy toml
</button>
{showToml && (
<div style={{
background:"#060809",borderRadius:4,padding:"12px 14px",
fontFamily:"var(--mono)",fontSize:11,color:"#a0b0c0",
whiteSpace:"pre",overflowX:"auto",maxHeight:300,overflowY:"auto",
}}>{result.toml}</div>
)}
</div>
)}
{/* Port 53 fix notification (Option A) */}
{result.mode === "local" && result.port53?.needed && (
<div style={{
marginTop:12,padding:"10px 14px",borderRadius:4,fontSize:11,lineHeight:1.7,
background: result.port53.fixed
? "rgba(0,230,118,.06)" : "rgba(255,100,100,.06)",
border: result.port53.fixed
? "1px solid rgba(0,230,118,.2)" : "1px solid rgba(255,100,100,.2)",
}}>
<span style={{fontWeight:700,color:result.port53.fixed?"var(--ok)":"var(--err)"}}>
{result.port53.fixed ? "✓ " : "✗ "}Port 53 conflict:{" "}
</span>
<span style={{color:"var(--dm)"}}>{result.port53.message}</span>
{!result.port53.fixed && (
<div style={{marginTop:6,color:"var(--dm)"}}>
Fix manually:{" "}
<code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>
echo "[Resolve]" | sudo tee -a /etc/systemd/resolved.conf
&amp;&amp; echo "DNSStubListener=no" | sudo tee -a /etc/systemd/resolved.conf
&amp;&amp; sudo systemctl restart systemd-resolved
</code>
</div>
)}
</div>
)}
{/* DHCP reminder */}
{result.success && (
<div style={{
marginTop:12,background:"rgba(0,229,255,.05)",
border:"1px solid rgba(0,229,255,.15)",borderRadius:4,
padding:"10px 14px",fontSize:11,color:"var(--dm)",lineHeight:1.7,
}}>
<span style={{color:"var(--ac)",fontWeight:700}}>Next step: </span>
In the <strong>DHCP tab</strong>, configure each VLAN pool to use
the ctrld machine's IP as DNS (option 6). The switch will then hand
out the correct DNS server to every device on each VLAN automatically.
</div>
)}
</div>
)}
</div>
)}
{/* Local Hostname Resolution */}
<LocalHostnamesPanel session={session} onNeedAuth={onNeedAuth} />
{/* DNS Enforcement ACLs */}
<div className="panel">
<div className="ph"> Enforce DNS on Switch</div>
<div className="pb">
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.8,marginBottom:12}}>
Without enforcement, a device can ignore DHCP-assigned DNS and use
<code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}> 8.8.8.8</code> directly,
bypassing all ctrld filtering. These ACLs block that:
<ul style={{margin:"6px 0 0 18px",lineHeight:1.9}}>
<li>Permit UDP/TCP port 53 <em>to ctrld only</em></li>
<li>Deny UDP/TCP port 53 to everywhere else</li>
<li>Deny TCP port 853 (DNS-over-TLS bypass)</li>
<li>Permit everything else (internet still works)</li>
</ul>
Generated ACLs are shown for review push them via the
<strong> Review &amp; Push</strong> tab as usual.
</div>
<div style={{display:"flex",gap:10,alignItems:"flex-end",flexWrap:"wrap"}}>
<div className="field" style={{margin:0,flex:"0 0 auto"}}>
<label>ctrld IP address</label>
<input
value={enforceIp || (status?.dns_ip||"")}
onChange={e=>setEnforceIp(e.target.value)}
placeholder={status?.dns_ip || "e.g. 192.168.99.50"}
style={{fontFamily:"var(--mono)",maxWidth:200}}
/>
</div>
<button className="btn bp" style={{alignSelf:"flex-end"}}
onClick={generateEnforceAcls} disabled={enforceLoading||!session}>
{enforceLoading ? "Generating..." : "Generate Enforcement ACLs"}
</button>
{!session && (
<button className="btn bg" style={{alignSelf:"flex-end",fontSize:10}}
onClick={onNeedAuth}>Authenticate</button>
)}
</div>
{/* Enforcement result */}
{enforceResult && (
<div style={{marginTop:12}}>
{enforceResult.success ? (
<div>
<div style={{
padding:"8px 12px",borderRadius:4,marginBottom:10,fontSize:11,
background:"rgba(0,230,118,.06)",border:"1px solid rgba(0,230,118,.2)",
color:"var(--dm)",
}}>
<span style={{color:"var(--ok)",fontWeight:700}}> </span>
Generated {enforceResult.count} commands for VLANs{" "}
{enforceResult.vlans?.join(", ")}.{" "}
Copy the commands below into the{" "}
<strong>Review &amp; Push</strong> tab Raw CLI mode.
</div>
<div style={{
background:"#060809",borderRadius:4,padding:"10px 12px",
fontFamily:"var(--mono)",fontSize:10,color:"#a0b0c0",
whiteSpace:"pre",overflowX:"auto",maxHeight:260,overflowY:"auto",
}}>{enforceResult.commands?.join("\n")}</div>
<button className="btn bg" style={{marginTop:8,fontSize:10}}
onClick={()=>navigator.clipboard?.writeText(enforceResult.commands?.join("\n"))}>
Copy Commands
</button>
</div>
) : (
<div style={{
padding:"8px 12px",borderRadius:4,fontSize:11,
background:"rgba(255,100,100,.06)",border:"1px solid rgba(255,100,100,.2)",
color:"var(--err)",
}}>
{enforceResult.message}
</div>
)}
</div>
)}
</div>
</div>
{/* Quick reference */}
<div className="panel">
<div className="ph"> How It Works</div>
<div className="pb">
<div style={{
display:"grid",gridTemplateColumns:"repeat(4,1fr)",gap:10,
fontSize:11,textAlign:"center",
}}>
{[
{icon:"📱", label:"Device", sub:"sends DNS query\nto switch DHCP\nassigned DNS IP"},
{icon:"⚡", label:"ctrld", sub:"receives query\nidentifies VLAN\nby source subnet"},
{icon:"🔒", label:"DoH3", sub:"forwards via\nencrypted HTTPS/3\nto Control D"},
{icon:"🛡️", label:"Control D", sub:"applies your\nVLAN profile\nreturns answer"},
].map((s,i)=>(
<div key={i} style={{
background:"var(--bg)",border:"1px solid var(--b2)",borderRadius:5,
padding:"12px 8px",
}}>
<div style={{fontSize:22,marginBottom:6}}>{s.icon}</div>
<div style={{fontWeight:700,marginBottom:4,color:"var(--tx)"}}>{s.label}</div>
<div style={{color:"var(--dm)",whiteSpace:"pre-line",lineHeight:1.6}}>{s.sub}</div>
</div>
))}
</div>
<div style={{marginTop:12,fontSize:11,color:"var(--dm)",lineHeight:1.8}}>
<span style={{color:"var(--ac)",fontWeight:700}}>Per-VLAN enforcement: </span>
ctrld identifies which VLAN a DNS query came from by the source IP subnet.
Each VLAN subnet maps to a different Control D profile. Staff get permissive
filtering. IoT devices get strict filtering. Guests get aggressive ad/malware blocking.
All encrypted via DoH3 your ISP sees HTTPS traffic, not DNS queries.
</div>
</div>
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// UNIFIED NETWORK TAB — manage VLANs + ports + OPNsense in one place
// ══════════════════════════════════════════════════════════════════════════════
function NetworkTab({ vlans, setVlans, ports, updatePort, session, onNeedAuth, backendOk }) {
const [form, setForm] = useState({
vlan_id: "", name: "", subnet: "", gateway: "",
dhcp_start: "", dhcp_end: "",
parent_if: "igb0", opnsense_if: "", allow_internet: true,
});
const [selectedPorts, setSelectedPorts] = useState([]);
const [trunkPorts, setTrunkPorts] = useState([]);
const [provisioning, setProvisioning] = useState(false);
const [result, setResult] = useState(null);
const [connStatus, setConnStatus] = useState(null);
const [connLoading, setConnLoading] = useState(false);
const checkConn = async () => {
setConnLoading(true);
try { setConnStatus(await API("/connectivity/check")); }
catch(e) { setConnStatus({ error: e.message }); }
setConnLoading(false);
};
useEffect(() => { if (backendOk) checkConn(); }, [backendOk]);
const autoFillSubnet = (vid) => {
if (!vid) return;
const v = parseInt(vid);
if (v > 0 && v < 255) {
setForm(f => ({
...f,
subnet: `192.168.${v}.0/24`,
gateway: `192.168.${v}.1`,
dhcp_start: `192.168.${v}.100`,
dhcp_end: `192.168.${v}.200`,
}));
}
};
const togglePort = (portId, list, setList) => {
setList(prev => prev.includes(portId) ? prev.filter(p => p !== portId) : [...prev, portId]);
};
const provision = async () => {
if (!session) { onNeedAuth(); return; }
setProvisioning(true); setResult(null);
try {
const body = {
token: session.token,
vlan_id: parseInt(form.vlan_id),
name: form.name,
subnet: form.subnet,
gateway: form.gateway,
dhcp_start: form.dhcp_start,
dhcp_end: form.dhcp_end,
parent_if: form.parent_if,
opnsense_if: form.opnsense_if || "",
allow_internet: form.allow_internet,
ports: selectedPorts.map(p => ({ port: p.id, poe: p.poe, poe_limit: p.poeLimit || 30000 })),
trunk_ports: trunkPorts,
};
const r = await API("/network/provision", { method: "POST", body });
setResult(r);
if (r.success) {
// Update local vlans state
const newVlan = { id: parseInt(form.vlan_id), name: form.name,
color: VLAN_COLORS[vlans.length % VLAN_COLORS.length] };
if (!vlans.find(v => v.id === newVlan.id)) setVlans([...vlans, newVlan]);
}
} catch(e) {
setResult({ success: false, errors: [e.message] });
}
setProvisioning(false);
};
const portGrid = (start, end, forTrunk) => (
<div style={{ display:"flex", flexWrap:"wrap", gap:4, marginBottom:8 }}>
{ports.filter(p => p.id >= start && p.id <= end).map(p => {
const inUse = p.mode === "access" && p.accessVlan !== 1;
const vlan = vlans.find(v => v.id === (p.accessVlan || 1));
const isSelected = forTrunk ? trunkPorts.includes(p.id) : selectedPorts.find(s => s.id === p.id);
return (
<div key={p.id} onClick={() => {
if (forTrunk) {
togglePort(p.id, trunkPorts, setTrunkPorts);
} else {
if (isSelected) {
setSelectedPorts(prev => prev.filter(s => s.id !== p.id));
} else {
setSelectedPorts(prev => [...prev, { id: p.id, poe: true, poeLimit: 30000 }]);
}
}
}} style={{
width:36, height:36, display:"flex", alignItems:"center", justifyContent:"center",
fontSize:11, fontWeight:700, borderRadius:4, cursor:"pointer",
border: isSelected ? "2px solid var(--ac)" : "1px solid var(--b2)",
background: isSelected ? "var(--ac)" : inUse ? (vlan?.color || "var(--b1)") + "30" : "var(--bg)",
color: isSelected ? "var(--bg)" : inUse ? "var(--dm)" : "var(--tx)",
opacity: inUse && !forTrunk ? 0.5 : 1,
}} title={`Port ${p.id}${inUse ? ` (${vlan?.name || "VLAN " + p.accessVlan})` : ""}${p.description ? " — " + p.description : ""}`}>
{p.id}
</div>
);
})}
</div>
);
return (
<div className="main">
<div style={{flex:1}}>
{/* Connectivity Status */}
<div className="panel">
<div className="ph">Connectivity Status</div>
<div className="pb" style={{display:"flex",gap:16,alignItems:"center"}}>
<div style={{display:"flex",alignItems:"center",gap:6}}>
<span className={`dot ${connStatus?.switch?.ok ? "ok" : "err"}`}/>
<span style={{fontSize:12}}>Switch {connStatus?.switch?.ok ? "Online" : "Offline"}</span>
</div>
<div style={{display:"flex",alignItems:"center",gap:6}}>
<span className={`dot ${connStatus?.opnsense?.ok ? "ok" : connStatus?.opnsense?.configured ? "warn" : "idle"}`}/>
<span style={{fontSize:12}}>
OPNsense {connStatus?.opnsense?.ok ? "Online" : connStatus?.opnsense?.configured ? "Unreachable" : "Not configured"}
</span>
</div>
<button className="btn bd" onClick={checkConn} disabled={connLoading}
style={{marginLeft:"auto",fontSize:11,padding:"4px 12px"}}>
{connLoading ? "Checking..." : "Refresh"}
</button>
</div>
</div>
{/* Existing VLANs overview */}
<div className="panel">
<div className="ph">Active VLANs</div>
<div className="pb">
<div style={{display:"flex",flexWrap:"wrap",gap:8}}>
{vlans.map(v => {
const cnt = ports.filter(p =>
p.mode === "access" ? p.accessVlan === v.id : p.taggedVlans?.includes(v.id)
).length;
return (
<div key={v.id} style={{
background: v.color + "20", border:`1px solid ${v.color}50`,
borderRadius:6, padding:"8px 14px", minWidth:140,
}}>
<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:4}}>
{cnt} port{cnt !== 1 ? "s" : ""} | 192.168.{v.id}.0/24
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Unified VLAN Provisioning Form */}
<div className="panel">
<div className="ph">Provision New VLAN (Switch + OPNsense)</div>
<div className="pb">
<div style={{
background:"var(--bg)",border:"1px solid var(--ac)30",borderRadius:6,
padding:12,marginBottom:16,fontSize:12,color:"var(--dm)",lineHeight:1.6,
}}>
This creates everything in one step: VLAN on the switch, assigns ports with PoE settings,
creates the VLAN tag and DHCP scope on OPNsense, and adds a firewall rule.
A backup is taken automatically before any changes are made.
</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gap:12}}>
<div className="field"><label>VLAN ID</label>
<input value={form.vlan_id} onChange={e => {
setForm(f => ({...f, vlan_id: e.target.value}));
autoFillSubnet(e.target.value);
}} type="number" placeholder="60"/>
</div>
<div className="field"><label>Name</label>
<input value={form.name} onChange={e => setForm(f => ({...f, name: e.target.value}))} placeholder="e.g. Cameras"/>
</div>
<div className="field"><label>Subnet</label>
<input value={form.subnet} onChange={e => setForm(f => ({...f, subnet: e.target.value}))} placeholder="192.168.60.0/24"/>
</div>
<div className="field"><label>Gateway (OPNsense IP)</label>
<input value={form.gateway} onChange={e => setForm(f => ({...f, gateway: e.target.value}))} placeholder="192.168.60.1"/>
</div>
<div className="field"><label>DHCP Start</label>
<input value={form.dhcp_start} onChange={e => setForm(f => ({...f, dhcp_start: e.target.value}))} placeholder="192.168.60.100"/>
</div>
<div className="field"><label>DHCP End</label>
<input value={form.dhcp_end} onChange={e => setForm(f => ({...f, dhcp_end: e.target.value}))} placeholder="192.168.60.200"/>
</div>
<div className="field"><label>OPNsense Parent Interface</label>
<input value={form.parent_if} onChange={e => setForm(f => ({...f, parent_if: e.target.value}))} placeholder="igb0"/>
</div>
<div className="field"><label>OPNsense Interface (if assigned)</label>
<input value={form.opnsense_if} onChange={e => setForm(f => ({...f, opnsense_if: e.target.value}))} placeholder="e.g. opt3 (leave blank if new)"/>
</div>
<div className="field" style={{display:"flex",alignItems:"flex-end"}}>
<label style={{display:"flex",alignItems:"center",gap:6,cursor:"pointer"}}>
<input type="checkbox" checked={form.allow_internet}
onChange={e => setForm(f => ({...f, allow_internet: e.target.checked}))}/>
Allow internet access
</label>
</div>
</div>
{/* Port Selection */}
<div style={{marginTop:20}}>
<div className="sect">Access Ports (will be assigned to this VLAN)</div>
<div style={{fontSize:11,color:"var(--dm)",marginBottom:8}}>
Click ports to select. Dimmed ports are already assigned to another VLAN.
</div>
{portGrid(1, 24, false)}
{portGrid(25, 48, false)}
{selectedPorts.length > 0 && (
<div style={{marginTop:12}}>
<div className="sect">PoE Settings for Selected Ports</div>
<div style={{display:"flex",flexWrap:"wrap",gap:8}}>
{selectedPorts.map(sp => (
<div key={sp.id} style={{
display:"flex",alignItems:"center",gap:6,
background:"var(--bg)",border:"1px solid var(--b2)",
borderRadius:4,padding:"4px 10px",fontSize:12,
}}>
<span style={{fontWeight:700,color:"var(--ac)"}}>Port {sp.id}</span>
<label style={{display:"flex",alignItems:"center",gap:4,cursor:"pointer"}}>
<input type="checkbox" checked={sp.poe}
onChange={e => setSelectedPorts(prev =>
prev.map(p => p.id === sp.id ? {...p, poe: e.target.checked} : p)
)}/>
<span style={{fontSize:11}}>PoE</span>
</label>
</div>
))}
</div>
</div>
)}
<div style={{marginTop:16}}>
<div className="sect">Trunk Uplinks (tag this VLAN on existing trunks)</div>
<div style={{fontSize:11,color:"var(--dm)",marginBottom:8}}>
Select uplink ports that should carry this VLAN (typically SFP+ ports 49-52).
</div>
{portGrid(49, 52, true)}
</div>
</div>
{/* Summary */}
{(form.vlan_id && form.name) && (
<div style={{
marginTop:16,padding:12,background:"var(--b1)",borderRadius:6,
fontSize:12,lineHeight:1.8,
}}>
<div style={{fontWeight:700,color:"var(--ac)",marginBottom:4}}>Provision Summary</div>
<div>VLAN {form.vlan_id} "{form.name}" | Subnet: {form.subnet || "—"}</div>
<div>Access ports: {selectedPorts.length > 0
? selectedPorts.map(p => `${p.id}${p.poe ? " (PoE)" : ""}`).join(", ")
: "none selected"}</div>
<div>Trunk ports: {trunkPorts.length > 0 ? trunkPorts.join(", ") : "none"}</div>
<div>OPNsense: {form.opnsense_if
? `DHCP ${form.dhcp_start}${form.dhcp_end} on ${form.opnsense_if}`
: "VLAN tag only (assign interface in OPNsense UI)"}</div>
{form.allow_internet && <div>Firewall: allow outbound</div>}
</div>
)}
<div style={{marginTop:16,display:"flex",gap:12,alignItems:"center"}}>
<button className="btn bp" onClick={provision}
disabled={provisioning || !form.vlan_id || !form.name}
style={{padding:"10px 24px"}}>
{provisioning ? "Provisioning..." : "Provision VLAN"}
</button>
<span style={{fontSize:11,color:"var(--dm)"}}>
Auto-backup runs before changes. TOTP required.
</span>
</div>
{/* Result */}
{result && (
<div style={{
marginTop:16,padding:12,borderRadius:6,
background: result.success ? "rgba(0,230,118,0.08)" : "rgba(255,23,68,0.08)",
border: `1px solid ${result.success ? "rgba(0,230,118,0.3)" : "rgba(255,23,68,0.3)"}`,
}}>
<div style={{fontWeight:700,marginBottom:8,color:result.success?"#00e676":"#ff1744"}}>
{result.success ? "Provisioning Complete" : "Provisioning Failed"}
</div>
{result.steps_done?.map((s,i) => (
<div key={i} style={{fontSize:12,color:"var(--tx)",lineHeight:1.8}}>
<span style={{color:"#00e676",marginRight:6}}>done</span>{s}
</div>
))}
{result.errors?.map((e,i) => (
<div key={i} style={{fontSize:12,color:"#ff1744",lineHeight:1.8}}>
<span style={{marginRight:6}}>error</span>{e}
</div>
))}
{result.pending_steps?.map((s,i) => (
<div key={i} style={{fontSize:12,color:"var(--warn, #ffea00)",lineHeight:1.8}}>
<span style={{marginRight:6}}>manual</span>{s}
</div>
))}
{result.backup && (
<div style={{marginTop:8,fontSize:11,color:"var(--dm)"}}>
Backup: switch={result.backup.switch?.file || "N/A"}
{result.backup.opnsense ? `, opnsense=${result.backup.opnsense.file || "N/A"}` : ""}
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// BACKUP TAB — view, create, restore, download backups
// ══════════════════════════════════════════════════════════════════════════════
function BackupTab({ session, onNeedAuth, backendOk }) {
const [backups, setBackups] = useState({ switch: [], opnsense: [] });
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [restoring, setRestoring] = useState(null);
const [restoreResult, setRestoreResult] = useState(null);
const [reason, setReason] = useState("");
const load = async () => {
setLoading(true);
try { setBackups(await API("/backup/list")); } catch(e) { console.error(e); }
setLoading(false);
};
useEffect(() => { if (backendOk) load(); }, [backendOk]);
const createBackup = async (device) => {
if (!session) { onNeedAuth(); return; }
setCreating(true);
try {
await API("/backup/create", { method: "POST", body: {
token: session.token, device, reason: reason || "manual backup",
}});
setReason("");
await load();
} catch(e) { alert("Backup failed: " + e.message); }
setCreating(false);
};
const restore = async (device, filename) => {
if (!session) { onNeedAuth(); return; }
if (!confirm(`Restore ${device} from ${filename}?\n\nA safety backup will be created first.`)) return;
setRestoring(filename); setRestoreResult(null);
try {
const r = await API("/backup/restore", { method: "POST", body: {
token: session.token, device, filename,
}});
setRestoreResult(r);
await load();
} catch(e) { setRestoreResult({ result: { ok: false, error: e.message } }); }
setRestoring(null);
};
const BackupTable = ({ device, items }) => (
<div className="panel">
<div className="ph">{device === "switch" ? "Switch" : "OPNsense"} Backups</div>
<div className="pb">
<div style={{display:"flex",gap:8,marginBottom:12,alignItems:"flex-end"}}>
<div className="field" style={{margin:0,flex:1}}>
<label>Reason (optional)</label>
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="e.g. before VLAN change"/>
</div>
<button className="btn bp" onClick={() => createBackup(device)} disabled={creating}
style={{padding:"8px 16px"}}>
{creating ? "Creating..." : `Backup ${device === "switch" ? "Switch" : "OPNsense"}`}
</button>
</div>
{items.length === 0 ? (
<div style={{color:"var(--dm)",fontSize:12,padding:16,textAlign:"center"}}>
No backups yet. Create one before making changes.
</div>
) : (
<table className="vtbl">
<thead><tr><th>Time</th><th>Reason</th><th>Size</th><th>Actions</th></tr></thead>
<tbody>
{items.map((b,i) => (
<tr key={i}>
<td style={{fontSize:12,fontFamily:"monospace"}}>{b.timestamp}</td>
<td style={{fontSize:12}}>{b.reason || "—"}</td>
<td style={{fontSize:12}}>{b.size ? `${(b.size/1024).toFixed(1)} KB` : "—"}</td>
<td style={{display:"flex",gap:6}}>
<a href={`/api/backup/download/${device}/${b.file}`}
style={{fontSize:11,color:"var(--ac)",textDecoration:"none",cursor:"pointer"}}>
Download
</a>
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
onClick={() => restore(device, b.file)}
disabled={restoring === b.file}>
{restoring === b.file ? "Restoring..." : "Restore"}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
return (
<div className="main">
<div style={{flex:1}}>
<div className="panel">
<div className="ph">Backup & Restore</div>
<div className="pb" style={{fontSize:12,color:"var(--dm)",lineHeight:1.8}}>
Backups are created automatically before every change (VLAN provisioning, push operations).
You can also create manual backups here. OPNsense backups are full XML config exports.
Switch backups capture the running configuration.
<div style={{marginTop:8,color:"var(--ac)",fontWeight:600}}>
Restore: OPNsense configs can be restored via API. Switch configs must be reviewed
and applied via the Review & Push tab (to prevent accidental lockout).
</div>
</div>
</div>
<div style={{display:"flex",gap:8,marginBottom:16}}>
<button className="btn bp" onClick={() => createBackup("both")} disabled={creating}
style={{padding:"10px 20px"}}>
{creating ? "Creating..." : "Backup Both Devices"}
</button>
</div>
{restoreResult && (
<div style={{
marginBottom:16,padding:12,borderRadius:6,
background: restoreResult.result?.ok ? "rgba(0,230,118,0.08)" : "rgba(255,23,68,0.08)",
border: `1px solid ${restoreResult.result?.ok ? "rgba(0,230,118,0.3)" : "rgba(255,23,68,0.3)"}`,
fontSize:12,
}}>
{restoreResult.result?.ok
? <div style={{color:"#00e676"}}>Restore successful. Safety backup was created first.</div>
: <div style={{color:"#ff1744"}}>Restore failed: {restoreResult.result?.error || "Unknown error"}</div>
}
{restoreResult.config_preview && (
<details style={{marginTop:8}}>
<summary style={{cursor:"pointer",color:"var(--ac)"}}>Config preview</summary>
<pre style={{fontSize:10,maxHeight:300,overflow:"auto",marginTop:4,
background:"var(--bg)",padding:8,borderRadius:4}}>
{restoreResult.config_preview}
</pre>
</details>
)}
</div>
)}
<BackupTable device="switch" items={backups.switch} />
<BackupTable device="opnsense" items={backups.opnsense} />
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// FIREWALL TAB — inter-VLAN policy matrix
// ══════════════════════════════════════════════════════════════════════════════
function FirewallTab({ vlans, session, onNeedAuth, backendOk }) {
const [policies, setPolicies] = useState([]);
const [presets, setPresets] = useState({});
const [form, setForm] = useState({ src_vlan: "", dst_vlan: "", type: "block", ports: "" });
const [preview, setPreview] = useState(null);
const [pushing, setPushing] = useState(false);
const [result, setResult] = useState(null);
const load = async () => {
try {
const d = await API("/firewall/policies");
setPolicies(d.policies || []);
setPresets(d.presets || {});
} catch(e) { console.error(e); }
};
useEffect(() => { if (backendOk) load(); }, [backendOk]);
const doPreview = async () => {
if (!form.src_vlan || !form.dst_vlan || !form.type) return;
try {
const policy = {
src_vlan: parseInt(form.src_vlan), dst_vlan: parseInt(form.dst_vlan),
type: form.type,
ports: form.ports ? form.ports.split(",").map(p => parseInt(p.trim())).filter(Boolean) : [],
};
const p = await API("/firewall/preview", { method: "POST", body: { policy } });
setPreview(p);
} catch(e) { setPreview({ error: e.message }); }
};
const pushPolicy = async () => {
if (!session) { onNeedAuth(); return; }
setPushing(true); setResult(null);
try {
const policy = {
src_vlan: parseInt(form.src_vlan), dst_vlan: parseInt(form.dst_vlan),
type: form.type,
ports: form.ports ? form.ports.split(",").map(p => parseInt(p.trim())).filter(Boolean) : [],
};
const r = await API("/firewall/push", { method: "POST", body: { token: session.token, policy } });
setResult(r);
await load();
} catch(e) { setResult({ success: false, errors: [e.message] }); }
setPushing(false);
};
// Build the VLAN matrix
const nonMgmt = vlans.filter(v => v.id !== 99 && v.id !== 1);
const getPolicy = (src, dst) => policies.find(p => p.src_vlan === src && p.dst_vlan === dst);
const policyColor = (type) => ({
block: "#ff1744", allow: "#00e676", "one-way": "#2979ff",
printer: "#ff6d00", services: "#d500f9",
}[type] || "var(--dm)");
return (
<div className="main">
<div style={{flex:1}}>
<div className="panel">
<div className="ph">Inter-VLAN Policy Matrix</div>
<div className="pb">
<div style={{fontSize:12,color:"var(--dm)",marginBottom:12,lineHeight:1.6}}>
Click a cell to set the policy between two VLANs. Policies generate both switch ACLs
and OPNsense firewall rules. LAN (VLAN 1) has full access by default.
Management VLAN 99 is always isolated (enforced by hard-block).
</div>
{nonMgmt.length > 1 ? (
<div style={{overflowX:"auto"}}>
<table style={{borderCollapse:"collapse",fontSize:12,width:"100%"}}>
<thead>
<tr>
<th style={{padding:8,textAlign:"left",borderBottom:"1px solid var(--b2)",color:"var(--dm)"}}>
From \ To
</th>
{nonMgmt.map(v => (
<th key={v.id} style={{padding:8,textAlign:"center",borderBottom:"1px solid var(--b2)",
color:v.color,fontWeight:700,minWidth:80}}>
{v.name}<br/><span style={{fontSize:10,color:"var(--dm)"}}>V{v.id}</span>
</th>
))}
</tr>
</thead>
<tbody>
{nonMgmt.map(src => (
<tr key={src.id}>
<td style={{padding:8,fontWeight:700,color:src.color,borderBottom:"1px solid var(--b1)"}}>
{src.name} <span style={{fontSize:10,color:"var(--dm)"}}>V{src.id}</span>
</td>
{nonMgmt.map(dst => {
if (src.id === dst.id) return (
<td key={dst.id} style={{padding:8,textAlign:"center",background:"var(--b1)",
borderBottom:"1px solid var(--b1)",color:"var(--dm)",fontSize:10}}></td>
);
const p = getPolicy(src.id, dst.id);
return (
<td key={dst.id} style={{
padding:4,textAlign:"center",borderBottom:"1px solid var(--b1)",
cursor:"pointer",
}} onClick={() => {
setForm(f => ({...f, src_vlan: String(src.id), dst_vlan: String(dst.id)}));
setPreview(null); setResult(null);
}}>
<div style={{
padding:"6px 4px",borderRadius:4,fontSize:10,fontWeight:600,
background: p ? policyColor(p.type) + "20" : "var(--bg)",
border: `1px solid ${p ? policyColor(p.type) + "40" : "var(--b2)"}`,
color: p ? policyColor(p.type) : "var(--dm)",
}}>
{p ? (presets[p.type]?.label || p.type) : "No policy"}
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div style={{color:"var(--dm)",fontSize:12,padding:16,textAlign:"center"}}>
Create at least 2 non-management VLANs to use the policy matrix.
</div>
)}
{/* Legend */}
<div style={{display:"flex",gap:12,marginTop:12,flexWrap:"wrap"}}>
{Object.entries(presets).map(([k,v]) => (
<div key={k} style={{display:"flex",alignItems:"center",gap:4,fontSize:11}}>
<span style={{width:10,height:10,borderRadius:2,background:policyColor(k),display:"inline-block"}}/>
{v.label}
</div>
))}
</div>
</div>
</div>
{/* Policy Editor */}
<div className="panel">
<div className="ph">Set Policy</div>
<div className="pb">
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr 1fr",gap:12,alignItems:"flex-end"}}>
<div className="field"><label>Source VLAN</label>
<select value={form.src_vlan} onChange={e => { setForm(f => ({...f, src_vlan: e.target.value})); setPreview(null); }}>
<option value="">Select...</option>
{vlans.filter(v=>v.id!==99).map(v => <option key={v.id} value={v.id}>{v.name} (V{v.id})</option>)}
</select>
</div>
<div className="field"><label>Destination VLAN</label>
<select value={form.dst_vlan} onChange={e => { setForm(f => ({...f, dst_vlan: e.target.value})); setPreview(null); }}>
<option value="">Select...</option>
{vlans.filter(v=>v.id!==99).map(v => <option key={v.id} value={v.id}>{v.name} (V{v.id})</option>)}
</select>
</div>
<div className="field"><label>Policy Type</label>
<select value={form.type} onChange={e => setForm(f => ({...f, type: e.target.value}))}>
{Object.entries(presets).map(([k,v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
{(form.type === "services" || form.type === "printer") && (
<div className="field"><label>Ports (comma-separated)</label>
<input value={form.ports} onChange={e => setForm(f => ({...f, ports: e.target.value}))}
placeholder={form.type === "printer" ? "9100,631,443,515" : "80,443,8080"}/>
</div>
)}
</div>
{form.type && presets[form.type] && (
<div style={{marginTop:8,fontSize:12,color:"var(--dm)",fontStyle:"italic"}}>
{presets[form.type].description}
</div>
)}
<div style={{display:"flex",gap:8,marginTop:16}}>
<button className="btn bd" onClick={doPreview}
disabled={!form.src_vlan || !form.dst_vlan}>
Preview Commands
</button>
<button className="btn bp" onClick={pushPolicy}
disabled={pushing || !form.src_vlan || !form.dst_vlan}>
{pushing ? "Pushing..." : "Push Policy"}
</button>
</div>
{preview && !preview.error && (
<div style={{marginTop:12,padding:12,background:"var(--bg)",borderRadius:6,border:"1px solid var(--b2)"}}>
<div style={{fontWeight:700,fontSize:12,marginBottom:8,color:"var(--ac)"}}>
{preview.description}
</div>
<div style={{fontSize:11,fontWeight:600,color:"var(--tx)",marginBottom:4}}>Switch ACL Commands:</div>
<pre style={{fontSize:11,color:"var(--dm)",margin:0,whiteSpace:"pre-wrap"}}>
{preview.switch_cmds?.join("\n")}
</pre>
{preview.opnsense_rules?.length > 0 && <>
<div style={{fontSize:11,fontWeight:600,color:"var(--tx)",marginTop:8,marginBottom:4}}>
OPNsense Firewall Rules:
</div>
{preview.opnsense_rules.map((r,i) => (
<div key={i} style={{fontSize:11,color:"var(--dm)"}}>
{r.rule.action.toUpperCase()} {r.rule.descr}
</div>
))}
</>}
</div>
)}
{result && (
<div style={{
marginTop:12,padding:12,borderRadius:6,
background: result.success ? "rgba(0,230,118,0.08)" : "rgba(255,23,68,0.08)",
border: `1px solid ${result.success ? "rgba(0,230,118,0.3)" : "rgba(255,23,68,0.3)"}`,
}}>
<div style={{fontWeight:700,color:result.success?"#00e676":"#ff1744",marginBottom:4}}>
{result.success ? "Policy Pushed" : "Push Failed"}
</div>
{result.steps_done?.map((s,i) => (
<div key={i} style={{fontSize:12,color:"var(--tx)"}}><span style={{color:"#00e676"}}>done </span>{s}</div>
))}
{result.errors?.map((e,i) => (
<div key={i} style={{fontSize:12,color:"#ff1744"}}><span>error </span>{e}</div>
))}
</div>
)}
</div>
</div>
{/* Active Policies List */}
{policies.length > 0 && (
<div className="panel">
<div className="ph">Active Policies ({policies.length})</div>
<div className="pb">
<table className="vtbl">
<thead><tr><th>Source</th><th>Destination</th><th>Type</th><th>Pushed</th><th></th></tr></thead>
<tbody>
{policies.map((p,i) => (
<tr key={i}>
<td style={{fontWeight:700}}>{vlans.find(v=>v.id===p.src_vlan)?.name || `V${p.src_vlan}`}</td>
<td style={{fontWeight:700}}>{vlans.find(v=>v.id===p.dst_vlan)?.name || `V${p.dst_vlan}`}</td>
<td><span style={{color:policyColor(p.type),fontWeight:600}}>{presets[p.type]?.label || p.type}</span></td>
<td style={{fontSize:11,color:"var(--dm)"}}>{p.pushed_at || "not pushed"}</td>
<td>
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}} onClick={async () => {
if (!session) { onNeedAuth(); return; }
await API("/firewall/policies", { method:"DELETE", body:{ token:session.token, src_vlan:p.src_vlan, dst_vlan:p.dst_vlan }});
load();
}}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// SERVICES TAB — expose LAN services to other VLANs via reverse proxy + DNS
// ══════════════════════════════════════════════════════════════════════════════
function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
const [services, setServices] = useState([]);
const [form, setForm] = useState({ fqdn: "", backend_url: "", description: "", allowed_vlans: [] });
const [deploying, setDeploying] = useState(false);
const [deployResult, setDeployResult] = useState(null);
const load = async () => {
try {
const d = await API("/services");
setServices(d.services || []);
} catch(e) { console.error(e); }
};
useEffect(() => { if (backendOk) load(); }, [backendOk]);
const addService = async () => {
if (!session) { onNeedAuth(); return; }
if (!form.fqdn || !form.backend_url) return;
try {
await API("/services", { method: "POST", body: { token: session.token, service: form } });
setForm({ fqdn: "", backend_url: "", description: "", allowed_vlans: [] });
await load();
} catch(e) { alert("Save failed: " + e.message); }
};
const removeService = async (fqdn) => {
if (!session) { onNeedAuth(); return; }
await API("/services", { method: "DELETE", body: { token: session.token, fqdn } });
await load();
};
const deploy = async () => {
if (!session) { onNeedAuth(); return; }
setDeploying(true); setDeployResult(null);
try {
const r = await API("/services/deploy", { method: "POST", body: { token: session.token } });
setDeployResult(r);
} catch(e) { setDeployResult({ success: false, errors: [e.message] }); }
setDeploying(false);
};
const toggleVlan = (vid) => {
setForm(f => ({
...f,
allowed_vlans: f.allowed_vlans.includes(vid)
? f.allowed_vlans.filter(v => v !== vid)
: [...f.allowed_vlans, vid],
}));
};
return (
<div className="main">
<div style={{flex:1}}>
<div className="panel">
<div className="ph">Service Proxy</div>
<div className="pb" style={{fontSize:12,color:"var(--dm)",lineHeight:1.8}}>
Expose services running on your LAN to other VLANs without opening inter-VLAN access.
Each service gets an FQDN (e.g. <span style={{color:"var(--ac)"}}>plex.home.lan</span>) that
resolves to the management box. Caddy reverse-proxies the request to the actual server.
Only port 443 is opened no direct VLAN-to-VLAN access needed.
<div style={{marginTop:8,padding:8,background:"var(--bg)",borderRadius:4,fontFamily:"monospace",fontSize:11}}>
Device on VLAN 30 DNS: plex.home.lan = mgmt IP Caddy LAN server:32400
</div>
</div>
</div>
{/* Add Service Form */}
<div className="panel">
<div className="ph">Add Service</div>
<div className="pb">
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gap:12}}>
<div className="field"><label>Service FQDN</label>
<input value={form.fqdn} onChange={e => setForm(f => ({...f, fqdn: e.target.value}))}
placeholder="plex.home.lan"/>
</div>
<div className="field"><label>Backend URL (actual server)</label>
<input value={form.backend_url} onChange={e => setForm(f => ({...f, backend_url: e.target.value}))}
placeholder="http://192.168.1.100:32400"/>
</div>
<div className="field"><label>Description</label>
<input value={form.description} onChange={e => setForm(f => ({...f, description: e.target.value}))}
placeholder="Plex Media Server"/>
</div>
</div>
<div style={{marginTop:12}}>
<div className="sect">Allowed VLANs (which VLANs can reach this service)</div>
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
{vlans.filter(v => v.id !== 99 && v.id !== 1).map(v => (
<label key={v.id} style={{
display:"flex",alignItems:"center",gap:4,cursor:"pointer",
padding:"4px 10px",borderRadius:4,fontSize:12,
background: form.allowed_vlans.includes(v.id) ? v.color + "20" : "var(--bg)",
border: `1px solid ${form.allowed_vlans.includes(v.id) ? v.color : "var(--b2)"}`,
}}>
<input type="checkbox" checked={form.allowed_vlans.includes(v.id)}
onChange={() => toggleVlan(v.id)}/>
<span style={{color: v.color, fontWeight:600}}>{v.name}</span>
<span style={{color:"var(--dm)",fontSize:10}}>V{v.id}</span>
</label>
))}
</div>
</div>
<button className="btn bp" onClick={addService} disabled={!form.fqdn || !form.backend_url}
style={{marginTop:12}}>
Add Service
</button>
</div>
</div>
{/* Service List */}
{services.length > 0 && (
<div className="panel">
<div className="ph">Configured Services ({services.length})</div>
<div className="pb">
<table className="vtbl">
<thead><tr><th>FQDN</th><th>Backend</th><th>Description</th><th>VLANs</th><th></th></tr></thead>
<tbody>
{services.map((s,i) => (
<tr key={i}>
<td style={{fontWeight:700,color:"var(--ac)",fontFamily:"monospace"}}>{s.fqdn}</td>
<td style={{fontFamily:"monospace",fontSize:11}}>{s.backend_url}</td>
<td>{s.description || "—"}</td>
<td style={{fontSize:11}}>
{(s.allowed_vlans || []).map(vid => {
const v = vlans.find(x => x.id === vid);
return <span key={vid} style={{color:v?.color||"var(--dm)",marginRight:4}}>{v?.name||`V${vid}`}</span>;
})}
</td>
<td>
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
onClick={() => removeService(s.fqdn)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
<div style={{marginTop:16,display:"flex",gap:12,alignItems:"center"}}>
<button className="btn bp" onClick={deploy} disabled={deploying} style={{padding:"10px 24px"}}>
{deploying ? "Deploying..." : "Deploy All Services"}
</button>
<span style={{fontSize:11,color:"var(--dm)"}}>
Writes Caddyfile, pushes DNS overrides to Unbound, adds firewall rules
</span>
</div>
{deployResult && (
<div style={{
marginTop:12,padding:12,borderRadius:6,
background: deployResult.success ? "rgba(0,230,118,0.08)" : "rgba(255,23,68,0.08)",
border: `1px solid ${deployResult.success ? "rgba(0,230,118,0.3)" : "rgba(255,23,68,0.3)"}`,
}}>
<div style={{fontWeight:700,color:deployResult.success?"#00e676":"#ff1744",marginBottom:4}}>
{deployResult.success ? "Deploy Complete" : "Deploy Had Errors"}
</div>
{deployResult.steps_done?.map((s,i) => (
<div key={i} style={{fontSize:12,color:"var(--tx)"}}><span style={{color:"#00e676"}}>done </span>{s}</div>
))}
{deployResult.errors?.map((e,i) => (
<div key={i} style={{fontSize:12,color:"#ff1744"}}><span>error </span>{e}</div>
))}
{deployResult.note && (
<div style={{marginTop:8,fontSize:11,color:"var(--ac)",fontWeight:600}}>
{deployResult.note}
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// ALERTS TAB — ntfy configuration + scheduled operations
// ══════════════════════════════════════════════════════════════════════════════
function AlertsTab({ session, onNeedAuth, backendOk }) {
const [ntfyCfg, setNtfyCfg] = useState({ url: "https://ntfy.sh", topic: "", enabled: false, events: {} });
const [ntfyToken, setNtfyToken] = useState("");
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [schedules, setSchedules] = useState([]);
const [schedForm, setSchedForm] = useState({
name: "", action: "backup", device: "both", hour: "3", minute: "0", days: "*", enabled: true,
});
const loadNtfy = async () => {
try { setNtfyCfg(await API("/alerts/config")); } catch(e) { console.error(e); }
};
const loadSchedules = async () => {
try {
const d = await API("/schedules");
setSchedules(d.schedules || []);
} catch(e) { console.error(e); }
};
useEffect(() => { if (backendOk) { loadNtfy(); loadSchedules(); } }, [backendOk]);
const saveNtfy = async () => {
if (!session) { onNeedAuth(); return; }
setSaving(true);
try {
await API("/alerts/config", { method: "POST", body: {
token: session.token, url: ntfyCfg.url, topic: ntfyCfg.topic,
ntfy_token: ntfyToken, enabled: ntfyCfg.enabled, events: ntfyCfg.events,
}});
await loadNtfy();
} catch(e) { alert("Save failed: " + e.message); }
setSaving(false);
};
const testNtfy = async () => {
if (!session) { onNeedAuth(); return; }
setTesting(true);
try {
await API("/alerts/test", { method: "POST", body: { token: session.token } });
alert("Test notification sent! Check your ntfy app/topic.");
} catch(e) { alert("Test failed: " + e.message); }
setTesting(false);
};
const addSchedule = async () => {
if (!session) { onNeedAuth(); return; }
if (!schedForm.name) return;
try {
await API("/schedules", { method: "POST", body: { token: session.token, schedule: schedForm } });
setSchedForm(f => ({...f, name: ""}));
await loadSchedules();
} catch(e) { alert("Save failed: " + e.message); }
};
const deleteSchedule = async (name) => {
if (!session) { onNeedAuth(); return; }
await API("/schedules", { method: "DELETE", body: { token: session.token, name } });
await loadSchedules();
};
const runNow = async (name) => {
if (!session) { onNeedAuth(); return; }
try {
await API("/schedules/run-now", { method: "POST", body: { token: session.token, name } });
alert(`Schedule "${name}" triggered.`);
} catch(e) { alert("Run failed: " + e.message); }
};
const toggleEvent = (key) => {
setNtfyCfg(c => ({ ...c, events: { ...c.events, [key]: !c.events[key] } }));
};
const eventLabels = {
connectivity_lost: "Switch goes offline / comes back",
backup_failed: "Scheduled backup fails",
push_failed: "Config push fails",
poe_budget_warning: "PoE budget exceeds 85%",
port_down: "Port goes down (high volume)",
};
return (
<div className="main">
<div style={{flex:1}}>
{/* ntfy Configuration */}
<div className="panel">
<div className="ph">Push Notifications (ntfy)</div>
<div className="pb">
<div style={{fontSize:12,color:"var(--dm)",marginBottom:12,lineHeight:1.6}}>
Get push notifications on your phone/desktop when network events occur.
Works with <span style={{color:"var(--ac)"}}>ntfy.sh</span> (free, no account needed)
or a self-hosted ntfy server.
</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gap:12}}>
<div className="field"><label>ntfy Server URL</label>
<input value={ntfyCfg.url} onChange={e => setNtfyCfg(c => ({...c, url: e.target.value}))}
placeholder="https://ntfy.sh"/>
</div>
<div className="field"><label>Topic</label>
<input value={ntfyCfg.topic} onChange={e => setNtfyCfg(c => ({...c, topic: e.target.value}))}
placeholder="my-network-alerts"/>
</div>
<div className="field"><label>Access Token (optional)</label>
<input value={ntfyToken} onChange={e => setNtfyToken(e.target.value)}
type="password" placeholder={ntfyCfg.has_token ? "••••••• (saved)" : "for private topics"}/>
</div>
</div>
<div style={{marginTop:12}}>
<div className="sect">Alert Events</div>
<div style={{display:"flex",flexDirection:"column",gap:6}}>
{Object.entries(eventLabels).map(([k,label]) => (
<label key={k} style={{display:"flex",alignItems:"center",gap:8,cursor:"pointer",fontSize:12}}>
<input type="checkbox" checked={ntfyCfg.events?.[k] || false}
onChange={() => toggleEvent(k)}/>
{label}
</label>
))}
</div>
</div>
<div style={{marginTop:12}}>
<label style={{display:"flex",alignItems:"center",gap:8,cursor:"pointer",fontSize:13,fontWeight:600}}>
<input type="checkbox" checked={ntfyCfg.enabled}
onChange={e => setNtfyCfg(c => ({...c, enabled: e.target.checked}))}/>
Enable notifications
</label>
</div>
<div style={{display:"flex",gap:8,marginTop:16}}>
<button className="btn bp" onClick={saveNtfy} disabled={saving}>
{saving ? "Saving..." : "Save Configuration"}
</button>
<button className="btn bd" onClick={testNtfy} disabled={testing || !ntfyCfg.topic}>
{testing ? "Sending..." : "Send Test"}
</button>
</div>
</div>
</div>
{/* Scheduled Operations */}
<div className="panel">
<div className="ph">Scheduled Operations</div>
<div className="pb">
<div style={{fontSize:12,color:"var(--dm)",marginBottom:12,lineHeight:1.6}}>
Schedule recurring tasks like automatic backups or connectivity checks.
Tasks run in the background and send ntfy alerts on failure (if configured).
</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr 80px 80px 1fr",gap:12,alignItems:"flex-end"}}>
<div className="field"><label>Name</label>
<input value={schedForm.name} onChange={e => setSchedForm(f => ({...f, name: e.target.value}))}
placeholder="nightly-backup"/>
</div>
<div className="field"><label>Action</label>
<select value={schedForm.action} onChange={e => setSchedForm(f => ({...f, action: e.target.value}))}>
<option value="backup">Backup</option>
<option value="connectivity_check">Connectivity Check</option>
</select>
</div>
{schedForm.action === "backup" && (
<div className="field"><label>Device</label>
<select value={schedForm.device} onChange={e => setSchedForm(f => ({...f, device: e.target.value}))}>
<option value="both">Both</option>
<option value="switch">Switch only</option>
<option value="opnsense">OPNsense only</option>
</select>
</div>
)}
<div className="field"><label>Hour</label>
<input value={schedForm.hour} onChange={e => setSchedForm(f => ({...f, hour: e.target.value}))}
placeholder="3" style={{textAlign:"center"}}/>
</div>
<div className="field"><label>Minute</label>
<input value={schedForm.minute} onChange={e => setSchedForm(f => ({...f, minute: e.target.value}))}
placeholder="0" style={{textAlign:"center"}}/>
</div>
<div className="field"><label>Days (* = every day)</label>
<input value={schedForm.days} onChange={e => setSchedForm(f => ({...f, days: e.target.value}))}
placeholder="mon,wed,fri or *"/>
</div>
</div>
<button className="btn bp" onClick={addSchedule} disabled={!schedForm.name}
style={{marginTop:12}}>Add Schedule</button>
{schedules.length > 0 && (
<div style={{marginTop:16}}>
<table className="vtbl">
<thead><tr><th>Name</th><th>Action</th><th>Time</th><th>Days</th><th>Status</th><th></th></tr></thead>
<tbody>
{schedules.map((s,i) => (
<tr key={i}>
<td style={{fontWeight:700}}>{s.name}</td>
<td>{s.action}{s.device ? ` (${s.device})` : ""}</td>
<td style={{fontFamily:"monospace"}}>{s.hour || "*"}:{(s.minute || "0").padStart(2,"0")}</td>
<td style={{fontSize:11}}>{s.days || "*"}</td>
<td><span style={{color:s.enabled!==false?"#00e676":"var(--dm)",fontSize:11}}>
{s.enabled!==false?"active":"disabled"}</span></td>
<td style={{display:"flex",gap:4}}>
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
onClick={() => runNow(s.name)}>Run Now</button>
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
onClick={() => deleteSchedule(s.name)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
</div>
);
}