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, onClose }) { return (
e.stopPropagation()}>

◈ SETTINGS

Interface

CLI Mode
Advanced — type raw ERS commands directly

Push Behaviour

Default push mode
Batch or step-by-step

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.