Files
dcstephenson 9610fc66d3 Use dedicated RADIUS credentials for Web Access Control SSH login
Switches authenticate SSH via RADIUS (aaa authentication login ssh
radius local), which is separate from the local admin account used
for the HTTPS GUI and baked into generated configs. Web Access
Control (HTTPS enable/disable and Disable Telnet) was reusing that
local admin credential (or per-switch NocoDB creds) for its SSH
connection, which is the wrong credential pair.

Adds a dedicated SSH Credentials (RADIUS) section in Settings and a
getSshCredentials() helper that Web Access Control uses exclusively,
plus an upfront error toast if those fields are left blank.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLfUJoQTaUePx1m1d2v2j9
2026-08-18 11:39:13 -05:00

1450 lines
47 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Switch Config Manager</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0e14;
--surface: #111720;
--surface2: #171f2e;
--border: #1e2d42;
--accent: #00b4d8;
--accent2: #0077a8;
--green: #00e676;
--yellow: #ffd166;
--red: #ff4d6d;
--text: #c9d6e3;
--muted: #4a6080;
--mono: 'JetBrains Mono', monospace;
--sans: 'Inter', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 13px;
font-weight: 400;
min-height: 100vh;
}
/* ── Header ── */
header {
background: var(--surface);
border-bottom: 2px solid var(--accent);
padding: 0 24px;
height: 60px;
display: flex;
align-items: center;
gap: 14px;
}
.logo-badge {
width: 36px; height: 36px;
background: var(--accent2);
border: 1px solid var(--accent);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
font-size: 18px;
flex-shrink: 0;
}
header .logo {
font-family: var(--sans);
font-size: 16px;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.5px;
text-transform: uppercase;
line-height: 1;
}
header .logo .sub {
display: block;
font-family: var(--sans);
font-size: 10px;
color: var(--muted);
letter-spacing: 1px;
font-weight: 400;
margin-top: 3px;
text-transform: uppercase;
}
header .logo span { color: var(--muted); }
.header-status {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
font-family: var(--mono);
font-size: 10px;
color: var(--muted);
letter-spacing: 1px;
}
.dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--muted);
}
.dot.green { background: var(--green); box-shadow: 0 0 6px var(--green); }
.dot.red { background: var(--red); box-shadow: 0 0 6px var(--red); }
/* ── Layout ── */
.layout {
display: grid;
grid-template-columns: 320px 1fr;
height: calc(100vh - 56px);
}
/* ── Left Panel ── */
.left-panel {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.panel-section {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.panel-section:last-child { border-bottom: none; }
.section-label {
font-family: var(--mono);
font-size: 9px;
letter-spacing: 3px;
color: var(--muted);
text-transform: uppercase;
margin-bottom: 10px;
}
/* Switch selector */
select, input[type="text"], input[type="password"] {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
font-family: var(--sans);
font-size: 13px;
font-weight: 400;
padding: 7px 10px;
border-radius: 3px;
outline: none;
transition: border-color 0.2s;
}
select:focus, input:focus {
border-color: var(--accent);
}
select option { background: var(--surface2); font-family: var(--sans); }
/* Switch info card */
.switch-card {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 3px;
padding: 12px;
font-family: var(--sans);
font-size: 12px;
font-weight: 400;
line-height: 1.9;
display: none;
}
.switch-card.visible { display: block; }
.switch-card .row { display: flex; justify-content: space-between; gap: 8px; }
.switch-card .key { color: var(--muted); font-family: var(--mono); font-size: 10px; letter-spacing: 1px; align-self: center; }
.switch-card .val { color: var(--text); font-weight: 600; text-align: right; }
.dept-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 3px;
font-family: var(--sans);
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
}
.dept-ELEC { background: #003d66; color: #00b4d8; border: 1px solid #005a99; }
.dept-GW { background: #003300; color: #00e676; border: 1px solid #005500; }
.dept-SEC { background: #4d1a00; color: #ffd166; border: 1px solid #7a2a00; }
.dept-AMI { background: #2a003d; color: #cc88ff; border: 1px solid #5500aa; }
/* Settings accordion */
.accordion-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
}
.accordion-header .chevron {
color: var(--muted);
font-size: 10px;
transition: transform 0.2s;
}
.accordion-header.open .chevron { transform: rotate(180deg); }
.accordion-body {
display: none;
margin-top: 12px;
}
.accordion-body.open { display: block; }
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.setting-item { display: flex; flex-direction: column; gap: 4px; }
.setting-item.full { grid-column: 1 / -1; }
.setting-label {
font-family: var(--mono);
font-size: 9px;
color: var(--muted);
letter-spacing: 2px;
text-transform: uppercase;
}
.setting-item input {
font-size: 13px;
padding: 6px 8px;
}
/* Save checkbox */
.save-row {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
}
.save-row label {
font-family: var(--sans);
font-size: 13px;
font-weight: 500;
color: var(--text);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
/* Generate button */
.btn-generate {
margin: 16px 20px;
width: calc(100% - 40px);
padding: 10px;
background: var(--accent2);
border: 1px solid var(--accent);
color: #fff;
font-family: var(--sans);
font-size: 13px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
border-radius: 3px;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s;
}
.btn-generate:hover {
background: var(--accent);
box-shadow: 0 0 16px rgba(0,180,216,0.3);
}
.btn-generate:disabled {
background: var(--surface2);
border-color: var(--border);
color: var(--muted);
cursor: not-allowed;
box-shadow: none;
}
/* Saved files list */
.files-list {
min-height: 60px;
padding: 8px 0;
}
.file-item {
padding: 7px 20px;
font-family: var(--sans);
font-size: 12px;
font-weight: 400;
color: var(--muted);
display: flex;
align-items: center;
gap: 8px;
cursor: default;
transition: background 0.15s;
}
.file-item:hover { background: var(--surface2); color: var(--text); }
.file-item .file-icon { color: var(--accent2); font-size: 10px; }
/* ── Right Panel (config preview) ── */
.right-panel {
display: flex;
flex-direction: column;
overflow: hidden;
}
.preview-header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 12px 24px;
display: flex;
align-items: center;
gap: 12px;
}
.preview-title {
font-family: var(--mono);
font-size: 10px;
color: var(--muted);
letter-spacing: 2px;
text-transform: uppercase;
}
.preview-filename {
font-family: var(--sans);
font-size: 13px;
font-weight: 600;
color: var(--accent);
}
.config-preview {
flex: 1;
overflow-y: auto;
padding: 24px;
background: var(--bg);
}
.config-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: var(--muted);
font-family: var(--mono);
font-size: 12px;
letter-spacing: 1px;
}
.config-empty .big { font-size: 48px; opacity: 0.15; }
pre.config-code {
font-family: var(--mono);
font-size: 12px;
line-height: 1.7;
color: var(--text);
white-space: pre;
tab-size: 4;
}
/* syntax highlights */
.cfg-comment { color: var(--muted); }
.cfg-keyword { color: var(--accent); }
.cfg-value { color: var(--green); }
.cfg-section { color: var(--yellow); font-weight: 600; }
.cfg-bang { color: var(--muted); }
/* ── Modal ── */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.7);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-overlay.show { display: flex; }
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 28px 32px;
max-width: 400px;
width: 90%;
font-family: var(--sans);
}
.modal h3 {
font-size: 16px;
color: var(--yellow);
margin-bottom: 12px;
font-family: var(--mono);
letter-spacing: 1px;
}
.modal p {
font-size: 14px;
color: var(--text);
line-height: 1.6;
margin-bottom: 20px;
}
.modal-btns { display: flex; gap: 10px; justify-content: flex-end; }
.btn-sm {
padding: 8px 18px;
border-radius: 4px;
font-family: var(--mono);
font-size: 12px;
cursor: pointer;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
transition: background 0.2s;
}
.btn-sm:hover { background: var(--border); }
.btn-sm.danger {
background: #4d0015;
border-color: var(--red);
color: var(--red);
}
.btn-sm.danger:hover { background: #7a0020; }
/* ── Toast ── */
.toast {
position: fixed;
bottom: 24px; right: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: 12px 18px;
font-family: var(--mono);
font-size: 12px;
color: var(--text);
transform: translateY(80px);
opacity: 0;
transition: transform 0.3s, opacity 0.3s;
z-index: 200;
display: flex;
align-items: center;
gap: 10px;
}
.toast.show { transform: translateY(0); opacity: 1; }
.toast.ok { border-color: var(--green); color: var(--green); }
.toast.err { border-color: var(--red); color: var(--red); }
/* ── Web Access Control ── */
.wa-section { display: none; }
.wa-section.visible { display: block; }
.wa-target-row { margin-bottom: 10px; }
.wa-slider-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.wa-slider-row label {
font-family: var(--mono);
font-size: 9px;
color: var(--muted);
letter-spacing: 2px;
text-transform: uppercase;
white-space: nowrap;
}
.wa-slider-row input[type="range"] {
flex: 1;
accent-color: var(--accent);
padding: 0;
border: none;
background: transparent;
}
.wa-concurrency-val {
font-family: var(--mono);
font-size: 12px;
color: var(--accent);
min-width: 14px;
text-align: right;
}
.wa-btn-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.btn-wa {
padding: 9px 6px;
border-radius: 3px;
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s;
text-transform: uppercase;
}
.btn-wa:disabled { opacity: 0.35; cursor: not-allowed; }
.btn-wa-enable {
background: #003320;
border: 1px solid var(--green);
color: var(--green);
}
.btn-wa-enable:not(:disabled):hover {
background: #004d30;
box-shadow: 0 0 12px rgba(0,230,118,0.25);
}
.btn-wa-disable {
background: #3d0010;
border: 1px solid var(--red);
color: var(--red);
}
.btn-wa-disable:not(:disabled):hover {
background: #5c0018;
box-shadow: 0 0 12px rgba(255,77,109,0.25);
}
/* ── Web Access Results (right panel) ── */
.wa-results-panel {
display: none;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.wa-results-panel.active { display: flex; }
.wa-results-header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 12px 24px;
display: flex;
align-items: center;
gap: 12px;
}
.wa-results-title {
font-family: var(--mono);
font-size: 10px;
color: var(--muted);
letter-spacing: 2px;
text-transform: uppercase;
}
.wa-action-badge {
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
padding: 2px 10px;
border-radius: 3px;
letter-spacing: 1px;
}
.wa-action-badge.enable {
background: #003320;
color: var(--green);
border: 1px solid var(--green);
}
.wa-action-badge.disable {
background: #3d0010;
color: var(--red);
border: 1px solid var(--red);
}
.wa-progress-text {
margin-left: auto;
font-family: var(--mono);
font-size: 10px;
color: var(--muted);
}
.wa-results-body {
flex: 1;
overflow-y: auto;
padding: 16px 24px;
background: var(--bg);
}
.wa-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
margin-bottom: 4px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 3px;
font-family: var(--mono);
font-size: 12px;
}
.wa-row .wa-hostname { color: var(--text); flex: 0 0 160px; }
.wa-row .wa-ip { color: var(--muted); flex: 0 0 120px; }
.wa-row .wa-status { flex: 1; }
.wa-status.pending { color: var(--muted); }
.wa-status.running { color: var(--yellow); }
.wa-status.ok { color: var(--green); }
.wa-status.err { color: var(--red); }
/* ── Generate button note ── */
.btn-generate-note {
font-family: var(--mono);
font-size: 9px;
color: var(--muted);
text-align: center;
padding: 0 20px 10px;
letter-spacing: 1px;
display: none;
}
/* scrollbar */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
</style>
</head>
<body>
<header>
<div class="logo-badge"></div>
<div class="logo">SWITCH CONFIG MANAGER<span class="sub">FIBER SWITCHES // TEMPLATE OPS</span></div>
<div class="header-status">
<div class="dot" id="noco-dot"></div>
<span id="noco-status">CONNECTING...</span>
</div>
</header>
<div class="layout">
<!-- ── LEFT PANEL ── -->
<div class="left-panel">
<!-- Switch selector -->
<div class="panel-section">
<div class="section-label">Select Switch</div>
<select id="switch-select">
<option value="">— Loading switches... —</option>
</select>
</div>
<!-- Switch info card -->
<div class="panel-section">
<div class="section-label">Switch Details</div>
<div class="switch-card" id="switch-card">
<div class="row"><span class="key">HOSTNAME</span><span class="val" id="ci-hostname"></span></div>
<div class="row"><span class="key">IP</span><span class="val" id="ci-ip"></span></div>
<div class="row"><span class="key">LOCATION</span><span class="val" id="ci-location"></span></div>
<div class="row"><span class="key">ASSET TAG</span><span class="val" id="ci-asset"></span></div>
<div class="row"><span class="key">DEPT</span><span class="val" id="ci-dept"></span></div>
<div class="row"><span class="key">MAC</span><span class="val" id="ci-mac"></span></div>
<div class="row"><span class="key">USERNAME</span><span class="val" id="ci-username"></span></div>
<div class="row"><span class="key">PASSWORD</span><span class="val" id="ci-password"></span></div>
</div>
</div>
<!-- Web Access Control -->
<div class="panel-section wa-section" id="wa-section">
<div class="section-label">Web Access Control</div>
<div class="wa-target-row">
<div class="setting-label" style="margin-bottom:5px">TARGET</div>
<select id="wa-target">
<option value="selected">Selected Switch</option>
<option value="fs">All FS Switches</option>
<option value="extreme">All Extreme Switches</option>
<option value="all">All Active Switches</option>
</select>
</div>
<div class="wa-slider-row" style="margin-top:10px">
<label for="wa-concurrency">CONCURRENCY</label>
<input type="range" id="wa-concurrency" min="1" max="10" value="2">
<span class="wa-concurrency-val" id="wa-concurrency-val">2</span>
</div>
<div class="wa-btn-row">
<button class="btn-wa btn-wa-enable" id="btn-wa-enable" disabled>ENABLE</button>
<button class="btn-wa btn-wa-disable" id="btn-wa-disable" disabled>DISABLE</button>
</div>
<div class="setting-label" style="margin:14px 0 5px">Telnet Access</div>
<div class="wa-btn-row" style="grid-template-columns: 1fr">
<button class="btn-wa btn-wa-disable" id="btn-wa-disable-telnet" disabled>DISABLE TELNET</button>
</div>
</div>
<!-- Settings accordion -->
<div class="panel-section">
<div class="accordion-header" id="settings-toggle">
<div class="section-label" style="margin:0">Settings</div>
<span class="chevron"></span>
</div>
<div class="accordion-body" id="settings-body">
<div style="margin-bottom:10px">
<div class="section-label" style="margin-bottom:6px">VLANs</div>
<div class="settings-grid">
<div class="setting-item">
<span class="setting-label">MGMT VLAN #</span>
<input type="text" id="s-vlan-mgmt" value="214">
</div>
<div class="setting-item">
<span class="setting-label">MGMT NAME</span>
<input type="text" id="s-vlan-mgmt-name" value="SWITCHES">
</div>
<div class="setting-item">
<span class="setting-label">ELEC DATA #</span>
<input type="text" id="s-vlan-elec" value="217">
</div>
<div class="setting-item">
<span class="setting-label">ELEC NAME</span>
<input type="text" id="s-vlan-elec-name" value="ELEC_SCADA">
</div>
<div class="setting-item">
<span class="setting-label">GW DATA #</span>
<input type="text" id="s-vlan-gw" value="215">
</div>
<div class="setting-item">
<span class="setting-label">GW NAME</span>
<input type="text" id="s-vlan-gw-name" value="GW_SCADA">
</div>
<div class="setting-item">
<span class="setting-label">SEC DATA #</span>
<input type="text" id="s-vlan-sec" value="209">
</div>
<div class="setting-item">
<span class="setting-label">SEC NAME</span>
<input type="text" id="s-vlan-sec-name" value="SECURITY">
</div>
<div class="setting-item">
<span class="setting-label">AMI DATA #</span>
<input type="text" id="s-vlan-ami" value="203">
</div>
<div class="setting-item">
<span class="setting-label">AMI NAME</span>
<input type="text" id="s-vlan-ami-name" value="AMI">
</div>
<div class="setting-item">
<span class="setting-label">BLACKHOLE #</span>
<input type="text" id="s-vlan-bh" value="900">
</div>
</div>
</div>
<div style="margin-bottom:10px">
<div class="section-label" style="margin-bottom:6px">Network</div>
<div class="settings-grid">
<div class="setting-item full">
<span class="setting-label">MGMT GATEWAY</span>
<input type="text" id="s-gateway" value="10.214.0.1">
</div>
</div>
</div>
<div style="margin-bottom:10px">
<div class="section-label" style="margin-bottom:6px">Credentials</div>
<div class="settings-grid">
<div class="setting-item full">
<span class="setting-label">DEFAULT USERNAME</span>
<input type="text" id="s-username" value="admin">
</div>
<div class="setting-item full">
<span class="setting-label">DEFAULT PASSWORD (plaintext)</span>
<input type="text" id="s-password" value="RivieraUtilities1!">
</div>
<div class="setting-item full" style="margin-top:4px">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="s-use-nocodb-creds">
<span class="setting-label" style="margin:0">USE NOCODB CREDENTIALS IN TEMPLATE</span>
</label>
</div>
</div>
</div>
<div style="margin-bottom:10px">
<div class="section-label" style="margin-bottom:6px">SSH Credentials (RADIUS)</div>
<div class="settings-grid">
<div class="setting-item full">
<span class="setting-label">SSH USERNAME</span>
<input type="text" id="s-ssh-username" value="">
</div>
<div class="setting-item full">
<span class="setting-label">SSH PASSWORD (plaintext)</span>
<input type="text" id="s-ssh-password" value="">
</div>
</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px">
Used for the SSH login in Web Access Control (switches authenticate SSH via RADIUS, not the local admin account above).
</div>
</div>
<div style="margin-bottom:10px">
<div class="section-label" style="margin-bottom:6px">SNMP &amp; RADIUS</div>
<div class="settings-grid">
<div class="setting-item full">
<span class="setting-label">ENGINE ID</span>
<input type="text" id="s-engine-id" value="800007e5017f000001">
</div>
<div class="setting-item full">
<span class="setting-label">RADIUS KEY (plaintext)</span>
<input type="text" id="s-radius" value="4VXMKUA62QZHFG3FTYNCZ4Y">
</div>
<div class="setting-item full">
<span class="setting-label">SNMP AUTH (switchmon)</span>
<input type="text" id="s-snmp-switchmon-auth" value="7.gIeC2zMw6E6wX7sl-2">
</div>
<div class="setting-item full">
<span class="setting-label">SNMP PRIV (switchmon)</span>
<input type="text" id="s-snmp-switchmon-priv" value="haQE#qYeOEl!NIqkP3As">
</div>
</div>
</div>
</div>
</div>
<!-- Save to server checkbox -->
<div class="save-row">
<label>
<input type="checkbox" id="save-to-server">
Save to server
</label>
</div>
<!-- Generate button -->
<button class="btn-generate" id="btn-generate" disabled>GENERATE CONFIG</button>
<div class="btn-generate-note" id="btn-generate-note">TEMPLATE N/A — NON-FS SWITCH</div>
<!-- Saved files -->
<div class="panel-section" style="padding-bottom:6px">
<div class="section-label">Saved on Server</div>
</div>
<div class="files-list" id="files-list">
<div class="file-item" style="color:var(--muted);font-style:italic">No configs saved yet</div>
</div>
</div>
<!-- ── RIGHT PANEL ── -->
<div class="right-panel">
<!-- Config preview (default) -->
<div id="config-panel" style="display:flex;flex-direction:column;flex:1;overflow:hidden">
<div class="preview-header">
<span class="preview-title">CONFIG PREVIEW //</span>
<span class="preview-filename" id="preview-filename">none selected</span>
</div>
<div class="config-preview" id="config-preview">
<div class="config-empty">
<div class="big"></div>
<div>SELECT A SWITCH TO GENERATE CONFIG</div>
</div>
</div>
</div>
<!-- Web access results panel -->
<div class="wa-results-panel" id="wa-results-panel">
<div class="wa-results-header">
<span class="wa-results-title">WEB ACCESS //</span>
<span class="wa-action-badge" id="wa-action-badge"></span>
<span class="wa-progress-text" id="wa-progress-text"></span>
</div>
<div class="wa-results-body" id="wa-results-body"></div>
</div>
</div>
</div>
<!-- ── OVERWRITE MODAL ── -->
<div class="modal-overlay" id="overwrite-modal">
<div class="modal">
<h3>⚠ FILE EXISTS</h3>
<p id="overwrite-msg">A config file already exists on the server. Overwrite it?</p>
<div class="modal-btns">
<button class="btn-sm" id="modal-cancel">Cancel</button>
<button class="btn-sm danger" id="modal-overwrite">Overwrite</button>
</div>
</div>
</div>
<!-- ── TOAST ── -->
<div class="toast" id="toast"></div>
<script>
// ── State ──────────────────────────────────────────────────────────────────
let switches = [];
let selectedSwitch = null;
let pendingSaveContent = null;
let pendingSaveHostname = null;
// ── NocoDB load ────────────────────────────────────────────────────────────
async function loadSwitches() {
try {
const res = await fetch('/api/switches');
const data = await res.json();
if (!data.ok) throw new Error(data.error);
switches = data.switches;
populateSelect(switches);
setStatus(true, `${switches.length} SWITCHES`);
waSection.classList.add('visible');
updateWaButtons();
} catch (e) {
setStatus(false, 'NOCODB ERROR');
showToast('Failed to load switches: ' + e.message, 'err');
}
}
function setStatus(ok, msg) {
document.getElementById('noco-dot').className = 'dot ' + (ok ? 'green' : 'red');
document.getElementById('noco-status').textContent = msg;
}
function populateSelect(list) {
const sel = document.getElementById('switch-select');
sel.innerHTML = '<option value="">— Select a switch —</option>';
list.forEach(sw => {
const opt = document.createElement('option');
opt.value = sw.Id;
opt.textContent = `${sw.Hostname} (${sw.Dept}) ${sw.IP}`;
sel.appendChild(opt);
});
}
// ── Switch selection ───────────────────────────────────────────────────────
document.getElementById('switch-select').addEventListener('change', function() {
const id = parseInt(this.value);
selectedSwitch = switches.find(s => s.Id === id) || null;
updateCard();
updateGenerateButton();
updateWaButtons();
// Return to config panel view when switch changes
document.getElementById('config-panel').style.display = 'flex';
document.getElementById('wa-results-panel').classList.remove('active');
if (selectedSwitch) generatePreview();
else clearPreview();
});
function updateGenerateButton() {
const btn = document.getElementById('btn-generate');
const note = document.getElementById('btn-generate-note');
const isFS = selectedSwitch && selectedSwitch.Manufacturer === 'FS';
btn.disabled = !selectedSwitch || !isFS;
note.style.display = (selectedSwitch && !isFS) ? 'block' : 'none';
}
function updateCard() {
const card = document.getElementById('switch-card');
if (!selectedSwitch) { card.classList.remove('visible'); return; }
card.classList.add('visible');
const c = cfg();
document.getElementById('ci-hostname').textContent = selectedSwitch.Hostname || '—';
document.getElementById('ci-ip').textContent = selectedSwitch.IP || '—';
document.getElementById('ci-location').textContent = selectedSwitch.Location || '—';
document.getElementById('ci-asset').textContent = selectedSwitch['Asset Tag'] || '—';
document.getElementById('ci-mac').textContent = selectedSwitch.MAC || '—';
document.getElementById('ci-username').textContent = (selectedSwitch.Username || '').trim() || c.defaultUsername || 'admin';
document.getElementById('ci-password').textContent = (selectedSwitch.Password || '').trim() ? '••••••••' : `${c.defaultPassword} (default)`;
const dept = selectedSwitch.Dept || '—';
document.getElementById('ci-dept').innerHTML =
`<span class="dept-badge dept-${dept}">${dept}</span>`;
}
// ── Settings helpers ───────────────────────────────────────────────────────
function cfg() {
return {
vlanMgmt: document.getElementById('s-vlan-mgmt').value.trim(),
vlanMgmtName: document.getElementById('s-vlan-mgmt-name').value.trim(),
vlanElec: document.getElementById('s-vlan-elec').value.trim(),
vlanElecName: document.getElementById('s-vlan-elec-name').value.trim(),
vlanGw: document.getElementById('s-vlan-gw').value.trim(),
vlanGwName: document.getElementById('s-vlan-gw-name').value.trim(),
vlanSec: document.getElementById('s-vlan-sec').value.trim(),
vlanSecName: document.getElementById('s-vlan-sec-name').value.trim(),
vlanAmi: document.getElementById('s-vlan-ami').value.trim(),
vlanAmiName: document.getElementById('s-vlan-ami-name').value.trim(),
vlanBh: document.getElementById('s-vlan-bh').value.trim(),
gateway: document.getElementById('s-gateway').value.trim(),
defaultUsername: document.getElementById('s-username').value.trim(),
defaultPassword: document.getElementById('s-password').value.trim(),
sshUsername: document.getElementById('s-ssh-username').value.trim(),
sshPassword: document.getElementById('s-ssh-password').value.trim(),
engineId: document.getElementById('s-engine-id').value.trim(),
radius: document.getElementById('s-radius').value.trim(),
snmpSwitchmonAuth: document.getElementById('s-snmp-switchmon-auth').value.trim(),
snmpSwitchmonPriv: document.getElementById('s-snmp-switchmon-priv').value.trim(),
};
}
// ── Config generator ───────────────────────────────────────────────────────
function b64(str) {
return btoa(unescape(encodeURIComponent(str)));
}
function buildConfig(sw, c) {
const dept = (sw.Dept || '').toUpperCase();
const hostname = (sw.Hostname || 'UNKNOWN').trim().replace(/\r/g, '');
const ip = sw.IP || '0.0.0.0';
const location = sw.Location || '';
const asset = sw['Asset Tag'] || '';
const mac = (sw.MAC || '<MST-NAME>').trim().replace(/\r/g, '');
const engineId = c.engineId || '800007e5017f000001';
// Per-switch credentials with fallback to settings defaults
const useNocoCreds = document.getElementById('s-use-nocodb-creds').checked;
const username = useNocoCreds
? ((sw.Username || '').trim().replace(/\r/g, '') || c.defaultUsername || 'admin')
: (c.defaultUsername || 'admin');
const password = useNocoCreds
? ((sw.Password || '').trim().replace(/\r/g, '') || c.defaultPassword || 'RivieraUtilities1!')
: (c.defaultPassword || 'RivieraUtilities1!');
const passwordB64 = b64(password);
const radiusB64 = b64(c.radius);
// Dept-specific data vlan
let dataVlan, dataVlanName, port1Desc;
if (dept === 'ELEC') {
dataVlan = c.vlanElec; dataVlanName = c.vlanElecName; port1Desc = 'ELEC Scada';
} else if (dept === 'GW') {
dataVlan = c.vlanGw; dataVlanName = c.vlanGwName; port1Desc = 'GW Scada';
} else if (dept === 'SEC') {
dataVlan = c.vlanSec; dataVlanName = c.vlanSecName; port1Desc = 'Security';
} else if (dept === 'AMI') {
dataVlan = c.vlanAmi; dataVlanName = c.vlanAmiName; port1Desc = 'AMI';
} else {
dataVlan = c.vlanElec; dataVlanName = c.vlanElecName; port1Desc = 'Port 1';
}
// Build interface blocks
function accessPort(num, vlan, desc, shutdown = false) {
return [
`interface GigabitEthernet 1/${num}`,
` switchport access vlan ${vlan}`,
` no ip dhcp snooping trust`,
` no spanning-tree`,
` description ${desc}`,
shutdown ? ` shutdown` : null,
`!`
].filter(l => l !== null).join('\n');
}
function trunkPort(num, vlans, desc = 'NOTSET') {
return [
`interface GigabitEthernet 1/${num}`,
` switchport trunk allowed vlan ${vlans}`,
` switchport mode trunk`,
` no ip dhcp snooping trust`,
` description ${desc}`,
`!`
].join('\n');
}
// Port 1
let port1 = accessPort(1, dataVlan, port1Desc, false);
// Ports 2-8
let ports2to8 = '';
let secPorts = dept === 'SEC' ? [2,3,4] : [];
for (let i = 2; i <= 8; i++) {
if (secPorts.includes(i)) {
ports2to8 += accessPort(i, dataVlan, 'Security', false) + '\n';
} else {
ports2to8 += accessPort(i, c.vlanBh, 'NIU', true) + '\n';
}
}
ports2to8 = ports2to8.trimEnd();
// Ports 9-10
const trunkVlans = `${c.vlanMgmt},${dataVlan}`;
const port9 = trunkPort(9, trunkVlans);
const port10 = trunkPort(10, trunkVlans);
// Assemble
const lines = [
`hostname ${hostname}`,
`logging on`,
`logging level notice`,
`username ${username} privilege 15 password encrypted ${passwordB64}`,
`!`,
`vlan ${c.vlanMgmt}`,
` name ${c.vlanMgmtName}`,
`!`,
`vlan ${dataVlan}`,
` name ${dataVlanName}`,
`!`,
`vlan ${c.vlanBh}`,
` name BLACKHOLE`,
`!`,
`vlan 1`,
`!`,
`!`,
`!`,
`!`,
`no ip routing`,
`ip route 0.0.0.0 0.0.0.0 ${c.gateway}`,
`ip name-server 0 10.202.0.6`,
`ip name-server 1 10.202.0.14`,
`ip name-server 2 10.202.0.16`,
`ip name-server 3 10.202.0.15`,
`ip dhcp snooping`,
`sntp`,
`sntp server ip-address 10.202.0.16`,
`ip http secure-server`,
`ip http secure-redirect`,
`no ip telnet`,
`aggregation mode smac dmac ip port`,
`spanning-tree mst name ${mac} revision 0`,
`poe management mode allocation-consumption`,
`poe supply 250`,
`snmp-server engine-id local ${engineId}`,
`snmp-server version v3`,
`snmp-server company ${hostname.toUpperCase()}`,
`snmp-server contact ${asset}`,
`snmp-server location ${location}`,
`snmp-server user switchmon engine-id ${engineId} sha ${c.snmpSwitchmonAuth} priv aes ${c.snmpSwitchmonPriv}`,
`no snmp-server community v3 public`,
`no snmp-server community v3 private`,
`no snmp-server user default_user engine-id ${engineId} `,
`snmp-server security-to-group model v3 name switchmon group operatorauth`,
`no snmp-server security-to-group model v1 name public`,
`no snmp-server security-to-group model v1 name private`,
`no snmp-server security-to-group model v2c name public`,
`no snmp-server security-to-group model v2c name private`,
`no snmp-server security-to-group model v3 name default_user`,
`snmp-server view all-view .1 include`,
`snmp-server access operatorauth model any level priv read default_view `,
`no snmp-server access default_ro_group model any level noauth `,
`no snmp-server access default_rw_group model any level noauth `,
`aaa authentication login ssh radius local`,
`radius-server host 10.10.2.51 key-encrypted ${radiusB64}`,
`!`,
port1,
ports2to8,
port9,
port10,
`interface vlan 1`,
` no ip address`,
`!`,
`interface vlan ${c.vlanMgmt}`,
` ip address ${ip} 255.255.255.0`,
`!`,
`mep os-tlv oui 0xC sub-type 0x1 value 0x2`,
`!`,
`spanning-tree aggregation`,
` spanning-tree link-type point-to-point`,
`!`,
`!`,
];
// Add line vty blocks
for (let i = 0; i <= 15; i++) {
lines.push(`line vty ${i}`);
lines.push(`!`);
}
lines.push(`end`);
return lines.join('\n');
}
// ── Preview ────────────────────────────────────────────────────────────────
function generatePreview() {
if (!selectedSwitch) return;
const content = buildConfig(selectedSwitch, cfg());
const hostname = (selectedSwitch.Hostname || '').trim().replace(/\r/g, '');
document.getElementById('preview-filename').textContent = hostname + '.conf';
document.getElementById('config-preview').innerHTML =
`<pre class="config-code">${escapeHtml(content)}</pre>`;
}
function clearPreview() {
document.getElementById('preview-filename').textContent = 'none selected';
document.getElementById('config-preview').innerHTML = `
<div class="config-empty">
<div class="big">⌗</div>
<div>SELECT A SWITCH TO GENERATE CONFIG</div>
</div>`;
}
// Re-generate preview when any setting changes (inputs + the checkbox)
document.querySelectorAll('#settings-body input').forEach(el =>
el.addEventListener('input', () => { if (selectedSwitch) generatePreview(); })
);
document.getElementById('s-use-nocodb-creds').addEventListener('change', () => {
if (selectedSwitch) generatePreview();
});
// ── Generate / Download ────────────────────────────────────────────────────
document.getElementById('btn-generate').addEventListener('click', async () => {
if (!selectedSwitch) return;
const content = buildConfig(selectedSwitch, cfg());
const hostname = (selectedSwitch.Hostname || '').trim().replace(/\r/g, '');
const filename = hostname + '.conf';
const saveToServer = document.getElementById('save-to-server').checked;
if (saveToServer) {
// Try saving, check for conflict first
const res = await fetch('/api/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ hostname, content, overwrite: false })
});
const data = await res.json();
if (!data.ok && data.exists) {
// Show overwrite modal
pendingSaveContent = content;
pendingSaveHostname = hostname;
document.getElementById('overwrite-msg').textContent =
`"${filename}" already exists on the server. Overwrite it?`;
document.getElementById('overwrite-modal').classList.add('show');
return;
}
if (!data.ok) {
showToast('Save failed: ' + data.error, 'err');
return;
}
showToast(`Saved to server: ${filename}`, 'ok');
loadFilesList();
}
triggerDownload(content, filename);
});
async function doOverwrite() {
document.getElementById('overwrite-modal').classList.remove('show');
const res = await fetch('/api/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
hostname: pendingSaveHostname,
content: pendingSaveContent,
overwrite: true
})
});
const data = await res.json();
if (data.ok) {
showToast(`Overwritten: ${pendingSaveHostname}.conf`, 'ok');
loadFilesList();
triggerDownload(pendingSaveContent, pendingSaveHostname + '.conf');
} else {
showToast('Overwrite failed: ' + data.error, 'err');
}
}
function triggerDownload(content, filename) {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// ── Modal buttons ──────────────────────────────────────────────────────────
document.getElementById('modal-cancel').addEventListener('click', () => {
document.getElementById('overwrite-modal').classList.remove('show');
// Still download even if cancelled
if (pendingSaveContent && pendingSaveHostname) {
triggerDownload(pendingSaveContent, pendingSaveHostname + '.conf');
}
});
document.getElementById('modal-overwrite').addEventListener('click', doOverwrite);
// ── Saved files list ───────────────────────────────────────────────────────
async function loadFilesList() {
const res = await fetch('/api/configs');
const data = await res.json();
const el = document.getElementById('files-list');
if (!data.ok || !data.files.length) {
el.innerHTML = '<div class="file-item" style="color:var(--muted);font-style:italic">No configs saved yet</div>';
return;
}
el.innerHTML = data.files.map(f =>
`<div class="file-item"><span class="file-icon">▸</span>${f}</div>`
).join('');
}
// ── Settings accordion ─────────────────────────────────────────────────────
document.getElementById('settings-toggle').addEventListener('click', function() {
this.classList.toggle('open');
document.getElementById('settings-body').classList.toggle('open');
});
// ── Toast ──────────────────────────────────────────────────────────────────
let toastTimer;
function showToast(msg, type = '') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast show ' + type;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 3500);
}
// ── Helpers ────────────────────────────────────────────────────────────────
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── Init ───────────────────────────────────────────────────────────────────
loadSwitches();
loadFilesList();
// ── Web Access Control ─────────────────────────────────────────────────────
const waSection = document.getElementById('wa-section');
const waTarget = document.getElementById('wa-target');
const waConcurrency = document.getElementById('wa-concurrency');
const waConcurVal = document.getElementById('wa-concurrency-val');
const btnWaEnable = document.getElementById('btn-wa-enable');
const btnWaDisable = document.getElementById('btn-wa-disable');
const btnWaDisableTelnet = document.getElementById('btn-wa-disable-telnet');
waConcurrency.addEventListener('input', () => {
waConcurVal.textContent = waConcurrency.value;
});
waTarget.addEventListener('change', updateWaButtons);
function updateWaButtons() {
const target = waTarget.value;
const hasSelected = !!selectedSwitch;
const hasAny = switches.length > 0;
const enabled = (target === 'selected') ? hasSelected : hasAny;
btnWaEnable.disabled = !enabled;
btnWaDisable.disabled = !enabled;
btnWaDisableTelnet.disabled = !enabled;
}
function getWaTargetSwitches() {
const target = waTarget.value;
if (target === 'selected') return selectedSwitch ? [selectedSwitch] : [];
if (target === 'fs') return switches.filter(s => s.Manufacturer === 'FS');
if (target === 'extreme') return switches.filter(s =>
(s.Manufacturer || '').toLowerCase().includes('extreme'));
return switches; // all
}
// SSH login to the switches goes through RADIUS, not the local admin
// account used for the HTTPS GUI / baked into generated configs — so
// Web Access Control uses its own credential fields, not per-switch
// NocoDB creds or the DEFAULT USERNAME/PASSWORD settings.
function getSshCredentials() {
const c = cfg();
return {
username: c.sshUsername,
password: c.sshPassword,
};
}
// Concurrency-limited runner — runs tasks N at a time
async function runWithConcurrency(tasks, concurrency, onStart, onDone) {
const queue = [...tasks];
const active = new Set();
function runNext() {
while (active.size < concurrency && queue.length > 0) {
const task = queue.shift();
const p = (async () => {
onStart(task);
const result = await task.run();
onDone(task, result);
})();
active.add(p);
p.finally(() => { active.delete(p); runNext(); });
}
}
runNext();
// Wait until all finish
await new Promise(resolve => {
const check = setInterval(() => {
if (active.size === 0 && queue.length === 0) {
clearInterval(check);
resolve();
}
}, 100);
});
}
async function sendWebAccess(sw, action) {
const creds = getSshCredentials();
const res = await fetch('/api/web-access', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip: sw.IP, username: creds.username, password: creds.password, action })
});
return res.json();
}
function showWaResults(action, targetList) {
// Switch right panel to results view
document.getElementById('config-panel').style.display = 'none';
document.getElementById('wa-results-panel').classList.add('active');
const badge = document.getElementById('wa-action-badge');
badge.textContent = action.replace('_', ' ').toUpperCase();
badge.className = 'wa-action-badge ' + (action === 'enable' ? 'enable' : 'disable');
const body = document.getElementById('wa-results-body');
body.innerHTML = '';
const rows = {};
targetList.forEach(sw => {
const div = document.createElement('div');
div.className = 'wa-row';
div.innerHTML = `
<span class="wa-hostname">${sw.Hostname || sw.IP}</span>
<span class="wa-ip">${sw.IP}</span>
<span class="wa-status pending" id="wa-status-${sw.Id}">PENDING</span>`;
body.appendChild(div);
rows[sw.Id] = div.querySelector(`#wa-status-${sw.Id}`);
});
return rows;
}
function updateWaProgress(done, total) {
document.getElementById('wa-progress-text').textContent = `${done} / ${total}`;
}
async function runWaOperation(action) {
const targetList = getWaTargetSwitches();
if (!targetList.length) {
showToast('No switches match the selected target', 'err');
return;
}
const sshCreds = getSshCredentials();
if (!sshCreds.username || !sshCreds.password) {
showToast('Set SSH USERNAME / SSH PASSWORD (RADIUS) in Settings first', 'err');
return;
}
btnWaEnable.disabled = true;
btnWaDisable.disabled = true;
btnWaDisableTelnet.disabled = true;
const rows = showWaResults(action, targetList);
let done = 0;
updateWaProgress(0, targetList.length);
const concurrency = parseInt(waConcurrency.value, 10);
const tasks = targetList.map(sw => ({
sw,
run: () => sendWebAccess(sw, action)
}));
await runWithConcurrency(
tasks,
concurrency,
(task) => {
const el = rows[task.sw.Id];
if (el) { el.textContent = 'CONNECTING…'; el.className = 'wa-status running'; }
},
(task, result) => {
done++;
updateWaProgress(done, targetList.length);
const el = rows[task.sw.Id];
if (!el) return;
if (result.ok) {
el.textContent = 'OK';
el.className = 'wa-status ok';
} else {
el.textContent = 'ERR: ' + (result.error || 'unknown');
el.className = 'wa-status err';
}
}
);
showToast(`Web access ${action.replace('_', ' ')} complete — ${targetList.length} switches`, 'ok');
updateWaButtons();
}
btnWaEnable.addEventListener('click', () => runWaOperation('enable'));
btnWaDisable.addEventListener('click', () => runWaOperation('disable'));
btnWaDisableTelnet.addEventListener('click', () => runWaOperation('disable_telnet'));
</script>
</body>
</html>