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
3235 lines
154 KiB
React
3235 lines
154 KiB
React
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
|
||
&& echo "DNSStubListener=no" | sudo tee -a /etc/systemd/resolved.conf
|
||
&& 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 & 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 & 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>
|
||
);
|
||
}
|