cd819002b4
High-impact reliability: - SQLite job persistence (rv50x.db) — job history and AT sessions survive restarts - Extract _login_and_open_modal() — eliminates ~40 lines of duplicated Playwright login logic - Separate NocoDB view IDs per group via NOCODB_VIEW_ID_ELECTRIC / NOCODB_VIEW_ID_GW env vars - Excel cache TTL (1h) + size cap (20 files) with eviction helpers - In-memory job store pruning (MAX_JOBS_MEMORY, default 200) Functionality: - TCP reachability pre-check before launching Playwright — fails fast on unreachable devices - AT command presets — save/load/delete named command sequences, stored in at_presets.json Ops: - Bind-mount rv50x.db and at_presets.json in docker-compose so data survives rebuilds - Add NOCODB_VIEW_ID_ELECTRIC, NOCODB_VIEW_ID_GW, REACH_TIMEOUT env vars to compose - Ignore runtime files (rv50x.db, at_presets.json, template dirs) in .gitignore Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1747 lines
92 KiB
HTML
1747 lines
92 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; }
|
||
.preset-strip { display: flex; flex-wrap: wrap; gap: 5px; min-height: 24px; }
|
||
.preset-pill { display: inline-flex; align-items: center; background: rgba(255,109,0,0.08); border: 1px solid rgba(255,109,0,0.3); border-radius: 3px; font-family: var(--font-mono); font-size: 10px; }
|
||
.preset-load { background: none; border: none; color: var(--orange); cursor: pointer; font-family: var(--font-mono); font-size: 10px; padding: 3px 6px; letter-spacing: 0.3px; }
|
||
.preset-load:hover { color: var(--text); }
|
||
.preset-del { background: none; border: none; color: var(--text3); cursor: pointer; font-size: 13px; line-height: 1; padding: 2px 5px 2px 0; }
|
||
.preset-del:hover { color: var(--red); }
|
||
.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 & 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 AT*CELLINFO2? 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>
|
||
<!-- Saved presets -->
|
||
<div style="margin-top:10px;padding-top:8px;border-top:1px solid var(--border)">
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||
<span style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3);letter-spacing:1.5px">PRESETS</span>
|
||
<button class="at-quick" onclick="atSavePreset()" style="margin-left:auto;padding:2px 8px">+ SAVE CURRENT</button>
|
||
</div>
|
||
<div class="preset-strip" id="at-preset-strip">
|
||
<span style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3)">No presets saved</span>
|
||
</div>
|
||
</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';
|
||
};
|
||
}
|
||
|
||
// ── AT Presets ────────────────────────────────────────────────────────────────
|
||
async function atLoadPresets() {
|
||
try {
|
||
const res = await fetch('/api/at/presets');
|
||
const data = await res.json();
|
||
atRenderPresets(data.presets || []);
|
||
} catch(e) {}
|
||
}
|
||
|
||
function atRenderPresets(presets) {
|
||
const strip = document.getElementById('at-preset-strip');
|
||
if (!presets.length) {
|
||
strip.innerHTML = '<span style="font-family:\'Share Tech Mono\',monospace;font-size:10px;color:var(--text3)">No presets saved</span>';
|
||
return;
|
||
}
|
||
strip.innerHTML = presets.map(p => `
|
||
<span class="preset-pill">
|
||
<button class="preset-load"
|
||
onclick="atApplyPreset(${JSON.stringify(p.commands)})"
|
||
title="${escHtml(p.commands.join(' | '))}">${escHtml(p.name)}</button>
|
||
<button class="preset-del"
|
||
onclick="atDeletePreset(${JSON.stringify(p.name)})"
|
||
title="Delete preset">×</button>
|
||
</span>`).join('');
|
||
}
|
||
|
||
function atApplyPreset(commands) {
|
||
document.getElementById('at-cmd-input').value = commands.join('\n');
|
||
}
|
||
|
||
async function atSavePreset() {
|
||
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 command in the textarea first.'); return; }
|
||
const name = prompt('Preset name:');
|
||
if (!name || !name.trim()) return;
|
||
try {
|
||
const res = await fetch('/api/at/presets', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: name.trim(), commands: rawCmds }),
|
||
});
|
||
const data = await res.json();
|
||
if (data.error) { alert('Error: ' + data.error); return; }
|
||
await atLoadPresets();
|
||
} catch(e) { alert('Request failed: ' + e.message); }
|
||
}
|
||
|
||
async function atDeletePreset(name) {
|
||
if (!confirm(`Delete preset "${name}"?`)) return;
|
||
try {
|
||
const res = await fetch(`/api/at/presets/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||
const data = await res.json();
|
||
if (data.error) { alert('Error: ' + data.error); return; }
|
||
await atLoadPresets();
|
||
} catch(e) { alert('Request failed: ' + e.message); }
|
||
}
|
||
|
||
// ── 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(); atLoadPresets(); }
|
||
}
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
function escId(s){ return s.replace(/[^a-zA-Z0-9_-]/g,'_'); }
|
||
</script>
|
||
</body>
|
||
</html>
|