766d6e357c
The previous default (NZXMkUA62QZHfG3FTYNCz4Y) was already base64-encoded, causing the config generator to double-encode it via b64(). Updated to the correct plaintext key (4VXMKUA62QZHFG3FTYNCZ4Y) so b64() produces the right key-encrypted value in the switch config output. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1410 lines
46 KiB
HTML
1410 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; }
|
|
|
|
/* 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">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 & 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(),
|
|
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 {
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
// ── 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>
|