Files
D Stephenson cd819002b4 Add reliability enhancements, reachability pre-check, and AT presets
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>
2026-04-23 20:37:17 +00:00

1747 lines
92 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 &amp; Water <span class="group-count" id="cnt-Gas & Water"></span></button>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-label">Job Settings</div>
<div class="setting-row">
<span class="setting-label">Concurrency</span>
<div style="display:flex;align-items:center;gap:8px">
<input type="range" id="concurrency" min="1" max="5" value="1" oninput="document.getElementById('concurrency-val').textContent=this.value">
<span class="setting-value" id="concurrency-val">1</span>
</div>
</div>
<div class="setting-row">
<span class="setting-label">Reboot after upload</span>
<label class="toggle"><input type="checkbox" id="reboot" checked><span class="toggle-slider"></span></label>
</div>
<div class="setting-row">
<span class="setting-label">Live log stream</span>
<label class="toggle"><input type="checkbox" id="live-stream" checked><span class="toggle-slider"></span></label>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-label">Actions</div>
<div class="action-btns">
<button class="btn btn-download" id="btn-dl" onclick="startJob('download')">⬇ Download Templates</button>
<button class="btn btn-upload" id="btn-ul" onclick="startJob('upload')">⬆ Upload Templates</button>
</div>
<div class="notice" id="selection-info">All devices in group will be targeted</div>
</div>
<div class="sidebar-section" style="flex:1">
<div class="sidebar-label">Template Files</div>
<div style="margin-bottom:6px;font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3)">DOWNLOADS</div>
<div id="dl-files" style="margin-bottom:12px;max-height:100px;overflow-y:auto"></div>
<div style="margin-bottom:6px;font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3)">UPLOADS</div>
<div id="ul-files" style="max-height:100px;overflow-y:auto"></div>
</div>
</aside>
<!-- ── Main ── -->
<main>
<div class="main-tabs">
<button class="tab active" onclick="switchTab('devices',this)">Devices</button>
<button class="tab" onclick="switchTab('jobs',this)">Live / Logs</button>
<button class="tab" onclick="switchTab('history',this)">History</button>
<button class="tab" onclick="switchTab('builder',this)" style="color:var(--purple);border-bottom-color:transparent" id="tab-btn-builder">⬡ XML Builder</button>
<button class="tab" onclick="switchTab('at',this)" style="color:var(--orange);border-bottom-color:transparent" id="tab-btn-at">⌨ AT Terminal</button>
</div>
<!-- Devices tab -->
<div class="tab-content active" id="tab-devices">
<div class="table-toolbar">
<input class="search-input" type="text" placeholder="Search hostname, IP, location..." id="search" oninput="filterTable()">
<button class="btn btn-sm btn-download" onclick="selectAll(true)">SELECT ALL</button>
<button class="btn btn-sm btn-neutral" onclick="selectAll(false)">CLEAR</button>
<span class="toolbar-count" id="table-count"></span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:32px"><input type="checkbox" class="chk" id="chk-all" onclick="toggleAll(this)"></th>
<th onclick="sortTable('id')">Hostname</th>
<th onclick="sortTable('ip')">IP Address</th>
<th onclick="sortTable('dept')">Dept</th>
<th onclick="sortTable('location')">Location</th>
<th>DL File</th>
</tr>
</thead>
<tbody id="device-tbody"></tbody>
</table>
</div>
</div>
<!-- Jobs tab -->
<div class="tab-content" id="tab-jobs">
<div class="log-panel">
<div class="log-toolbar">
<span style="font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--text3)">JOB</span>
<select class="job-selector" id="job-selector" onchange="loadJob(this.value)">
<option value="">— select job —</option>
</select>
<span id="job-status-pill"></span>
<div style="margin-left:auto;display:flex;gap:8px;align-items:center">
<span id="stream-indicator" style="display:none;font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--yellow)"><span class="spinner"></span> streaming</span>
<button class="btn btn-sm" id="btn-abort-all" onclick="abortAllJobs()"
style="background:rgba(255,109,0,0.1);border:1px solid rgba(255,109,0,0.5);color:var(--orange);display:none">
✕ ABORT ALL
</button>
<button class="btn btn-sm" id="btn-abort-all" onclick="abortAllJobs()"
style="background:rgba(255,109,0,0.1);border:1px solid rgba(255,109,0,0.5);color:var(--orange);display:none">
✕ ABORT ALL
</button>
<button class="btn btn-sm" id="btn-abort" onclick="abortJob()"
style="background:rgba(255,23,68,0.1);border:1px solid var(--red2);color:var(--red);display:none">
✕ ABORT
</button>
<button class="btn btn-sm btn-neutral" onclick="refreshCurrentJob()">↻ REFRESH</button>
</div>
</div>
<div class="progress-wrap" id="progress-wrap" style="display:none"><div class="progress-bar" id="progress-bar" style="width:0%"></div></div>
<div class="log-output" id="log-output"><div class="empty-state">No job selected.</div></div>
<div class="results-grid" id="results-grid" style="display:none"></div>
<div class="stats-bar" id="stats-bar" style="display:none"></div>
</div>
</div>
<!-- History tab -->
<div class="tab-content" id="tab-history">
<div class="history-list" id="history-list"><div class="empty-state">No jobs run yet.</div></div>
</div>
<!-- XML Builder tab -->
<div class="tab-content" id="tab-builder">
<div class="builder-layout">
<!-- Builder sidebar -->
<div class="builder-sidebar">
<!-- Source selector -->
<div class="builder-section">
<div class="builder-section-title">Data Source</div>
<div class="source-tabs">
<button class="source-tab active" id="src-nocodb" onclick="selectSource('nocodb')">NocoDB</button>
<button class="source-tab" id="src-excel" onclick="selectSource('excel')">Excel</button>
</div>
<!-- NocoDB source -->
<div id="nocodb-source">
<div class="notice" style="margin-bottom:8px">Using live NocoDB data. Group filter applied from sidebar.</div>
<div style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3);margin-bottom:6px">AVAILABLE VARIABLES</div>
<div id="nocodb-fields"></div>
</div>
<!-- Excel source -->
<div id="excel-source" style="display:none">
<div class="upload-zone" id="excel-drop">
<input type="file" accept=".xlsx,.xls" onchange="uploadExcel(this)">
<div class="upload-zone-text" id="excel-zone-text">Click or drop Excel file (.xlsx)</div>
</div>
<div id="excel-columns" style="margin-top:8px"></div>
</div>
</div>
<!-- Template upload zone -->
<div class="builder-section">
<div class="builder-section-title">XML Template</div>
<div class="upload-zone">
<input type="file" accept=".xml" onchange="uploadTemplate(this)">
<div class="upload-zone-text" id="tpl-zone-text">Upload new template (.xml)</div>
</div>
</div>
<!-- Template list — separate section so file input cannot overlap cards -->
<div class="builder-section">
<div id="template-list"><div class="empty-state" style="padding:16px">No templates yet</div></div>
</div>
<!-- Default value -->
<div class="builder-section">
<div class="builder-section-title">Options</div>
<div class="setting-label" style="margin-bottom:4px">Default value for missing fields</div>
<input class="form-input" id="default-value" value="NOTSET" placeholder="NOTSET">
</div>
<!-- Device selection -->
<div class="builder-section" style="flex:1">
<div class="builder-section-title">Select Devices</div>
<div style="display:flex;gap:6px;margin-bottom:8px">
<button class="btn btn-sm btn-neutral" onclick="builderSelectAll(true)" style="flex:1">ALL</button>
<button class="btn btn-sm btn-neutral" onclick="builderSelectAll(false)" style="flex:1">CLEAR</button>
</div>
<div class="host-list" id="builder-host-list"><div class="empty-state" style="padding:16px">Load a data source first</div></div>
<div class="notice" id="builder-sel-count" style="margin-top:6px"></div>
</div>
<!-- Generate button -->
<div class="builder-section">
<button class="btn btn-purple" id="btn-generate" onclick="generateXMLs()" style="width:100%" disabled>
⬡ Generate XMLs → uploads/
</button>
<div class="notice" id="generate-notice"></div>
</div>
</div>
<!-- Builder main — results -->
<div class="builder-main">
<div style="padding:16px 24px;border-bottom:1px solid var(--border);background:var(--bg2);display:flex;align-items:center;justify-content:space-between">
<div>
<span style="font-family:'Barlow Condensed',sans-serif;font-size:16px;font-weight:700;color:var(--purple);letter-spacing:1px">XML BUILDER</span>
<span style="font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--text3);margin-left:12px" id="builder-template-label">No template selected</span>
</div>
<div style="font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--text3)" id="builder-stats"></div>
</div>
<!-- Variable reference panel -->
<div style="padding:12px 24px;border-bottom:1px solid var(--border);background:var(--bg2)">
<div style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3);margin-bottom:6px;letter-spacing:1px">TEMPLATE VARIABLES DETECTED</div>
<div id="tpl-vars-display" style="font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--text3)">Select a template to see its variables</div>
</div>
<div class="builder-results" id="builder-results">
<div class="empty-state">
Select a data source, choose a template,<br>pick devices, then click Generate.
<br><br>
Generated XMLs go directly to <span style="color:var(--accent)">template_uploads/</span><br>
and are ready to push to modems immediately.
</div>
</div>
</div>
</div>
</div>
<!-- AT Terminal tab -->
<div class="tab-content" id="tab-at">
<div class="at-layout">
<!-- AT Sidebar -->
<div class="at-sidebar">
<!-- SSH Credentials -->
<div class="at-section">
<div class="at-section-title">SSH Credentials</div>
<div class="at-cred-row">
<label class="at-label">USERNAME</label>
<input class="at-input" id="at-ssh-user" type="text" value="user" placeholder="user">
</div>
<div class="at-cred-row">
<label class="at-label">PASSWORD</label>
<input class="at-input" id="at-ssh-pass" type="password" placeholder="leave blank → use NocoDB password">
</div>
<div style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3);margin-top:4px">PORT: 3223 (fixed)</div>
</div>
<!-- Concurrency -->
<div class="at-section">
<div class="at-section-title">Parallel Sessions</div>
<div class="setting-row">
<span class="setting-label">Concurrency</span>
<span class="setting-value" id="at-conc-val">3</span>
</div>
<input type="range" min="1" max="10" value="3" id="at-concurrency" oninput="document.getElementById('at-conc-val').textContent=this.value">
</div>
<!-- Device select controls -->
<div class="at-section">
<div class="at-section-title">Target Devices</div>
<div style="display:flex;gap:6px;margin-bottom:8px">
<button class="btn btn-sm btn-neutral" onclick="atSelectAll(true)" style="flex:1">ALL</button>
<button class="btn btn-sm btn-neutral" onclick="atSelectAll(false)" style="flex:1">CLEAR</button>
</div>
<input class="at-input" id="at-search" type="text" placeholder="filter..." oninput="atFilterDevices()" style="margin-bottom:6px">
<div style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3)" id="at-sel-count">0 selected</div>
</div>
<!-- Device list -->
<div class="at-device-list" id="at-device-list">
<div class="empty-state" style="padding:20px">Loading devices…</div>
</div>
</div>
<!-- AT Main -->
<div class="at-main">
<!-- Command input area -->
<div class="at-cmd-area">
<div class="at-cmd-header">
<span style="font-family:'Barlow Condensed',sans-serif;font-size:15px;font-weight:700;color:var(--orange);letter-spacing:1px">AT COMMAND</span>
<span style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3)">one per line — all sent sequentially</span>
</div>
<textarea class="at-cmd-textarea" id="at-cmd-input" placeholder="ATI1&#10;AT*CELLINFO2?&#10;AT*NETRSSI?"></textarea>
<div class="at-quick-cmds">
<button class="at-quick" onclick="atSetCmd('ATZ')">ATZ — reboot</button>
<button class="at-quick" onclick="atSetCmd('ATI1')">ATI1 — fw ver</button>
<button class="at-quick" onclick="atSetCmd('AT*CELLINFO2?')">CELLINFO2</button>
<button class="at-quick" onclick="atSetCmd('AT*NETRSSI?')">RSSI</button>
<button class="at-quick" onclick="atSetCmd('AT*HOSTMODE?')">HOSTMODE</button>
<button class="at-quick" onclick="atSetCmd('AT*OPMODE?')">OPMODE</button>
<button class="at-quick" onclick="atSetCmd('AT*NETSTATE?')">NETSTATE</button>
<button class="at-quick" onclick="atSetCmd('AT*WWAN1HOMEPAGEURL?')">APN URL</button>
<button class="at-quick" onclick="atSetCmd('ATI1\nAT*CELLINFO2?\nAT*NETRSSI?')">📶 signal bundle</button>
</div>
<!-- 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function escId(s){ return s.replace(/[^a-zA-Z0-9_-]/g,'_'); }
</script>
</body>
</html>