Files
switch-config-manager/templates/index.html
T
2026-06-23 08:29:10 -05:00

1423 lines
46 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>
<!-- 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">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 (checkmk)</span>
<input type="text" id="s-snmp-checkmk-auth" value="2b5yJLzRhEzxtRCg">
</div>
<div class="setting-item full">
<span class="setting-label">SNMP PRIV (checkmk)</span>
<input type="text" id="s-snmp-checkmk-priv" value="9YW7fK7Cj8msh8VV">
</div>
<div class="setting-item full">
<span class="setting-label">SNMP AUTH (jdisc)</span>
<input type="text" id="s-snmp-jdisc-auth" value="eEEfQYfquytfwghs">
</div>
<div class="setting-item full">
<span class="setting-label">SNMP PRIV (jdisc)</span>
<input type="text" id="s-snmp-jdisc-priv" value="JR9qYGXyvBeX4fN9">
</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(),
engineId: document.getElementById('s-engine-id').value.trim(),
radius: document.getElementById('s-radius').value.trim(),
snmpCmkAuth: document.getElementById('s-snmp-checkmk-auth').value.trim(),
snmpCmkPriv: document.getElementById('s-snmp-checkmk-priv').value.trim(),
snmpJdAuth: document.getElementById('s-snmp-jdisc-auth').value.trim(),
snmpJdPriv: document.getElementById('s-snmp-jdisc-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`,
`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 checkmk engine-id ${engineId} sha ${c.snmpCmkAuth} priv aes ${c.snmpCmkPriv}`,
`snmp-server user jdisc engine-id ${engineId} sha ${c.snmpJdAuth} priv aes ${c.snmpJdPriv}`,
`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 checkmk group operatorauth`,
`snmp-server security-to-group model v3 name jdisc 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');
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;
}
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
}
function getCredentials(sw) {
const c = cfg();
return {
username: (sw.Username || '').trim() || c.defaultUsername || 'admin',
password: (sw.Password || '').trim() || c.defaultPassword || '',
};
}
// 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 = getCredentials(sw);
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.toUpperCase();
badge.className = 'wa-action-badge ' + action;
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;
}
btnWaEnable.disabled = true;
btnWaDisable.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} complete — ${targetList.length} switches`, 'ok');
updateWaButtons();
}
btnWaEnable.addEventListener('click', () => runWaOperation('enable'));
btnWaDisable.addEventListener('click', () => runWaOperation('disable'));
</script>
</body>
</html>