Files
rv50x-manager/index.html
T
D Stephenson 6c21525b79 Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 20:43:59 +00:00

1673 lines
88 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RV50x Template Manager</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&family=Share+Tech+Mono&family=Barlow:wght@400;500;600;700&family=Barlow+Condensed:wght@600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0c0f;
--bg2: #10141a;
--bg3: #161c25;
--border: #1e2a38;
--border2: #263545;
--text: #c8d8e8;
--text2: #6a8aa8;
--text3: #3a5068;
--accent: #00bfff;
--accent2: #0085cc;
--green: #00e676;
--green2: #00a152;
--red: #ff1744;
--red2: #b2102f;
--yellow: #ffea00;
--orange: #ff6d00;
--purple: #d580ff;
--purple2: #9c27b0;
--glow: 0 0 20px rgba(0,191,255,0.15);
--glow-g: 0 0 20px rgba(0,230,118,0.2);
--glow-p: 0 0 20px rgba(213,128,255,0.2);
--font-ui: 'Inter', sans-serif;
--font-mono: 'Inter', sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: var(--font-ui); font-size: 14px; min-height: 100vh; overflow-x: hidden; }
body::before { content: ''; position: fixed; inset: 0; background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.03) 2px, rgba(0,0,0,0.03) 4px); pointer-events: none; z-index: 9999; }
/* ── Header ── */
header { background: var(--bg2); border-bottom: 1px solid var(--border); padding: 0 24px; height: 56px; display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 100; }
.logo { display: flex; align-items: center; gap: 12px; }
.logo-icon { width: 32px; height: 32px; background: var(--accent); clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); animation: pulse-hex 3s ease-in-out infinite; }
@keyframes pulse-hex { 0%,100% { box-shadow: 0 0 0 0 rgba(0,191,255,0.4); } 50% { box-shadow: 0 0 0 8px rgba(0,191,255,0); } }
.logo-text { font-family: var(--font-ui); font-size: 20px; font-weight: 700; letter-spacing: 2px; color: var(--accent); text-transform: uppercase; }
.logo-sub { font-family: var(--font-mono); font-size: 10px; color: var(--text3); letter-spacing: 1px; }
.status-bar { display: flex; align-items: center; gap: 16px; font-family: var(--font-mono); font-size: 11px; color: var(--text3); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 6px var(--green); animation: blink 2s ease-in-out infinite; }
@keyframes blink { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
/* ── Layout ── */
.layout { display: grid; grid-template-columns: 280px 1fr; height: calc(100vh - 56px); }
aside { background: var(--bg2); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow-y: auto; }
.sidebar-section { border-bottom: 1px solid var(--border); padding: 16px; }
.sidebar-label { font-family: var(--font-mono); font-size: 10px; letter-spacing: 2px; color: var(--text3); text-transform: uppercase; margin-bottom: 12px; }
/* ── Group buttons ── */
.group-btns { display: flex; flex-direction: column; gap: 4px; }
.group-btn { background: transparent; border: 1px solid var(--border2); color: var(--text2); padding: 8px 12px; border-radius: 4px; cursor: pointer; font-family: var(--font-ui); font-size: 13px; font-weight: 500; text-align: left; transition: all 0.15s; display: flex; justify-content: space-between; align-items: center; }
.group-btn:hover { border-color: var(--accent); color: var(--accent); }
.group-btn.active { background: rgba(0,191,255,0.08); border-color: var(--accent); color: var(--accent); }
.group-count { font-family: var(--font-mono); font-size: 11px; background: var(--border); padding: 2px 6px; border-radius: 3px; }
/* ── Settings ── */
.setting-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.setting-label { font-size: 12px; color: var(--text2); }
.setting-value { font-family: var(--font-mono); font-size: 12px; color: var(--accent); }
input[type=range] { -webkit-appearance: none; width: 100px; height: 4px; background: var(--border2); border-radius: 2px; outline: none; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: var(--accent); cursor: pointer; }
.toggle { position: relative; width: 36px; height: 20px; }
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; inset: 0; background: var(--border2); border-radius: 20px; cursor: pointer; transition: 0.2s; }
.toggle-slider::before { content: ''; position: absolute; width: 14px; height: 14px; border-radius: 50%; left: 3px; top: 3px; background: var(--text3); transition: 0.2s; }
.toggle input:checked + .toggle-slider { background: var(--accent2); }
.toggle input:checked + .toggle-slider::before { transform: translateX(16px); background: var(--accent); }
/* ── Buttons ── */
.btn { padding: 10px 16px; border-radius: 4px; font-family: var(--font-ui); font-size: 15px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; cursor: pointer; border: none; transition: all 0.15s; display: flex; align-items: center; justify-content: center; gap: 8px; }
.btn-download { background: rgba(0,191,255,0.12); border: 1px solid var(--accent2); color: var(--accent); }
.btn-download:hover:not(:disabled) { background: rgba(0,191,255,0.2); box-shadow: var(--glow); }
.btn-upload { background: rgba(0,230,118,0.1); border: 1px solid var(--green2); color: var(--green); }
.btn-upload:hover:not(:disabled) { background: rgba(0,230,118,0.18); box-shadow: var(--glow-g); }
.btn-purple { background: rgba(213,128,255,0.1); border: 1px solid var(--purple2); color: var(--purple); }
.btn-purple:hover:not(:disabled) { background: rgba(213,128,255,0.18); box-shadow: var(--glow-p); }
.btn-neutral { background: var(--bg3); border: 1px solid var(--border2); color: var(--text2); }
.btn-neutral:hover:not(:disabled) { border-color: var(--text2); color: var(--text); }
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
.btn-sm { padding: 5px 10px; font-size: 11px; letter-spacing: 0.5px; }
.action-btns { display: flex; flex-direction: column; gap: 8px; }
/* ── File stats ── */
.file-stat { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid var(--border); font-size: 12px; }
.file-stat:last-child { border-bottom: none; }
.file-stat-name { color: var(--text2); font-family: var(--font-mono); }
.file-stat-size { color: var(--text3); }
/* ── Main ── */
main { display: flex; flex-direction: column; overflow: hidden; }
.main-tabs { display: flex; border-bottom: 1px solid var(--border); background: var(--bg2); padding: 0 24px; }
.tab { padding: 14px 20px; font-family: var(--font-ui); font-size: 13px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; color: var(--text3); cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.15s; background: none; border-top: none; border-left: none; border-right: none; }
.tab:hover { color: var(--text); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-content { display: none; flex: 1; overflow: hidden; }
.tab-content.active { display: flex; flex-direction: column; }
/* ── Device table ── */
.table-toolbar { display: flex; align-items: center; gap: 12px; padding: 12px 24px; border-bottom: 1px solid var(--border); background: var(--bg2); }
.search-input { background: var(--bg3); border: 1px solid var(--border2); color: var(--text); padding: 6px 12px; border-radius: 4px; font-family: var(--font-mono); font-size: 12px; width: 220px; outline: none; }
.search-input:focus { border-color: var(--accent); }
.search-input::placeholder { color: var(--text3); }
.toolbar-count { font-family: var(--font-mono); font-size: 11px; color: var(--text3); margin-left: auto; }
.table-wrap { flex: 1; overflow-y: auto; }
table { width: 100%; border-collapse: collapse; }
thead { position: sticky; top: 0; background: var(--bg3); z-index: 10; }
th { padding: 10px 16px; text-align: left; font-family: var(--font-mono); font-size: 10px; letter-spacing: 1.5px; color: var(--text3); text-transform: uppercase; border-bottom: 1px solid var(--border); font-weight: normal; cursor: pointer; user-select: none; }
th:hover { color: var(--text2); }
td { padding: 9px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
tr:hover td { background: rgba(255,255,255,0.02); }
.td-id { font-family: var(--font-mono); font-size: 12px; color: var(--accent); }
.td-ip { font-family: var(--font-mono); font-size: 12px; color: var(--text2); }
.td-loc { color: var(--text2); font-size: 12px; }
.badge { display: inline-block; padding: 2px 7px; border-radius: 3px; font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.5px; }
.badge-el { background: rgba(255,109,0,0.15); color: var(--orange); border: 1px solid rgba(255,109,0,0.3); }
.badge-gw { background: rgba(0,191,255,0.1); color: var(--accent); border: 1px solid rgba(0,191,255,0.25); }
.badge-uk { background: var(--border); color: var(--text3); }
.chk { cursor: pointer; accent-color: var(--accent); width: 14px; height: 14px; }
/* ── Log panel ── */
.log-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.log-toolbar { display: flex; align-items: center; gap: 10px; padding: 10px 24px; border-bottom: 1px solid var(--border); background: var(--bg2); }
.job-selector { background: var(--bg3); border: 1px solid var(--border2); color: var(--text); padding: 5px 10px; border-radius: 4px; font-family: var(--font-mono); font-size: 11px; outline: none; cursor: pointer; }
.log-output { flex: 1; overflow-y: auto; padding: 16px 24px; font-family: var(--font-mono); font-size: 12px; line-height: 1.8; background: var(--bg); }
.log-line { color: var(--text2); }
.log-line.success { color: var(--green); }
.log-line.error { color: var(--red); }
.log-line.warn { color: var(--yellow); }
.log-line.info { color: var(--accent); }
.results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; padding: 16px 24px; border-top: 1px solid var(--border); max-height: 200px; overflow-y: auto; background: var(--bg2); }
.result-card { background: var(--bg3); border: 1px solid var(--border); border-radius: 4px; padding: 8px 12px; display: flex; align-items: center; gap: 8px; }
.result-card.ok { border-left: 3px solid var(--green); }
.result-card.err { border-left: 3px solid var(--red); }
.result-id { font-family: var(--font-mono); font-size: 11px; color: var(--text2); }
.result-msg { font-size: 10px; color: var(--text3); }
.stats-bar { display: flex; gap: 24px; padding: 8px 24px; background: var(--bg2); border-top: 1px solid var(--border); font-family: var(--font-mono); font-size: 11px; }
.stat { display: flex; gap: 6px; align-items: center; }
.stat-label { color: var(--text3); }
.stat-value { color: var(--text); }
.stat-value.ok { color: var(--green); }
.stat-value.err { color: var(--red); }
.progress-wrap { height: 3px; background: var(--border); position: relative; overflow: hidden; }
.progress-bar { height: 100%; background: var(--accent); transition: width 0.3s ease; }
/* ── History ── */
.history-list { flex: 1; overflow-y: auto; padding: 16px 24px; }
.history-item { background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; padding: 12px 16px; margin-bottom: 8px; display: flex; align-items: center; gap: 16px; cursor: pointer; transition: border-color 0.15s; }
.history-item:hover { border-color: var(--border2); }
.history-type { font-family: var(--font-ui); font-size: 13px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; min-width: 80px; }
.history-type.download { color: var(--accent); }
.history-type.upload { color: var(--green); }
.history-meta { flex: 1; font-family: var(--font-mono); font-size: 11px; color: var(--text3); }
.history-counts { display: flex; gap: 12px; }
.h-ok { color: var(--green); }
.h-err { color: var(--red); }
.status-pill { padding: 3px 8px; border-radius: 3px; font-family: var(--font-mono); font-size: 10px; }
.status-pill.running { background: rgba(255,234,0,0.1); color: var(--yellow); border: 1px solid rgba(255,234,0,0.3); }
.status-pill.done { background: rgba(0,230,118,0.1); color: var(--green); border: 1px solid rgba(0,230,118,0.3); }
.status-pill.queued { background: rgba(0,191,255,0.1); color: var(--accent); border: 1px solid rgba(0,191,255,0.3); }
/* ── XML Builder ── */
.builder-layout { flex: 1; display: grid; grid-template-columns: 320px 1fr; overflow: hidden; }
.builder-sidebar { border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow-y: auto; background: var(--bg2); }
.builder-main { display: flex; flex-direction: column; overflow: hidden; }
.builder-section { border-bottom: 1px solid var(--border); padding: 16px; }
.builder-section-title { font-family: var(--font-mono); font-size: 10px; letter-spacing: 2px; color: var(--text3); text-transform: uppercase; margin-bottom: 12px; }
.source-tabs { display: flex; gap: 4px; margin-bottom: 12px; }
.source-tab { flex: 1; padding: 7px; text-align: center; border-radius: 4px; font-family: var(--font-ui); font-size: 12px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; cursor: pointer; border: 1px solid var(--border2); color: var(--text3); background: transparent; transition: all 0.15s; }
.source-tab.active { background: rgba(213,128,255,0.1); border-color: var(--purple2); color: var(--purple); }
.upload-zone { border: 1px dashed var(--border2); border-radius: 6px; padding: 16px; text-align: center; cursor: pointer; transition: all 0.15s; position: relative; overflow: hidden; isolation: isolate; }
.upload-zone:hover { border-color: var(--purple); background: rgba(213,128,255,0.05); }
.upload-zone input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; z-index: 1; }
.upload-zone-text { font-family: var(--font-mono); font-size: 11px; color: var(--text3); pointer-events: none; }
.upload-zone-text.loaded { color: var(--green); }
.field-tag { display: inline-block; background: rgba(213,128,255,0.1); border: 1px solid rgba(213,128,255,0.3); color: var(--purple); padding: 2px 7px; border-radius: 3px; font-family: var(--font-mono); font-size: 10px; margin: 2px; cursor: pointer; transition: all 0.15s; }
.field-tag:hover { background: rgba(213,128,255,0.2); }
.template-card { background: var(--bg3); border: 1px solid var(--border); border-radius: 4px; padding: 10px 12px; margin-bottom: 6px; cursor: pointer; transition: all 0.15s; display: flex; justify-content: space-between; align-items: center; }
.template-card:hover { border-color: var(--purple2); }
.template-card.selected { border-color: var(--purple); background: rgba(213,128,255,0.08); }
.template-card-name { font-family: var(--font-mono); font-size: 12px; color: var(--text); }
.template-card-vars { font-size: 10px; color: var(--text3); margin-top: 3px; }
.host-list { max-height: 260px; overflow-y: auto; border: 1px solid var(--border); border-radius: 4px; }
.host-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-family: var(--font-mono); font-size: 11px; color: var(--text2); }
.host-item:last-child { border-bottom: none; }
.host-item:hover { background: rgba(255,255,255,0.02); }
.builder-results { flex: 1; overflow-y: auto; padding: 16px 24px; }
.build-result-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 4px; margin-bottom: 4px; background: var(--bg2); border: 1px solid var(--border); }
.build-result-item.ok { border-left: 3px solid var(--green); }
.build-result-item.err { border-left: 3px solid var(--red); }
.form-input { background: var(--bg3); border: 1px solid var(--border2); color: var(--text); padding: 6px 10px; border-radius: 4px; font-family: var(--font-mono); font-size: 12px; width: 100%; outline: none; margin-bottom: 8px; }
.form-input:focus { border-color: var(--purple); }
.form-select { background: var(--bg3); border: 1px solid var(--border2); color: var(--text); padding: 6px 10px; border-radius: 4px; font-family: var(--font-mono); font-size: 12px; width: 100%; outline: none; margin-bottom: 8px; cursor: pointer; }
/* ── AT Terminal ── */
.at-layout { flex: 1; display: grid; grid-template-columns: 280px 1fr; overflow: hidden; }
.at-sidebar { border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow-y: auto; background: var(--bg2); }
.at-main { display: flex; flex-direction: column; overflow: hidden; }
.at-section { border-bottom: 1px solid var(--border); padding: 14px 16px; }
.at-section-title { font-family: var(--font-mono); font-size: 10px; letter-spacing: 2px; color: var(--text3); text-transform: uppercase; margin-bottom: 10px; }
.at-cred-row { margin-bottom: 8px; }
.at-label { font-size: 11px; color: var(--text3); margin-bottom: 3px; display: block; font-family: var(--font-mono); letter-spacing: 1px; }
.at-input { background: var(--bg3); border: 1px solid var(--border2); color: var(--text); padding: 5px 9px; font-family: var(--font-mono); font-size: 12px; width: 100%; outline: none; border-radius: 3px; }
.at-input:focus { border-color: var(--orange); }
.at-device-list { flex: 1; overflow-y: auto; }
.at-dev-item { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-bottom: 1px solid rgba(30,42,56,0.5); font-family: var(--font-mono); font-size: 11px; color: var(--text2); cursor: pointer; }
.at-dev-item:hover { background: rgba(255,109,0,0.03); }
.at-dev-item input[type=checkbox] { accent-color: var(--orange); width: 13px; height: 13px; cursor: pointer; }
.at-cmd-area { padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--bg2); }
.at-cmd-header { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.at-cmd-textarea { width: 100%; background: var(--bg3); border: 1px solid var(--border2); color: var(--text); font-family: var(--font-mono); font-size: 13px; padding: 8px 12px; resize: none; height: 72px; outline: none; line-height: 1.6; border-radius: 3px; }
.at-cmd-textarea:focus { border-color: var(--orange); }
.at-quick-cmds { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
.at-quick { background: transparent; border: 1px solid var(--border2); color: var(--text3); font-family: var(--font-mono); font-size: 10px; padding: 3px 8px; cursor: pointer; border-radius: 3px; transition: all 0.12s; letter-spacing: 0.5px; }
.at-quick:hover { border-color: var(--orange); color: var(--orange); background: rgba(255,109,0,0.06); }
.at-terminal { flex: 1; overflow-y: auto; padding: 14px 20px; font-family: var(--font-mono); font-size: 12px; line-height: 1.75; background: var(--bg); }
.at-line { display: grid; grid-template-columns: 70px 130px 1fr; gap: 8px; margin-bottom: 1px; }
.at-ts { color: var(--text3); font-size: 10px; padding-top: 2px; }
.at-dev { color: var(--orange); }
.at-msg { color: var(--text2); }
.at-msg.cmd { color: var(--accent); }
.at-msg.data { color: var(--text); }
.at-msg.ok { color: var(--green); }
.at-msg.error { color: var(--red); }
.at-msg.warn { color: var(--yellow); }
.at-msg.info { color: var(--text2); }
.at-sep { border: none; border-top: 1px solid var(--border); margin: 8px 0; }
.at-results-bar { display: flex; gap: 20px; padding: 8px 20px; background: var(--bg2); border-top: 1px solid var(--border); font-family: var(--font-mono); font-size: 11px; }
.btn-orange { background: rgba(255,109,0,0.12); border: 1px solid rgba(255,109,0,0.5); color: var(--orange); }
.btn-orange:hover:not(:disabled) { background: rgba(255,109,0,0.2); box-shadow: 0 0 16px rgba(255,109,0,0.2); }
/* ── Theme Panel ── */
.theme-toggle-btn {
background: transparent; border: 1px solid var(--border2); color: var(--text3);
width: 30px; height: 30px; border-radius: 6px; cursor: pointer; font-size: 15px;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
flex-shrink: 0; line-height: 1;
}
.theme-toggle-btn:hover { border-color: var(--accent); color: var(--accent); }
.theme-toggle-btn.open { border-color: var(--accent); color: var(--accent); background: rgba(0,191,255,0.08); }
.theme-panel {
position: fixed; top: 56px; right: 0; width: 300px;
background: var(--bg2); border-left: 1px solid var(--border); border-bottom: 1px solid var(--border);
z-index: 500; box-shadow: -6px 6px 24px rgba(0,0,0,0.4);
display: none; flex-direction: column; pointer-events: none;
}
.theme-panel.open { display: flex; pointer-events: all; }
.theme-panel-header {
padding: 12px 16px; border-bottom: 1px solid var(--border);
font-size: 11px; font-weight: 600; letter-spacing: 2px; color: var(--text3);
text-transform: uppercase; display: flex; justify-content: space-between; align-items: center;
}
.theme-panel-close {
background: transparent; border: none; color: var(--text3); cursor: pointer;
font-size: 16px; line-height: 1; padding: 0; transition: color 0.15s;
}
.theme-panel-close:hover { color: var(--text); }
.theme-section { padding: 14px 16px; border-bottom: 1px solid var(--border); }
.theme-section-label {
font-size: 10px; font-weight: 600; letter-spacing: 1.5px; color: var(--text3);
text-transform: uppercase; margin-bottom: 10px;
}
/* Preset swatches */
.theme-presets { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; }
.theme-swatch {
display: flex; flex-direction: column; align-items: center; gap: 5px;
cursor: pointer; padding: 6px 4px; border-radius: 6px; border: 1px solid transparent;
transition: all 0.15s;
}
.theme-swatch:hover { background: rgba(255,255,255,0.04); border-color: var(--border2); }
.theme-swatch.active { border-color: var(--accent); background: rgba(255,255,255,0.06); }
.swatch-dot {
width: 28px; height: 28px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.1);
position: relative; overflow: hidden;
}
.swatch-dot::after {
content: ''; position: absolute; inset: 0;
background: linear-gradient(135deg, var(--sw-bg) 50%, var(--sw-accent) 50%);
}
.swatch-label { font-size: 9px; color: var(--text3); text-align: center; white-space: nowrap; font-weight: 500; }
.theme-swatch.active .swatch-label { color: var(--accent); }
/* Custom accent */
.custom-accent-row { display: flex; align-items: center; gap: 10px; }
.accent-preview { width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--border2); flex-shrink: 0; }
input[type=color] {
width: 100%; height: 30px; border: 1px solid var(--border2); border-radius: 4px;
background: var(--bg3); cursor: pointer; padding: 2px; outline: none;
}
input[type=color]::-webkit-color-swatch-wrapper { padding: 0; }
input[type=color]::-webkit-color-swatch { border: none; border-radius: 3px; }
/* Background slider */
.bg-slider-row { display: flex; align-items: center; gap: 10px; }
.bg-slider-row input[type=range] { flex: 1; width: auto; }
.bg-slider-label { font-size: 10px; color: var(--text3); min-width: 28px; text-align: right; font-family: var(--font-mono); }
/* Font selector */
.font-options { display: flex; gap: 6px; }
.font-option {
flex: 1; padding: 7px 6px; text-align: center; border-radius: 4px; cursor: pointer;
border: 1px solid var(--border2); color: var(--text3); background: transparent;
font-size: 11px; font-weight: 500; transition: all 0.15s;
}
.font-option:hover { border-color: var(--border2); color: var(--text); background: rgba(255,255,255,0.03); }
.font-option.active { border-color: var(--accent); color: var(--accent); background: rgba(0,191,255,0.06); }
.font-option-preview { font-size: 16px; margin-bottom: 2px; }
/* Reset button */
.theme-reset {
width: 100%; padding: 8px; background: transparent; border: 1px solid var(--border2);
color: var(--text3); border-radius: 4px; cursor: pointer; font-size: 11px;
font-family: var(--font-ui); font-weight: 500; transition: all 0.15s;
}
.theme-reset:hover { border-color: var(--red2); color: var(--red); }
/* ── Misc ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text3); }
.spinner { width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--border2); border-top-color: var(--accent); animation: spin 0.7s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
.empty-state { text-align: center; padding: 48px 24px; color: var(--text3); font-family: var(--font-mono); font-size: 12px; }
.notice { font-family: var(--font-mono); font-size: 10px; color: var(--text3); margin-top: 6px; }
.notice.ok { color: var(--green); }
.notice.err { color: var(--red); }
</style>
</head>
<body>
<header>
<div class="logo">
<div class="logo-icon"></div>
<div>
<div class="logo-text">RV50x Manager</div>
<div class="logo-sub">SIERRA WIRELESS // TEMPLATE OPS</div>
</div>
</div>
<div class="status-bar">
<div class="status-dot"></div>
<span>CONNECTED TO NOCODB</span>
<span>|</span>
<span id="hdr-time">--:--:--</span>
<button class="theme-toggle-btn" id="theme-toggle-btn" onclick="toggleThemePanel()" title="Themes & Appearance">🎨</button>
<a href="/auth/logout" title="Sign out"
style="background:transparent;border:1px solid var(--border2);color:var(--text3);width:30px;height:30px;border-radius:6px;cursor:pointer;font-size:13px;display:flex;align-items:center;justify-content:center;transition:all 0.15s;text-decoration:none;flex-shrink:0;"
onmouseover="this.style.borderColor='var(--red2)';this.style.color='var(--red)'"
onmouseout="this.style.borderColor='var(--border2)';this.style.color='var(--text3)'"
title="Sign out"></a>
</div>
</header>
<!-- ── Theme Panel ── -->
<div class="theme-panel" id="theme-panel">
<div class="theme-panel-header">
<span>Appearance</span>
<button class="theme-panel-close" onclick="toggleThemePanel()"></button>
</div>
<!-- Presets -->
<div class="theme-section">
<div class="theme-section-label">Presets</div>
<div class="theme-presets">
<div class="theme-swatch active" id="sw-cyan" onclick="applyPreset('cyan')" title="Cyan (Default)">
<div class="swatch-dot" style="--sw-bg:#10141a;--sw-accent:#00bfff"></div>
<span class="swatch-label">Cyan</span>
</div>
<div class="theme-swatch" id="sw-nocodb" onclick="applyPreset('nocodb')" title="NocoDB">
<div class="swatch-dot" style="--sw-bg:#1a1d2e;--sw-accent:#7c3aed"></div>
<span class="swatch-label">NocoDB</span>
</div>
<div class="theme-swatch" id="sw-portainer" onclick="applyPreset('portainer')" title="Portainer">
<div class="swatch-dot" style="--sw-bg:#0d1117;--sw-accent:#00d2e6"></div>
<span class="swatch-label">Portainer</span>
</div>
<div class="theme-swatch" id="sw-amber" onclick="applyPreset('amber')" title="Amber">
<div class="swatch-dot" style="--sw-bg:#0d0a00;--sw-accent:#ffb300"></div>
<span class="swatch-label">Amber</span>
</div>
<div class="theme-swatch" id="sw-green" onclick="applyPreset('green')" title="Green">
<div class="swatch-dot" style="--sw-bg:#060d06;--sw-accent:#00e676"></div>
<span class="swatch-label">Green</span>
</div>
</div>
</div>
<!-- Custom accent -->
<div class="theme-section">
<div class="theme-section-label">Custom Accent Color</div>
<div class="custom-accent-row">
<div class="accent-preview" id="accent-preview" style="background:var(--accent)"></div>
<input type="color" id="custom-accent" value="#00bfff" oninput="applyCustomAccent(this.value)">
</div>
</div>
<!-- Background darkness -->
<div class="theme-section">
<div class="theme-section-label">Background Darkness</div>
<div class="bg-slider-row">
<span style="font-size:11px;color:var(--text3)">Darker</span>
<input type="range" id="bg-darkness" min="0" max="100" value="50" oninput="applyBgDarkness(this.value)">
<span style="font-size:11px;color:var(--text3)">Lighter</span>
<span class="bg-slider-label" id="bg-darkness-val">50</span>
</div>
</div>
<!-- Font -->
<div class="theme-section">
<div class="theme-section-label">Font Style</div>
<div class="font-options">
<button class="font-option active" id="font-inter" onclick="applyFont('inter')">
<div class="font-option-preview" style="font-family:'Inter',sans-serif">Aa</div>
Inter
</button>
<button class="font-option" id="font-barlow" onclick="applyFont('barlow')">
<div class="font-option-preview" style="font-family:'Barlow',sans-serif">Aa</div>
Barlow
</button>
<button class="font-option" id="font-mono" onclick="applyFont('mono')">
<div class="font-option-preview" style="font-family:'IBM Plex Mono',monospace">Aa</div>
Mono
</button>
</div>
</div>
<!-- Reset -->
<div class="theme-section">
<button class="theme-reset" onclick="resetTheme()">↺ Reset to Default</button>
</div>
</div>
<div class="layout">
<!-- ── Sidebar ── -->
<aside>
<div class="sidebar-section">
<div class="sidebar-label">Device Group</div>
<div class="group-btns">
<button class="group-btn active" data-group="All" onclick="selectGroup(this)">All Devices <span class="group-count" id="cnt-All"></span></button>
<button class="group-btn" data-group="Electric" onclick="selectGroup(this)">Electric <span class="group-count" id="cnt-Electric"></span></button>
<button class="group-btn" data-group="Gas & Water" onclick="selectGroup(this)">Gas &amp; Water <span class="group-count" id="cnt-Gas & Water"></span></button>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-label">Job Settings</div>
<div class="setting-row">
<span class="setting-label">Concurrency</span>
<div style="display:flex;align-items:center;gap:8px">
<input type="range" id="concurrency" min="1" max="5" value="1" oninput="document.getElementById('concurrency-val').textContent=this.value">
<span class="setting-value" id="concurrency-val">1</span>
</div>
</div>
<div class="setting-row">
<span class="setting-label">Reboot after upload</span>
<label class="toggle"><input type="checkbox" id="reboot" checked><span class="toggle-slider"></span></label>
</div>
<div class="setting-row">
<span class="setting-label">Live log stream</span>
<label class="toggle"><input type="checkbox" id="live-stream" checked><span class="toggle-slider"></span></label>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-label">Actions</div>
<div class="action-btns">
<button class="btn btn-download" id="btn-dl" onclick="startJob('download')">⬇ Download Templates</button>
<button class="btn btn-upload" id="btn-ul" onclick="startJob('upload')">⬆ Upload Templates</button>
</div>
<div class="notice" id="selection-info">All devices in group will be targeted</div>
</div>
<div class="sidebar-section" style="flex:1">
<div class="sidebar-label">Template Files</div>
<div style="margin-bottom:6px;font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3)">DOWNLOADS</div>
<div id="dl-files" style="margin-bottom:12px;max-height:100px;overflow-y:auto"></div>
<div style="margin-bottom:6px;font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3)">UPLOADS</div>
<div id="ul-files" style="max-height:100px;overflow-y:auto"></div>
</div>
</aside>
<!-- ── Main ── -->
<main>
<div class="main-tabs">
<button class="tab active" onclick="switchTab('devices',this)">Devices</button>
<button class="tab" onclick="switchTab('jobs',this)">Live / Logs</button>
<button class="tab" onclick="switchTab('history',this)">History</button>
<button class="tab" onclick="switchTab('builder',this)" style="color:var(--purple);border-bottom-color:transparent" id="tab-btn-builder">⬡ XML Builder</button>
<button class="tab" onclick="switchTab('at',this)" style="color:var(--orange);border-bottom-color:transparent" id="tab-btn-at">⌨ AT Terminal</button>
</div>
<!-- Devices tab -->
<div class="tab-content active" id="tab-devices">
<div class="table-toolbar">
<input class="search-input" type="text" placeholder="Search hostname, IP, location..." id="search" oninput="filterTable()">
<button class="btn btn-sm btn-download" onclick="selectAll(true)">SELECT ALL</button>
<button class="btn btn-sm btn-neutral" onclick="selectAll(false)">CLEAR</button>
<span class="toolbar-count" id="table-count"></span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:32px"><input type="checkbox" class="chk" id="chk-all" onclick="toggleAll(this)"></th>
<th onclick="sortTable('id')">Hostname</th>
<th onclick="sortTable('ip')">IP Address</th>
<th onclick="sortTable('dept')">Dept</th>
<th onclick="sortTable('location')">Location</th>
<th>DL File</th>
</tr>
</thead>
<tbody id="device-tbody"></tbody>
</table>
</div>
</div>
<!-- Jobs tab -->
<div class="tab-content" id="tab-jobs">
<div class="log-panel">
<div class="log-toolbar">
<span style="font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--text3)">JOB</span>
<select class="job-selector" id="job-selector" onchange="loadJob(this.value)">
<option value="">— select job —</option>
</select>
<span id="job-status-pill"></span>
<div style="margin-left:auto;display:flex;gap:8px;align-items:center">
<span id="stream-indicator" style="display:none;font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--yellow)"><span class="spinner"></span> streaming</span>
<button class="btn btn-sm" id="btn-abort-all" onclick="abortAllJobs()"
style="background:rgba(255,109,0,0.1);border:1px solid rgba(255,109,0,0.5);color:var(--orange);display:none">
✕ ABORT ALL
</button>
<button class="btn btn-sm" id="btn-abort-all" onclick="abortAllJobs()"
style="background:rgba(255,109,0,0.1);border:1px solid rgba(255,109,0,0.5);color:var(--orange);display:none">
✕ ABORT ALL
</button>
<button class="btn btn-sm" id="btn-abort" onclick="abortJob()"
style="background:rgba(255,23,68,0.1);border:1px solid var(--red2);color:var(--red);display:none">
✕ ABORT
</button>
<button class="btn btn-sm btn-neutral" onclick="refreshCurrentJob()">↻ REFRESH</button>
</div>
</div>
<div class="progress-wrap" id="progress-wrap" style="display:none"><div class="progress-bar" id="progress-bar" style="width:0%"></div></div>
<div class="log-output" id="log-output"><div class="empty-state">No job selected.</div></div>
<div class="results-grid" id="results-grid" style="display:none"></div>
<div class="stats-bar" id="stats-bar" style="display:none"></div>
</div>
</div>
<!-- History tab -->
<div class="tab-content" id="tab-history">
<div class="history-list" id="history-list"><div class="empty-state">No jobs run yet.</div></div>
</div>
<!-- XML Builder tab -->
<div class="tab-content" id="tab-builder">
<div class="builder-layout">
<!-- Builder sidebar -->
<div class="builder-sidebar">
<!-- Source selector -->
<div class="builder-section">
<div class="builder-section-title">Data Source</div>
<div class="source-tabs">
<button class="source-tab active" id="src-nocodb" onclick="selectSource('nocodb')">NocoDB</button>
<button class="source-tab" id="src-excel" onclick="selectSource('excel')">Excel</button>
</div>
<!-- NocoDB source -->
<div id="nocodb-source">
<div class="notice" style="margin-bottom:8px">Using live NocoDB data. Group filter applied from sidebar.</div>
<div style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3);margin-bottom:6px">AVAILABLE VARIABLES</div>
<div id="nocodb-fields"></div>
</div>
<!-- Excel source -->
<div id="excel-source" style="display:none">
<div class="upload-zone" id="excel-drop">
<input type="file" accept=".xlsx,.xls" onchange="uploadExcel(this)">
<div class="upload-zone-text" id="excel-zone-text">Click or drop Excel file (.xlsx)</div>
</div>
<div id="excel-columns" style="margin-top:8px"></div>
</div>
</div>
<!-- Template upload zone -->
<div class="builder-section">
<div class="builder-section-title">XML Template</div>
<div class="upload-zone">
<input type="file" accept=".xml" onchange="uploadTemplate(this)">
<div class="upload-zone-text" id="tpl-zone-text">Upload new template (.xml)</div>
</div>
</div>
<!-- Template list — separate section so file input cannot overlap cards -->
<div class="builder-section">
<div id="template-list"><div class="empty-state" style="padding:16px">No templates yet</div></div>
</div>
<!-- Default value -->
<div class="builder-section">
<div class="builder-section-title">Options</div>
<div class="setting-label" style="margin-bottom:4px">Default value for missing fields</div>
<input class="form-input" id="default-value" value="NOTSET" placeholder="NOTSET">
</div>
<!-- Device selection -->
<div class="builder-section" style="flex:1">
<div class="builder-section-title">Select Devices</div>
<div style="display:flex;gap:6px;margin-bottom:8px">
<button class="btn btn-sm btn-neutral" onclick="builderSelectAll(true)" style="flex:1">ALL</button>
<button class="btn btn-sm btn-neutral" onclick="builderSelectAll(false)" style="flex:1">CLEAR</button>
</div>
<div class="host-list" id="builder-host-list"><div class="empty-state" style="padding:16px">Load a data source first</div></div>
<div class="notice" id="builder-sel-count" style="margin-top:6px"></div>
</div>
<!-- Generate button -->
<div class="builder-section">
<button class="btn btn-purple" id="btn-generate" onclick="generateXMLs()" style="width:100%" disabled>
⬡ Generate XMLs → uploads/
</button>
<div class="notice" id="generate-notice"></div>
</div>
</div>
<!-- Builder main — results -->
<div class="builder-main">
<div style="padding:16px 24px;border-bottom:1px solid var(--border);background:var(--bg2);display:flex;align-items:center;justify-content:space-between">
<div>
<span style="font-family:'Barlow Condensed',sans-serif;font-size:16px;font-weight:700;color:var(--purple);letter-spacing:1px">XML BUILDER</span>
<span style="font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--text3);margin-left:12px" id="builder-template-label">No template selected</span>
</div>
<div style="font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--text3)" id="builder-stats"></div>
</div>
<!-- Variable reference panel -->
<div style="padding:12px 24px;border-bottom:1px solid var(--border);background:var(--bg2)">
<div style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3);margin-bottom:6px;letter-spacing:1px">TEMPLATE VARIABLES DETECTED</div>
<div id="tpl-vars-display" style="font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--text3)">Select a template to see its variables</div>
</div>
<div class="builder-results" id="builder-results">
<div class="empty-state">
Select a data source, choose a template,<br>pick devices, then click Generate.
<br><br>
Generated XMLs go directly to <span style="color:var(--accent)">template_uploads/</span><br>
and are ready to push to modems immediately.
</div>
</div>
</div>
</div>
</div>
<!-- AT Terminal tab -->
<div class="tab-content" id="tab-at">
<div class="at-layout">
<!-- AT Sidebar -->
<div class="at-sidebar">
<!-- SSH Credentials -->
<div class="at-section">
<div class="at-section-title">SSH Credentials</div>
<div class="at-cred-row">
<label class="at-label">USERNAME</label>
<input class="at-input" id="at-ssh-user" type="text" value="user" placeholder="user">
</div>
<div class="at-cred-row">
<label class="at-label">PASSWORD</label>
<input class="at-input" id="at-ssh-pass" type="password" placeholder="leave blank → use NocoDB password">
</div>
<div style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3);margin-top:4px">PORT: 3223 (fixed)</div>
</div>
<!-- Concurrency -->
<div class="at-section">
<div class="at-section-title">Parallel Sessions</div>
<div class="setting-row">
<span class="setting-label">Concurrency</span>
<span class="setting-value" id="at-conc-val">3</span>
</div>
<input type="range" min="1" max="10" value="3" id="at-concurrency" oninput="document.getElementById('at-conc-val').textContent=this.value">
</div>
<!-- Device select controls -->
<div class="at-section">
<div class="at-section-title">Target Devices</div>
<div style="display:flex;gap:6px;margin-bottom:8px">
<button class="btn btn-sm btn-neutral" onclick="atSelectAll(true)" style="flex:1">ALL</button>
<button class="btn btn-sm btn-neutral" onclick="atSelectAll(false)" style="flex:1">CLEAR</button>
</div>
<input class="at-input" id="at-search" type="text" placeholder="filter..." oninput="atFilterDevices()" style="margin-bottom:6px">
<div style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3)" id="at-sel-count">0 selected</div>
</div>
<!-- Device list -->
<div class="at-device-list" id="at-device-list">
<div class="empty-state" style="padding:20px">Loading devices…</div>
</div>
</div>
<!-- AT Main -->
<div class="at-main">
<!-- Command input area -->
<div class="at-cmd-area">
<div class="at-cmd-header">
<span style="font-family:'Barlow Condensed',sans-serif;font-size:15px;font-weight:700;color:var(--orange);letter-spacing:1px">AT COMMAND</span>
<span style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3)">one per line — all sent sequentially</span>
</div>
<textarea class="at-cmd-textarea" id="at-cmd-input" placeholder="ATI1&#10;AT*CELLINFO2?&#10;AT*NETRSSI?"></textarea>
<div class="at-quick-cmds">
<button class="at-quick" onclick="atSetCmd('ATZ')">ATZ — reboot</button>
<button class="at-quick" onclick="atSetCmd('ATI1')">ATI1 — fw ver</button>
<button class="at-quick" onclick="atSetCmd('AT*CELLINFO2?')">CELLINFO2</button>
<button class="at-quick" onclick="atSetCmd('AT*NETRSSI?')">RSSI</button>
<button class="at-quick" onclick="atSetCmd('AT*HOSTMODE?')">HOSTMODE</button>
<button class="at-quick" onclick="atSetCmd('AT*OPMODE?')">OPMODE</button>
<button class="at-quick" onclick="atSetCmd('AT*NETSTATE?')">NETSTATE</button>
<button class="at-quick" onclick="atSetCmd('AT*WWAN1HOMEPAGEURL?')">APN URL</button>
<button class="at-quick" onclick="atSetCmd('ATI1\nAT*CELLINFO2?\nAT*NETRSSI?')">📶 signal bundle</button>
</div>
<div style="display:flex;gap:10px;margin-top:10px;align-items:center">
<button class="btn btn-orange" id="at-send-btn" onclick="atSend()">▶ SEND</button>
<button class="btn btn-neutral btn-sm" onclick="atClearTerminal()">CLEAR LOG</button>
<span id="at-status-text" style="font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--text3);margin-left:auto"></span>
</div>
</div>
<!-- Terminal output -->
<div class="at-terminal" id="at-terminal">
<div style="color:var(--text3);font-family:'Share Tech Mono',monospace;font-size:11px">// AT TERMINAL READY — SELECT DEVICES AND ENTER COMMAND</div>
</div>
<!-- Results bar -->
<div class="at-results-bar" id="at-results-bar" style="display:none">
<span style="color:var(--text3)">SESSION COMPLETE</span>
<span style="color:var(--green)"><span id="at-res-ok">0</span> ok</span>
<span style="color:var(--red)"><span id="at-res-err">0</span> failed</span>
<span style="color:var(--text3)" id="at-res-devices"></span>
</div>
</div>
</div>
</div>
</main>
</div>
<script>
// ── State ──────────────────────────────────────────────────────────────────────
let allDevices = [];
let dlFiles = new Set();
let currentGroup = 'All';
let sortCol = 'id';
let sortAsc = true;
let streamES = null;
let pollTimer = null;
let currentJobId = null;
let abortRequested = false;
let linesReceived = 0;
let builderSource = 'nocodb';
let selectedTemplate = null;
let excelFilename = null;
let builderHostnames = [];
// ── Init ───────────────────────────────────────────────────────────────────────
(async function init() {
setInterval(() => {
document.getElementById('hdr-time').textContent = new Date().toLocaleTimeString('en-US',{hour12:false});
}, 1000);
await Promise.all([loadDevices(), loadFiles(), refreshJobList(), loadBuilderTemplates(), loadNocoBDFields()]);
setInterval(refreshJobList, 15000);
setInterval(loadFiles, 30000);
})();
// ── Device loading ─────────────────────────────────────────────────────────────
async function loadDevices(group) {
group = group || currentGroup;
const res = await fetch(`/api/devices?group=${encodeURIComponent(group)}`);
const data = await res.json();
allDevices = data.devices || [];
for (const g of ['All','Electric','Gas & Water']) {
const r = await fetch(`/api/devices?group=${encodeURIComponent(g)}`);
const d = await r.json();
const el = document.getElementById(`cnt-${g}`);
if (el) el.textContent = d.count || 0;
}
renderTable();
if (builderSource === 'nocodb') loadBuilderHostnames();
}
async function selectGroup(btn) {
document.querySelectorAll('.group-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentGroup = btn.dataset.group;
await loadDevices(currentGroup);
}
// ── Table ──────────────────────────────────────────────────────────────────────
function renderTable() {
const q = document.getElementById('search').value.toLowerCase();
const sorted = [...allDevices].sort((a,b) => {
const va = (a[sortCol]||'').toLowerCase(), vb = (b[sortCol]||'').toLowerCase();
return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
});
const filtered = sorted.filter(d => !q || d.id.toLowerCase().includes(q) || d.ip.toLowerCase().includes(q) || (d.location||'').toLowerCase().includes(q));
document.getElementById('table-count').textContent = `${filtered.length} / ${allDevices.length} devices`;
const tbody = document.getElementById('device-tbody');
tbody.innerHTML = filtered.map(d => {
const dept = (d.dept||'').toUpperCase();
const bc = dept==='ELEC'?'badge-el':dept==='GW'?'badge-gw':'badge-uk';
const hasDl = dlFiles.has(d.id+'.xml');
return `<tr>
<td><input type="checkbox" class="chk dev-chk" value="${d.id}"></td>
<td class="td-id">${d.id}</td><td class="td-ip">${d.ip}</td>
<td><span class="badge ${bc}">${dept||'?'}</span></td>
<td class="td-loc">${d.location||'—'}</td>
<td style="font-family:'Share Tech Mono',monospace;font-size:11px;color:${hasDl?'var(--green)':'var(--text3)'}">${hasDl?'✓ ready':'—'}</td>
</tr>`;
}).join('');
updateSelectionInfo();
}
function filterTable() { renderTable(); }
function sortTable(col) { if(sortCol===col) sortAsc=!sortAsc; else {sortCol=col;sortAsc=true;} renderTable(); }
function toggleAll(chk) { document.querySelectorAll('.dev-chk').forEach(c=>c.checked=chk.checked); updateSelectionInfo(); }
function selectAll(v) { document.querySelectorAll('.dev-chk').forEach(c=>c.checked=v); document.getElementById('chk-all').checked=v; updateSelectionInfo(); }
function getSelectedIds() { return [...document.querySelectorAll('.dev-chk:checked')].map(c=>c.value); }
function updateSelectionInfo() {
const sel = getSelectedIds();
document.getElementById('selection-info').textContent = sel.length ? `${sel.length} device${sel.length>1?'s':''} selected` : 'All devices in group will be targeted';
}
document.addEventListener('change', e => { if(e.target.classList.contains('dev-chk')) updateSelectionInfo(); });
// ── Files ──────────────────────────────────────────────────────────────────────
async function loadFiles() {
const [dlRes,ulRes] = await Promise.all([fetch('/api/files/downloads'),fetch('/api/files/uploads')]);
const dlData = await dlRes.json(), ulData = await ulRes.json();
dlFiles = new Set((dlData.files||[]).map(f=>f.name));
const renderFiles = (files,id) => {
const el = document.getElementById(id);
if(!files.length){el.innerHTML='<div style="color:var(--text3);font-family:\'Share Tech Mono\',monospace;font-size:11px">None</div>';return;}
el.innerHTML = files.slice(0,10).map(f=>`<div class="file-stat"><span class="file-stat-name">${f.name}</span><span class="file-stat-size">${f.size_kb} KB</span></div>`).join('') + (files.length>10?`<div style="color:var(--text3);font-size:11px;padding-top:4px">+${files.length-10} more</div>`:'');
};
renderFiles(dlData.files||[],'dl-files');
renderFiles(ulData.files||[],'ul-files');
renderTable();
}
// ── Jobs ───────────────────────────────────────────────────────────────────────
async function startJob(type) {
const ids = getSelectedIds(), conc = parseInt(document.getElementById('concurrency').value);
const reboot = document.getElementById('reboot').checked, live = document.getElementById('live-stream').checked;
document.getElementById('btn-dl').disabled = true; document.getElementById('btn-ul').disabled = true;
const res = await fetch(`/api/jobs/${type}`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({group:currentGroup,device_ids:ids,concurrency:conc,reboot})});
const data = await res.json();
document.getElementById('btn-dl').disabled = false; document.getElementById('btn-ul').disabled = false;
if(data.error){alert('Error: '+data.error);return;}
await refreshJobList();
switchTabById('jobs');
document.getElementById('job-selector').value = data.job_id;
loadJob(data.job_id, live);
}
async function loadJob(jobId, stream) {
if(!jobId) return;
// Only reset line counter when switching to a different job
if(currentJobId !== jobId) linesReceived = 0;
currentJobId = jobId;
if(streamES){streamES.close();streamES=null;}
if(pollTimer){clearInterval(pollTimer);pollTimer=null;}
document.getElementById('stream-indicator').style.display='none';
const job = await fetchJob(jobId); if(!job) return;
renderJobLogs(job);
if(job.status==='done'){renderResults(job);return;}
const live = stream!==undefined?stream:document.getElementById('live-stream').checked;
if(live) startStream(jobId);
else pollTimer = setInterval(async()=>{
const j=await fetchJob(jobId);if(!j)return;
renderJobLogs(j);
if(j.status==='done'){
clearInterval(pollTimer);
renderResults(j);
abortRequested=false;
const ab=document.getElementById('btn-abort'),aa=document.getElementById('btn-abort-all');
if(ab){ab.style.display='none';ab.disabled=false;ab.textContent='✕ ABORT';}
if(aa){aa.style.display='none';aa.disabled=false;aa.textContent='✕ ABORT ALL';}
refreshJobList();loadFiles();
}
},2000);
}
async function fetchJob(id){const r=await fetch(`/api/jobs/${id}`);return r.ok?r.json():null;}
function startStream(jobId, fromLine){
fromLine = fromLine || linesReceived;
document.getElementById('stream-indicator').style.display='flex';
streamES = new EventSource(`/api/jobs/${jobId}/stream?from_line=${fromLine}`);
streamES.onmessage = async e => {
const data = JSON.parse(e.data);
if(data.log){ appendLog(data.log); linesReceived++; }
if(data.done){
streamES.close();streamES=null;
document.getElementById('stream-indicator').style.display='none';
const j=await fetchJob(jobId);
if(j) renderResults(j);
abortRequested=false;
const ab=document.getElementById('btn-abort'),aa=document.getElementById('btn-abort-all');
if(ab){ab.style.display='none';ab.disabled=false;ab.textContent='✕ ABORT';}
if(aa){aa.style.display='none';aa.disabled=false;aa.textContent='✕ ABORT ALL';}
refreshJobList();loadFiles();
}
};
streamES.onerror=()=>{streamES.close();streamES=null;document.getElementById('stream-indicator').style.display='none';};
}
function renderJobLogs(job){
const pill=document.getElementById('job-status-pill');
pill.innerHTML=`<span class="status-pill ${job.status}">${job.status.toUpperCase()}</span>`;
// Sync job selector label
const sel=document.getElementById('job-selector');
if(sel.value===currentJobId){
const opt=[...sel.options].find(o=>o.value===currentJobId);
if(opt) opt.text=`${currentJobId} (${job.status})`;
}
const prog=document.getElementById('progress-wrap'),bar=document.getElementById('progress-bar');
const abortBtn=document.getElementById('btn-abort');
if(job.status==='running'){
prog.style.display='block';
if(job.device_count) bar.style.width=Math.round(job.results.length/job.device_count*100)+'%';
// Keep abort button visible while running, disable it if abort already requested
if(abortBtn){
abortBtn.style.display='flex';
abortBtn.disabled=abortRequested;
abortBtn.textContent=abortRequested?'⚠ ABORTING...':'✕ ABORT';
}
const abortAllBtn=document.getElementById('btn-abort-all');
if(abortAllBtn){ abortAllBtn.style.display='flex'; abortAllBtn.disabled=abortRequested; abortAllBtn.textContent=abortRequested?'⚠ ABORTING ALL...':'✕ ABORT ALL'; }
} else {
prog.style.display='none';
abortRequested=false;
if(abortBtn){ abortBtn.style.display='none'; abortBtn.disabled=false; abortBtn.textContent='✕ ABORT'; }
const abortAllBtn=document.getElementById('btn-abort-all');
if(abortAllBtn){ abortAllBtn.style.display='none'; abortAllBtn.disabled=false; abortAllBtn.textContent='✕ ABORT ALL'; }
}
const out=document.getElementById('log-output');
out.innerHTML=job.logs.map(l=>{
const cls=l.includes('✓')?'success':l.includes('✗')||l.includes('ERROR')||l.includes('TIMEOUT')?'error':l.includes('Attempt')||l.includes('Retry')?'warn':l.includes('Logged in')||l.includes('Saved')?'info':'';
return `<div class="log-line ${cls}">${escHtml(l)}</div>`;
}).join('')||'<div class="empty-state">Waiting for logs...</div>';
out.scrollTop=out.scrollHeight;
}
function appendLog(line){
const out=document.getElementById('log-output');
const empty=out.querySelector('.empty-state');if(empty)empty.remove();
const cls=line.includes('✓')?'success':line.includes('✗')||line.includes('ERROR')||line.includes('TIMEOUT')?'error':line.includes('Attempt')||line.includes('Retry')?'warn':line.includes('Logged')||line.includes('Saved')?'info':'';
const div=document.createElement('div');div.className=`log-line ${cls}`;div.textContent=line;
out.appendChild(div);out.scrollTop=out.scrollHeight;
}
function renderResults(job){
const grid=document.getElementById('results-grid'),stats=document.getElementById('stats-bar');
const ok=job.results.filter(r=>r.success),err=job.results.filter(r=>!r.success);
grid.style.display='grid';
grid.innerHTML=job.results.map(r=>`<div class="result-card ${r.success?'ok':'err'}"><span>${r.success?'✓':'✗'}</span><div><div class="result-id">${r.id}</div><div class="result-msg">${r.message||''}</div></div></div>`).join('');
const elapsed=job.finished?Math.round((new Date(job.finished)-new Date(job.started))/1000):'—';
stats.style.display='flex';
stats.innerHTML=`<div class="stat"><span class="stat-label">TOTAL</span><span class="stat-value">${job.results.length}</span></div><div class="stat"><span class="stat-label">OK</span><span class="stat-value ok">${ok.length}</span></div><div class="stat"><span class="stat-label">FAILED</span><span class="stat-value err">${err.length}</span></div><div class="stat"><span class="stat-label">TIME</span><span class="stat-value">${elapsed}s</span></div>`;
document.getElementById('progress-wrap').style.display='none';
}
async function refreshJobList(){
const res=await fetch('/api/jobs');const data=await res.json();const jobs=data.jobs||[];
const sel=document.getElementById('job-selector'),cur=sel.value;
sel.innerHTML='<option value="">— select job —</option>'+jobs.map(j=>`<option value="${j.job_id}" ${j.job_id===cur?'selected':''}>${j.job_id} (${j.status})</option>`).join('');
const hist=document.getElementById('history-list');
if(!jobs.length){hist.innerHTML='<div class="empty-state">No jobs run yet.</div>';return;}
hist.innerHTML=jobs.map(j=>`<div class="history-item" onclick="openJob('${j.job_id}')"><div class="history-type ${j.type}">${j.type}</div><div class="history-meta"><div>${j.job_id}</div><div>${j.started?new Date(j.started).toLocaleString():''}</div></div><div class="history-counts"><span class="h-ok">✓ ${j.succeeded}</span><span class="h-err">✗ ${j.failed}</span><span style="color:var(--text3)">${j.device_count} devices</span></div><span class="status-pill ${j.status}">${j.status.toUpperCase()}</span></div>`).join('');
}
async function refreshCurrentJob(){
// Refresh the job list dropdown + history
await refreshJobList();
// If a job is loaded in the log panel, fully reload its logs + results + pill
if(!currentJobId) return;
const job = await fetchJob(currentJobId);
if(!job) return;
renderJobLogs(job);
if(job.status === 'done'){
renderResults(job);
// Stop any active stream/poll since job is done
if(streamES){ streamES.close(); streamES=null; document.getElementById('stream-indicator').style.display='none'; }
if(pollTimer){ clearInterval(pollTimer); pollTimer=null; }
// Reset abort state and hide buttons
abortRequested = false;
const abortBtn=document.getElementById('btn-abort');
const abortAllBtn=document.getElementById('btn-abort-all');
if(abortBtn){ abortBtn.style.display='none'; abortBtn.disabled=false; abortBtn.textContent='✕ ABORT'; }
if(abortAllBtn){ abortAllBtn.style.display='none'; abortAllBtn.disabled=false; abortAllBtn.textContent='✕ ABORT ALL'; }
await loadFiles();
}
}
function openJob(id){switchTabById('jobs');document.getElementById('job-selector').value=id;loadJob(id);}
// ── XML Builder ────────────────────────────────────────────────────────────────
async function loadNocoBDFields(){
const res=await fetch('/api/xmlbuilder/nocodb-fields');
const data=await res.json();
const el=document.getElementById('nocodb-fields');
el.innerHTML=(data.fields||[]).map(f=>`<span class="field-tag" title="Use {${f}} in your template">{${f}}</span>`).join('');
}
// Template registry — stores full metadata by index, avoids any escaping issues
const templateRegistry = {};
async function loadBuilderTemplates(){
const res=await fetch('/api/xmlbuilder/templates');
const data=await res.json();
const tpls=data.templates||[];
const el=document.getElementById('template-list');
if(!tpls.length){
el.innerHTML='<div class="empty-state" style="padding:16px">No templates yet.<br>Upload an XML template above.</div>';
return;
}
// Store ALL templates in registry by numeric index — no escaping needed
tpls.forEach((t, i) => { templateRegistry[i] = t; });
el.innerHTML = tpls.map((t, i) => {
const varPreview = t.variables.slice(0,4).join(', ') + (t.variables.length > 4 ? '…' : '');
return `<div class="template-card" data-tpl-index="${i}">
<div>
<div class="template-card-name">${escHtml(t.name)}</div>
<div class="template-card-vars">${t.variables.length} variable${t.variables.length!==1?'s':''}: ${escHtml(varPreview)}</div>
</div>
<span style="color:var(--text3);font-family:'Share Tech Mono',monospace;font-size:10px">${t.size_kb} KB</span>
</div>`;
}).join('');
// Attach click handlers after rendering — no inline onclick needed
document.querySelectorAll('#template-list .template-card').forEach(card => {
card.addEventListener('click', () => {
const idx = parseInt(card.dataset.tplIndex);
const t = templateRegistry[idx];
if(!t) return;
// Clear all selections and mark this one
document.querySelectorAll('#template-list .template-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
// Update state
selectedTemplate = t.name;
document.getElementById('builder-template-label').textContent = t.name;
// Show variables
const varsEl = document.getElementById('tpl-vars-display');
varsEl.innerHTML = t.variables.length
? t.variables.map(v => `<span class="field-tag">{${escHtml(v)}}</span>`).join('')
: 'No variables detected';
checkBuilderReady();
});
});
// Re-highlight previously selected template after list reloads
if(selectedTemplate){
const match = tpls.findIndex(t => t.name === selectedTemplate);
if(match >= 0){
const cards = document.querySelectorAll('#template-list .template-card');
if(cards[match]) cards[match].click();
}
}
}
function selectTemplateById(sid){
// Legacy shim — find by name match
const idx = Object.keys(templateRegistry).find(i => escId(templateRegistry[i].name) === sid);
if(idx !== undefined){
document.querySelectorAll('.template-card')[idx]?.click();
}
}
function selectTemplate(safeId, name, variables){
document.querySelectorAll('.template-card').forEach(c=>c.classList.remove('selected'));
const card=document.getElementById(`tcard-${safeId}`);
if(card) card.classList.add('selected');
selectedTemplate=name;
document.getElementById('builder-template-label').textContent=name;
const varsEl=document.getElementById('tpl-vars-display');
if(variables.length){
varsEl.innerHTML=variables.map(v=>`<span class="field-tag">{${v}}</span>`).join('');
} else {
varsEl.textContent='No variables detected';
}
checkBuilderReady();
}
function selectSource(src){
builderSource=src;
document.getElementById('src-nocodb').classList.toggle('active',src==='nocodb');
document.getElementById('src-excel').classList.toggle('active',src==='excel');
document.getElementById('nocodb-source').style.display=src==='nocodb'?'block':'none';
document.getElementById('excel-source').style.display=src==='excel'?'block':'none';
if(src==='nocodb') loadBuilderHostnames();
else { document.getElementById('builder-host-list').innerHTML='<div class="empty-state" style="padding:16px">Upload an Excel file above</div>'; builderHostnames=[]; checkBuilderReady(); }
}
async function loadBuilderHostnames(){
const res=await fetch(`/api/xmlbuilder/nocodb-devices?group=${encodeURIComponent(currentGroup)}`);
const data=await res.json();
builderHostnames=(data.hostnames||[]).sort((a,b)=>a.localeCompare(b));
renderBuilderHostList(builderHostnames);
}
function renderBuilderHostList(hostnames){
const el=document.getElementById('builder-host-list');
if(!hostnames.length){el.innerHTML='<div class="empty-state" style="padding:16px">No devices found</div>';return;}
el.innerHTML=hostnames.map(h=>`<div class="host-item"><input type="checkbox" class="chk bld-chk" value="${h}" onchange="updateBuilderCount()"><span>${h}</span></div>`).join('');
updateBuilderCount();
checkBuilderReady();
}
function builderSelectAll(v){
document.querySelectorAll('.bld-chk').forEach(c=>c.checked=v);
updateBuilderCount();
}
function updateBuilderCount(){
const sel=getBuilderSelected();
document.getElementById('builder-sel-count').textContent=sel.length?`${sel.length} device${sel.length>1?'s':''} selected`:'';
checkBuilderReady();
}
function getBuilderSelected(){ return [...document.querySelectorAll('.bld-chk:checked')].map(c=>c.value); }
function checkBuilderReady(){
const ready = selectedTemplate && getBuilderSelected().length > 0;
document.getElementById('btn-generate').disabled=!ready;
}
async function uploadExcel(input){
const file=input.files[0]; if(!file) return;
const zoneText=document.getElementById('excel-zone-text');
zoneText.textContent='Uploading...';
const fd=new FormData(); fd.append('file',file);
const res=await fetch('/api/xmlbuilder/upload-excel',{method:'POST',body:fd});
const data=await res.json();
if(data.error){zoneText.textContent='Error: '+data.error;return;}
excelFilename=data.filename;
zoneText.textContent=`${data.filename} (${data.rows} rows)`;
zoneText.className='upload-zone-text loaded';
// Show columns as field tags
const colEl=document.getElementById('excel-columns');
colEl.innerHTML='<div style="font-family:\'Share Tech Mono\',monospace;font-size:10px;color:var(--text3);margin-bottom:4px">COLUMNS</div>'+data.columns.map(c=>`<span class="field-tag">{${c}}</span>`).join('');
builderHostnames=(data.hostnames||[]).sort((a,b)=>a.localeCompare(b));
renderBuilderHostList(builderHostnames);
}
async function uploadTemplate(input){
const file=input.files[0]; if(!file) return;
document.getElementById('tpl-zone-text').textContent='Uploading...';
const fd=new FormData(); fd.append('file',file);
const res=await fetch('/api/xmlbuilder/upload-template',{method:'POST',body:fd});
const data=await res.json();
if(data.error){document.getElementById('tpl-zone-text').textContent='Error: '+data.error;return;}
document.getElementById('tpl-zone-text').textContent='Click or drop another template (.xml)';
await loadBuilderTemplates();
// Auto-select the just-uploaded template by name match
const allCards = document.querySelectorAll('.template-card');
const uploadedIdx = Object.keys(templateRegistry).find(i => templateRegistry[i].name === data.name);
if(uploadedIdx !== undefined) allCards[uploadedIdx]?.click();
}
async function generateXMLs(){
const hostnames=getBuilderSelected();
if(!hostnames.length){alert('Select at least one device.');return;}
if(!selectedTemplate){alert('Select a template.');return;}
const defaultVal=document.getElementById('default-value').value||'NOTSET';
const btn=document.getElementById('btn-generate');
btn.disabled=true; btn.textContent='Generating...';
const notice=document.getElementById('generate-notice');
notice.textContent='';
let res, data;
if(builderSource==='nocodb'){
res=await fetch('/api/xmlbuilder/generate/nocodb',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({source:'nocodb',group:currentGroup,hostnames,template_name:selectedTemplate,default_value:defaultVal})});
data=await res.json();
} else {
if(!excelFilename){alert('Please upload an Excel file first.');btn.disabled=false;btn.textContent='⬡ Generate XMLs → uploads/';return;}
const fd=new FormData();
fd.append('hostnames',JSON.stringify(hostnames));
fd.append('template_name',selectedTemplate);
fd.append('excel_filename',excelFilename);
fd.append('default_value',defaultVal);
res=await fetch('/api/xmlbuilder/generate/excel',{method:'POST',body:fd});
data=await res.json();
}
btn.disabled=false; btn.textContent='⬡ Generate XMLs → uploads/';
if(data.error){notice.textContent='Error: '+data.error;notice.className='notice err';return;}
const results=data.results||[];
const ok=data.succeeded, err=data.failed;
notice.textContent=`${ok} generated, ${err} failed`;
notice.className=err>0?'notice err':'notice ok';
document.getElementById('builder-stats').textContent=`${ok} ok ${err} failed`;
document.getElementById('builder-results').innerHTML=results.map(r=>`
<div class="build-result-item ${r.success?'ok':'err'}">
<span style="font-size:14px">${r.success?'✓':'✗'}</span>
<div>
<div style="font-family:'Share Tech Mono',monospace;font-size:12px;color:var(--text)">${r.hostname}</div>
<div style="font-size:11px;color:var(--text3)">${r.message}</div>
</div>
</div>`).join('');
// Refresh upload file list
await loadFiles();
}
// ── Abort ─────────────────────────────────────────────────────────────────────
async function abortAllJobs() {
const res = await fetch('/api/jobs');
const data = await res.json();
const running = (data.jobs||[]).filter(j=>j.status==='running'||j.status==='queued');
if(!running.length){ alert('No running jobs to abort.'); return; }
if(!confirm(`Abort ${running.length} running job${running.length>1?'s':''}?\nDevices currently being processed will finish.`)) return;
for(const j of running){
await fetch(`/api/jobs/${j.job_id}/abort`, {method:'POST'});
}
abortRequested=true;
const btn=document.getElementById('btn-abort-all');
if(btn){ btn.disabled=true; btn.textContent='⚠ ABORTING ALL...'; }
const abortBtn=document.getElementById('btn-abort');
if(abortBtn){ abortBtn.disabled=true; abortBtn.textContent='⚠ ABORTING...'; }
appendLog(`[${new Date().toLocaleTimeString('en-US',{hour12:false})}] ⚠ Abort requested on ${running.length} job${running.length>1?'s':''} — finishing current devices...`);
}
async function abortAllJobs() {
if(!confirm('Abort ALL running jobs?\nDevices currently being processed will finish.')) return;
const res = await fetch('/api/jobs/abort-all', {method:'POST'});
const data = await res.json();
if(!data.count){ alert('No running jobs to abort.'); return; }
abortRequested = true;
const btn=document.getElementById('btn-abort-all');
if(btn){ btn.disabled=true; btn.textContent='⚠ ABORTING ALL...'; }
const abortBtn=document.getElementById('btn-abort');
if(abortBtn){ abortBtn.disabled=true; abortBtn.textContent='⚠ ABORTING...'; }
appendLog(`[${new Date().toLocaleTimeString('en-US',{hour12:false})}] ⚠ Abort-all sent — ${data.count} job${data.count>1?'s':''} flagged.`);
}
async function abortJob() {
if (!currentJobId) return;
if (!confirm(`Abort job ${currentJobId}?\nDevices currently being processed will finish, but no new ones will start.`)) return;
const res = await fetch(`/api/jobs/${currentJobId}/abort`, { method: 'POST' });
const data = await res.json();
if (data.error) { alert('Error: ' + data.error); return; }
abortRequested = true;
const btn = document.getElementById('btn-abort');
if(btn){ btn.disabled=true; btn.textContent='⚠ ABORTING...'; }
appendLog(`[${new Date().toLocaleTimeString('en-US',{hour12:false})}] ⚠ Abort requested — finishing current device...`);
}
// ── AT Terminal ───────────────────────────────────────────────────────────────
let atDevices = [];
let atFiltered = [];
let atSelectedIds = new Set();
let atStreamES = null;
// Populate device list when AT tab is opened
function atLoadDevices() {
if (atDevices.length === 0) {
// Reuse the already-loaded allDevices from the main devices tab
atDevices = allDevices.slice();
atFiltered = atDevices.slice();
atRenderDeviceList();
}
}
function atRenderDeviceList() {
const list = document.getElementById('at-device-list');
if (!atFiltered.length) {
list.innerHTML = '<div class="empty-state" style="padding:20px">No devices</div>';
return;
}
list.innerHTML = atFiltered.map(d => `
<div class="at-dev-item">
<input type="checkbox" class="at-chk" value="${escHtml(d.id)}"
${atSelectedIds.has(d.id) ? 'checked' : ''}
onchange="atToggle('${escHtml(d.id)}',this.checked)">
<span style="flex:1">${escHtml(d.id)}</span>
<span style="color:var(--text3);font-size:10px">${escHtml(d.location||'')}</span>
</div>`).join('');
atUpdateSelCount();
}
function atToggle(id, checked) {
if (checked) atSelectedIds.add(id);
else atSelectedIds.delete(id);
atUpdateSelCount();
}
function atSelectAll(v) {
atFiltered.forEach(d => v ? atSelectedIds.add(d.id) : atSelectedIds.delete(d.id));
atRenderDeviceList();
}
function atFilterDevices() {
const q = document.getElementById('at-search').value.toLowerCase();
atFiltered = q ? atDevices.filter(d => d.id.toLowerCase().includes(q) || (d.location||'').toLowerCase().includes(q)) : atDevices.slice();
atRenderDeviceList();
}
function atUpdateSelCount() {
document.getElementById('at-sel-count').textContent = `${atSelectedIds.size} selected`;
}
function atSetCmd(cmd) {
document.getElementById('at-cmd-input').value = cmd;
}
function atClearTerminal() {
document.getElementById('at-terminal').innerHTML =
'<div style="color:var(--text3);font-family:\'Share Tech Mono\',monospace;font-size:11px">// LOG CLEARED</div>';
document.getElementById('at-results-bar').style.display = 'none';
}
function atLog(entry) {
const term = document.getElementById('at-terminal');
const div = document.createElement('div');
div.className = 'at-line';
const devLabel = entry.device === 'SYSTEM' || !entry.device
? `<span style="color:var(--text3)">SYSTEM</span>`
: `<span class="at-dev">${escHtml(entry.device)}</span>`;
div.innerHTML = `
<span class="at-ts">${escHtml(entry.ts||'')}</span>
${devLabel}
<span class="at-msg ${escHtml(entry.level||'')}">${escHtml(entry.msg||'')}</span>`;
term.appendChild(div);
term.scrollTop = term.scrollHeight;
}
function atAddSep() {
const term = document.getElementById('at-terminal');
const hr = document.createElement('hr');
hr.className = 'at-sep';
term.appendChild(hr);
}
async function atSend() {
const rawCmds = document.getElementById('at-cmd-input').value
.split('\n').map(l => l.trim()).filter(l => l);
if (!rawCmds.length) { alert('Enter at least one AT command.'); return; }
const selectedIds = [...atSelectedIds];
if (!selectedIds.length) { alert('Select at least one device.'); return; }
const user = document.getElementById('at-ssh-user').value.trim();
const pass = document.getElementById('at-ssh-pass').value;
// If blank, backend falls back to per-device password from NocoDB
// ATZ confirmation
if (rawCmds.some(c => c.toUpperCase() === 'ATZ')) {
if (!confirm(`⚠ ATZ will REBOOT all ${selectedIds.length} selected device(s).\n\nContinue?`)) return;
} else if (selectedIds.length > 10) {
if (!confirm(`Send to ${selectedIds.length} devices?\n\nCommands:\n${rawCmds.join('\n')}`)) return;
}
const conc = parseInt(document.getElementById('at-concurrency').value) || 3;
// Close any existing stream
if (atStreamES) { atStreamES.close(); atStreamES = null; }
atAddSep();
atLog({ ts: new Date().toLocaleTimeString('en-US',{hour12:false}), device: 'SYSTEM', msg: `▶ Sending to ${selectedIds.length} device(s) — commands: ${rawCmds.join(' | ')}`, level: 'warn' });
document.getElementById('at-send-btn').disabled = true;
document.getElementById('at-status-text').textContent = 'Connecting…';
document.getElementById('at-results-bar').style.display = 'none';
let sessionId = null;
try {
const res = await fetch('/api/at/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_ids: selectedIds,
group: currentGroup,
commands: rawCmds,
ssh_username: user,
ssh_password: pass,
concurrency: conc,
}),
});
const data = await res.json();
if (data.error) { alert('Error: ' + data.error); document.getElementById('at-send-btn').disabled = false; return; }
sessionId = data.session_id;
} catch (e) {
alert('Request failed: ' + e.message);
document.getElementById('at-send-btn').disabled = false;
return;
}
// Stream logs via SSE
atStreamES = new EventSource(`/api/at/${sessionId}/stream`);
let linesReceived = 0;
atStreamES.onmessage = (evt) => {
const msg = JSON.parse(evt.data);
if (msg.log) {
atLog(msg.log);
linesReceived++;
document.getElementById('at-status-text').textContent = `${linesReceived} log lines…`;
}
if (msg.done) {
atStreamES.close(); atStreamES = null;
document.getElementById('at-send-btn').disabled = false;
document.getElementById('at-status-text').textContent = 'Complete';
// Show results bar
const results = msg.results || [];
const ok = results.filter(r => r.success).length;
const err = results.length - ok;
const bar = document.getElementById('at-results-bar');
bar.style.display = 'flex';
document.getElementById('at-res-ok').textContent = ok;
document.getElementById('at-res-err').textContent = err;
document.getElementById('at-res-devices').textContent = `${results.length} device(s)`;
atLog({ ts: new Date().toLocaleTimeString('en-US',{hour12:false}), device: 'SYSTEM', msg: `Session complete — ${ok} ok, ${err} failed`, level: ok === results.length ? 'ok' : 'error' });
}
if (msg.error) {
atLog({ ts: new Date().toLocaleTimeString('en-US',{hour12:false}), device: 'SYSTEM', msg: '✗ Stream error: ' + msg.error, level: 'error' });
atStreamES.close(); atStreamES = null;
document.getElementById('at-send-btn').disabled = false;
}
};
atStreamES.onerror = () => {
if (atStreamES) { atStreamES.close(); atStreamES = null; }
document.getElementById('at-send-btn').disabled = false;
document.getElementById('at-status-text').textContent = 'Stream ended';
};
}
// ── Tab switching ──────────────────────────────────────────────────────────────
function switchTab(name,btn){
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));
btn.classList.add('active');
document.getElementById(`tab-${name}`).classList.add('active');
if(name==='history') refreshJobList();
if(name==='builder'){ loadBuilderTemplates(); if(builderSource==='nocodb') loadBuilderHostnames(); }
if(name==='at'){ atDevices=[...allDevices].sort((a,b)=>a.id.localeCompare(b.id)); atFiltered=atDevices.slice(); atRenderDeviceList(); }
}
function switchTabById(name){
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));
const btn=document.querySelector(`[onclick="switchTab('${name}',this)"]`);
if(btn) btn.classList.add('active');
const tc=document.getElementById(`tab-${name}`);
if(tc) tc.classList.add('active');
}
// ── Theme System ───────────────────────────────────────────────────────────────
const THEMES = {
cyan: {
'--bg': '#0a0c0f', '--bg2': '#10141a', '--bg3': '#161c25',
'--border': '#1e2a38', '--border2': '#263545',
'--text': '#c8d8e8', '--text2': '#6a8aa8', '--text3': '#3a5068',
'--accent': '#00bfff', '--accent2': '#0085cc',
'--green': '#00e676', '--green2': '#00a152',
'--red': '#ff1744', '--red2': '#b2102f',
'--yellow': '#ffea00', '--orange': '#ff6d00',
'--purple': '#d580ff', '--purple2': '#9c27b0',
},
nocodb: {
'--bg': '#1a1d2e', '--bg2': '#1f2340', '--bg3': '#252849',
'--border': '#2d3260', '--border2': '#363b70',
'--text': '#e2e8f0', '--text2': '#94a3b8', '--text3': '#475569',
'--accent': '#7c3aed', '--accent2': '#6d28d9',
'--green': '#10b981', '--green2': '#059669',
'--red': '#ef4444', '--red2': '#dc2626',
'--yellow': '#f59e0b', '--orange': '#f97316',
'--purple': '#a78bfa', '--purple2': '#7c3aed',
},
portainer: {
'--bg': '#0d1117', '--bg2': '#161b22', '--bg3': '#1c2128',
'--border': '#21262d', '--border2': '#30363d',
'--text': '#f0f6fc', '--text2': '#8b949e', '--text3': '#484f58',
'--accent': '#00d2e6', '--accent2': '#00a3b4',
'--green': '#3fb950', '--green2': '#238636',
'--red': '#f85149', '--red2': '#da3633',
'--yellow': '#d29922', '--orange': '#db6d28',
'--purple': '#bc8cff', '--purple2': '#8957e5',
},
amber: {
'--bg': '#0d0a00', '--bg2': '#141000', '--bg3': '#1a1500',
'--border': '#2a2000', '--border2': '#332800',
'--text': '#ffd97d', '--text2': '#b8861a', '--text3': '#6b4d00',
'--accent': '#ffb300', '--accent2': '#e65c00',
'--green': '#a3e635', '--green2': '#65a30d',
'--red': '#ff4444', '--red2': '#cc0000',
'--yellow': '#ffe066', '--orange': '#ff8c00',
'--purple': '#da8fff', '--purple2': '#9c27b0',
},
green: {
'--bg': '#060d06', '--bg2': '#0a140a', '--bg3': '#0f1a0f',
'--border': '#1a2e1a', '--border2': '#1f3a1f',
'--text': '#a0ffa0', '--text2': '#4a8a4a', '--text3': '#2a5a2a',
'--accent': '#00e676', '--accent2': '#00a152',
'--green': '#69ff47', '--green2': '#00e676',
'--red': '#ff4444', '--red2': '#cc0000',
'--yellow': '#ccff33', '--orange': '#ffaa00',
'--purple': '#cc88ff', '--purple2': '#8800cc',
},
};
const FONTS = {
inter: { '--font-ui': "'Inter', sans-serif", '--font-mono': "'Inter', sans-serif" },
barlow: { '--font-ui': "'Barlow', sans-serif", '--font-mono': "'Share Tech Mono', monospace" },
mono: { '--font-ui': "'IBM Plex Mono', monospace", '--font-mono': "'IBM Plex Mono', monospace" },
};
let currentPreset = 'cyan';
let currentFont = 'inter';
let currentAccent = null; // null = use preset accent
let currentDarkness = 50;
function _hexToRgb(hex) {
const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
return [r,g,b];
}
function _darken(hex, amount) {
// amount: 0=normal, negative=darker, positive=lighter (in %)
let [r,g,b] = _hexToRgb(hex);
const factor = 1 + amount / 100;
r = Math.max(0, Math.min(255, Math.round(r * factor)));
g = Math.max(0, Math.min(255, Math.round(g * factor)));
b = Math.max(0, Math.min(255, Math.round(b * factor)));
return '#' + [r,g,b].map(v=>v.toString(16).padStart(2,'0')).join('');
}
function _accentShade(hex) {
// derive --accent2 as a darkened version of accent
let [r,g,b] = _hexToRgb(hex);
r = Math.round(r * 0.65); g = Math.round(g * 0.65); b = Math.round(b * 0.65);
return '#' + [r,g,b].map(v=>v.toString(16).padStart(2,'0')).join('');
}
function applyVars(vars) {
const root = document.documentElement;
for (const [k,v] of Object.entries(vars)) root.style.setProperty(k, v);
}
function applyPreset(name) {
currentPreset = name;
currentAccent = null; // reset custom accent
const theme = THEMES[name];
applyVars(theme);
applyBgDarkness(currentDarkness, false); // reapply darkness on new bg
// Sync custom accent picker to preset accent
const accentHex = theme['--accent'];
document.getElementById('custom-accent').value = accentHex;
document.getElementById('accent-preview').style.background = accentHex;
// Update swatch active state
document.querySelectorAll('.theme-swatch').forEach(s => s.classList.remove('active'));
const sw = document.getElementById(`sw-${name}`);
if (sw) sw.classList.add('active');
saveTheme();
}
function applyCustomAccent(hex) {
currentAccent = hex;
document.getElementById('accent-preview').style.background = hex;
document.documentElement.style.setProperty('--accent', hex);
document.documentElement.style.setProperty('--accent2', _accentShade(hex));
// Deselect all presets since we're custom now
document.querySelectorAll('.theme-swatch').forEach(s => s.classList.remove('active'));
saveTheme();
}
function applyBgDarkness(val, save=true) {
currentDarkness = parseInt(val);
document.getElementById('bg-darkness').value = val;
document.getElementById('bg-darkness-val').textContent = val;
// shift = -30 (darkest) to +30 (lightest) relative to 50 midpoint
const shift = (currentDarkness - 50) * 0.6;
const theme = THEMES[currentPreset];
const bgs = ['--bg','--bg2','--bg3'];
bgs.forEach(k => {
const adjusted = _darken(theme[k], shift);
document.documentElement.style.setProperty(k, adjusted);
});
if (save) saveTheme();
}
function applyFont(name) {
currentFont = name;
applyVars(FONTS[name]);
document.querySelectorAll('.font-option').forEach(b => b.classList.remove('active'));
document.getElementById(`font-${name}`).classList.add('active');
saveTheme();
}
function toggleThemePanel() {
const panel = document.getElementById('theme-panel');
const btn = document.getElementById('theme-toggle-btn');
panel.classList.toggle('open');
btn.classList.toggle('open');
}
function resetTheme() {
currentPreset = 'cyan';
currentFont = 'inter';
currentAccent = null;
currentDarkness = 50;
applyPreset('cyan');
applyFont('inter');
document.getElementById('bg-darkness').value = 50;
document.getElementById('bg-darkness-val').textContent = 50;
applyBgDarkness(50, false);
localStorage.removeItem('rv50x-theme');
}
function saveTheme() {
localStorage.setItem('rv50x-theme', JSON.stringify({
preset: currentPreset, font: currentFont,
accent: currentAccent, darkness: currentDarkness,
}));
}
function loadSavedTheme() {
try {
const saved = JSON.parse(localStorage.getItem('rv50x-theme') || 'null');
if (!saved) { applyPreset('cyan'); applyFont('inter'); return; }
currentPreset = saved.preset || 'cyan';
currentFont = saved.font || 'inter';
currentAccent = saved.accent || null;
currentDarkness = saved.darkness ?? 50;
applyPreset(currentPreset);
applyBgDarkness(currentDarkness, false);
if (currentAccent) applyCustomAccent(currentAccent);
applyFont(currentFont);
} catch(e) {
applyPreset('cyan');
applyFont('inter');
}
}
// Apply theme immediately on load (before anything else renders)
loadSavedTheme();
// ── Helpers ────────────────────────────────────────────────────────────────────
function escHtml(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function escId(s){ return s.replace(/[^a-zA-Z0-9_-]/g,'_'); }
</script>
</body>
</html>