Files
avaya_switch_management/ers5952-manager.jsx
T
Claude 360fbb5606 Add DNS enforcement, ACL templates, local hostnames, ctrld format fix
Features added:
- Port 53 conflict resolution: auto-detect/fix systemd-resolved stub listener
  on Linux; instructions for OPNsense Unbound (ctrld auto-terminates it)
- DNS enforcement ACLs: generate ERS 5952 ACL commands that permit DNS only
  to ctrld IP and block all other port 53/853 traffic per VLAN
- Inter-VLAN routing ACL templates: Staff, IoT, Guest, Camera profiles with
  live preview and parameter inputs (ctrld IP, NVR IP, subnet)
- Local hostname resolution: dnsmasq Docker service for .lan split-horizon DNS;
  manage hostname→IP mappings via UI; generates dnsmasq.conf and ctrld.toml
  upstream.local block
- Fix ctrld.toml format: correct [listener.0], [network.N], [upstream.N] table
  notation (was using wrong [[array]] notation); matches official docs format
- Backend docstrings: added docstrings to all previously undocumented functions
- README: new sections for port 53 conflict resolution, DNS enforcement ACLs,
  ACL templates, and local hostname resolution (dnsmasq)
- Fix Python 3.11 f-string syntax errors in Avaya_5952_setup.py (backslash
  in f-string expressions, same-type quote in dict access); embed now succeeds

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
2026-03-23 13:16:59 +00:00

3235 lines
154 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 VLAN — internet only, no RFC1918",
description: "Blocks access to all private IP ranges (RFC1918). IoT devices get internet but cannot reach other VLANs, servers, or management.",
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: "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" },
],
}),
},
];
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 tmpl = ACL_TEMPLATES.find(t => t.id === tpl);
const vlan = vlans.find(v => v.id === vid);
const subnet = `192.168.${vid}.0`;
const apply = () => {
const acl = tmpl.build({ subnet, vid, ctrldIp, nvrIp });
// 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)"}}>
{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>
)}
{/* Preview */}
{tmpl && (
<div style={{
background:"#060809",borderRadius:4,padding:"10px 12px",
fontFamily:"var(--mono)",fontSize:10,color:"#7090a0",
lineHeight:1.7,marginBottom:14,maxHeight:160,overflowY:"auto",
}}>
{tmpl.build({subnet,vid,ctrldIp,nvrIp}).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.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("ports");
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:"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" },
];
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==="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==="vpn" && <WireGuardTab
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>
);
}
function WireGuardTab({ session, onNeedAuth, backendOk }) {
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('');
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;
return (
<div className="main" style={{flexDirection:"column",gap:12}}>
<div className="panel">
<div className="ph"> 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">
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:12}}>
WireGuard lets you connect from anywhere home WiFi, coffee shop, anywhere
and reach the switch manager as if you were on the management network.
Each device gets its own key. Revoking a key disconnects that device immediately.
</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"> 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"> VPN 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 info */}
<div className="panel">
<div className="ph"> SSH Tunnel Power User Alternative</div>
<div className="pb">
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:8}}>
If WireGuard is not available, SSH port forwarding gives secure access
in one command. Run this on your remote machine:
</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> in your browser.
The management computer needs SSH accessible from outside (key auth only, consider fail2ban).
</div>
</div>
</div>
{qrModal && <QRModal config={qrModal.config} name={qrModal.name} onClose={()=>setQrModal(null)}/>}
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// DHCP MANAGEMENT TAB
// ══════════════════════════════════════════════════════════════════════════════
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";
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.descr || <span style={{color:"var(--dm)"}}></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>
{res.if && <span style={{fontSize:10,color:"var(--dm)",marginLeft:6}}>{res.if}</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>
{/* 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","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>
);
}