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 (
{state === "connecting" && } {state !== "connecting" && } {state === "connecting" && "Connecting to switch..."} {state === "connected" && (info || "Connected")} {state === "error" && (info || "Switch unreachable — retrying...")}
); } // ══════════════════════════════════════════════════════════════════════════════ // 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 (
e.stopPropagation()}>

◈ AUTHENTICATE

Enter your 6-digit TOTP code to authorize this push.
{commandCount} command{commandCount!==1?"s":""} ready to send.
Session locks automatically when push completes.

setCode(e.target.value.replace(/\D/g,"").slice(0,6))} onKeyDown={e => e.key==="Enter" && verify()} placeholder="000000" autoFocus maxLength={6}/> {error &&
{error}
}
); } // ══════════════════════════════════════════════════════════════════════════════ // 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 (
🔓 Session Active
); } return ( ); } // ══════════════════════════════════════════════════════════════════════════════ // SETTINGS PANEL // ══════════════════════════════════════════════════════════════════════════════ function SettingsPanel({ settings, setSettings, session, onClose }) { return (
e.stopPropagation()}>

◈ SETTINGS

Interface

CLI Mode
Advanced — type raw ERS commands directly

Push Behaviour

Default push mode
Batch or step-by-step

Network

Services / Caddy box LAN IP
The computer running Caddy and services (Plex, etc.) — separate from this management computer
setSettings(s => ({...s, caddyIp: e.target.value}))} placeholder="192.168.1.50" onBlur={() => { if (settings.caddyIp && session?.token) { fetch("/api/services/config", { method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({ token: session.token, config: { caddy_ip: settings.caddyIp } }) }).catch(() => {}); } }} style={{background:"var(--bg)",border:"1px solid var(--b2)",color:"var(--tx)", padding:"4px 8px",fontFamily:"var(--mono)",fontSize:11,borderRadius:3,width:140}}/>

About

ERS 5952 Switch Manager
Runs on your management computer on VLAN 99.
SSH key lives on the management computer only.
You never write CLI commands.
README.md has full documentation.
); } // ══════════════════════════════════════════════════════════════════════════════ // 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 ( {pushMode === "step" && !pushState?.done && } {groups.map((grp, gi) => ( <> {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 ( <> {pushMode === "step" && !pushState?.done && ( )} {result && !result.success && result.output && ( )} ); })} ))}
Command What it does
{grp.label}
{rowState === "success" && "✓"} {rowState === "failed" && "✗"} {rowState === "running" && } {(rowState === "pending" || rowState === "waiting") && "·"} {e.cmd} {e.explain} {isCurrentStep && ( )}
Switch error: {result.output}
); } // ══════════════════════════════════════════════════════════════════════════════ // 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 (
◈ CLI Mode — Advanced Danger check and allowlist still apply
Type or paste ERS CLI commands. Do not include enable, configure terminal, end, or copy — these are added automatically.