Add admin panel, authentication, and first-run setup wizard for v1.0
Implements credentialed admin panel and multi-step setup wizard so new
users can configure location, receivers, and preferences without editing
JSON files. Adds bcrypt password auth with session cookies, setup lockout
after first run, and expanded config schema with backward compatibility.
New files: admin/{html,css,js}, setup/{html,css,js}
Modified: server.py (auth, setup, admin APIs), index.html (dynamic title),
pixel-view.js (site/display config), requirements.txt (bcrypt),
config.json.example (full schema)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+392
@@ -0,0 +1,392 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(180deg, #2c3e50 0%, #1a252f 100%);
|
||||
color: #fcfcfc;
|
||||
font-family: 'Courier New', monospace;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Login Screen */
|
||||
#login-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: rgba(52, 73, 94, 0.9);
|
||||
border: 3px solid #5c94fc;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.retro-title {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
color: #fcd444;
|
||||
text-shadow: 2px 2px 0px #000;
|
||||
font-size: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: #b4d4ec;
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
#login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
input[type="password"],
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
textarea,
|
||||
select {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 2px solid #5c94fc;
|
||||
border-radius: 6px;
|
||||
color: #fcfcfc;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
padding: 10px 12px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus, select:focus {
|
||||
border-color: #fcd444;
|
||||
}
|
||||
|
||||
select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select option {
|
||||
background: #2c3e50;
|
||||
color: #fcfcfc;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 11px;
|
||||
padding: 10px 20px;
|
||||
border: 2px solid #5c94fc;
|
||||
border-radius: 6px;
|
||||
background: rgba(92, 148, 252, 0.2);
|
||||
color: #fcfcfc;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(92, 148, 252, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: rgba(252, 212, 68, 0.3);
|
||||
border-color: #fcd444;
|
||||
color: #fcd444;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: rgba(252, 212, 68, 0.5);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: #fc5454;
|
||||
color: #fc5454;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(252, 84, 84, 0.3);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
font-size: 9px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: #fc5454;
|
||||
font-size: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-top: 16px;
|
||||
color: #b4d4ec;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: #fcd444;
|
||||
}
|
||||
|
||||
/* Admin Panel */
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-bottom: 3px solid #5c94fc;
|
||||
}
|
||||
|
||||
.admin-header .retro-title {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Tab Bar */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 10px 20px 0;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.tab {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 9px;
|
||||
padding: 8px 14px;
|
||||
background: rgba(52, 73, 94, 0.6);
|
||||
border: 2px solid #5c94fc;
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
color: #b4d4ec;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: rgba(92, 148, 252, 0.3);
|
||||
color: #fcfcfc;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: rgba(52, 73, 94, 0.9);
|
||||
color: #fcd444;
|
||||
border-color: #fcd444;
|
||||
}
|
||||
|
||||
/* Tab Content */
|
||||
.tab-content {
|
||||
padding: 20px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tab-pane {
|
||||
background: rgba(52, 73, 94, 0.6);
|
||||
border: 2px solid #5c94fc;
|
||||
border-radius: 0 8px 8px 8px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.tab-pane h2 {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 14px;
|
||||
color: #fcd444;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Status Grid */
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 2px solid #5c94fc;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
display: block;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 8px;
|
||||
color: #b4d4ec;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
display: block;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 18px;
|
||||
color: #54fc54;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 9px;
|
||||
color: #b4d4ec;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
accent-color: #fcd444;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 2px solid #5c94fc;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.info-box h3 {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 10px;
|
||||
color: #fcd444;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Toast Notification */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(84, 252, 84, 0.9);
|
||||
color: #000;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 10px;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #54fc54;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: rgba(252, 84, 84, 0.9);
|
||||
border-color: #fc5454;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.admin-header {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
padding: 8px 10px 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
font-size: 8px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.tab-pane {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.button-row .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ADS-Bit Admin</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/admin/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Login Screen -->
|
||||
<div id="login-screen">
|
||||
<div class="login-box">
|
||||
<h1 class="retro-title">ADS-Bit Admin</h1>
|
||||
<p class="login-subtitle">Enter admin password</p>
|
||||
<form id="login-form">
|
||||
<input type="password" id="login-password" placeholder="Password" autocomplete="current-password" required>
|
||||
<button type="submit" class="btn btn-primary">LOGIN</button>
|
||||
</form>
|
||||
<div id="login-error" class="error-msg hidden"></div>
|
||||
<a href="/" class="back-link">Back to Viewer</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Panel -->
|
||||
<div id="admin-panel" class="hidden">
|
||||
<header class="admin-header">
|
||||
<h1 class="retro-title">ADS-Bit Admin</h1>
|
||||
<div class="header-actions">
|
||||
<a href="/" class="btn btn-small">VIEWER</a>
|
||||
<button id="logout-btn" class="btn btn-small btn-danger">LOGOUT</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tab-bar">
|
||||
<button class="tab active" data-tab="dashboard">DASHBOARD</button>
|
||||
<button class="tab" data-tab="receivers">RECEIVERS</button>
|
||||
<button class="tab" data-tab="location">LOCATION</button>
|
||||
<button class="tab" data-tab="display">DISPLAY</button>
|
||||
<button class="tab" data-tab="tuning">TUNING</button>
|
||||
<button class="tab" data-tab="security">SECURITY</button>
|
||||
</nav>
|
||||
|
||||
<main class="tab-content">
|
||||
<!-- Dashboard Tab -->
|
||||
<section id="tab-dashboard" class="tab-pane active">
|
||||
<h2>Live Status</h2>
|
||||
<div class="status-grid">
|
||||
<div class="status-card">
|
||||
<span class="status-label">ACTIVE FLIGHTS</span>
|
||||
<span id="stat-flights" class="status-value">--</span>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<span class="status-label">CONNECTED VIEWERS</span>
|
||||
<span id="stat-viewers" class="status-value">--</span>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<span class="status-label">RECEIVERS</span>
|
||||
<span id="stat-receivers" class="status-value">--</span>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<span class="status-label">UPTIME</span>
|
||||
<span id="stat-uptime" class="status-value">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="receiver-list" class="info-box">
|
||||
<h3>Connected Receivers</h3>
|
||||
<div id="receiver-ips">Loading...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Receivers Tab -->
|
||||
<section id="tab-receivers" class="tab-pane hidden">
|
||||
<h2>Receiver Configuration</h2>
|
||||
<div class="form-group">
|
||||
<label>Discovery Mode</label>
|
||||
<select id="receiver-mode">
|
||||
<option value="AUTO">AUTO (scan network)</option>
|
||||
<option value="MANUAL">MANUAL (specify IPs)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="manual-ips-group" class="form-group hidden">
|
||||
<label>Receiver IPs (one per line)</label>
|
||||
<textarea id="receiver-ips-input" rows="4" placeholder="192.168.1.100 192.168.1.101"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Receiver Port</label>
|
||||
<input type="number" id="receiver-port" value="30003" min="1" max="65535">
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="scan-receivers-btn" class="btn">SCAN NOW</button>
|
||||
<button id="restart-receivers-btn" class="btn">RESTART CONNECTIONS</button>
|
||||
<button id="save-receivers-btn" class="btn btn-primary">SAVE</button>
|
||||
</div>
|
||||
<div id="scan-results" class="info-box hidden">
|
||||
<h3>Scan Results</h3>
|
||||
<div id="scan-results-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Location Tab -->
|
||||
<section id="tab-location" class="tab-pane hidden">
|
||||
<h2>Location Settings</h2>
|
||||
<div class="form-group">
|
||||
<label>Location Name</label>
|
||||
<input type="text" id="location-name" placeholder="My Location">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Latitude</label>
|
||||
<input type="number" id="location-lat" step="0.0001" placeholder="0.0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Longitude</label>
|
||||
<input type="number" id="location-lon" step="0.0001" placeholder="0.0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="browser-location-btn" class="btn">USE BROWSER LOCATION</button>
|
||||
<button id="save-location-btn" class="btn btn-primary">SAVE</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Display Tab -->
|
||||
<section id="tab-display" class="tab-pane hidden">
|
||||
<h2>Display Settings</h2>
|
||||
<div class="form-group">
|
||||
<label>Theme</label>
|
||||
<select id="theme-select"></select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Site Title</label>
|
||||
<input type="text" id="site-title" placeholder="ADS-Bit">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Subtitle</label>
|
||||
<input type="text" id="site-subtitle" placeholder="Retro Flight Tracker">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Temperature Unit</label>
|
||||
<select id="temp-unit">
|
||||
<option value="F">Fahrenheit (F)</option>
|
||||
<option value="C">Celsius (C)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="show-weather" checked> Show Weather
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="show-sidebar" checked> Show Sidebar
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Default View Direction</label>
|
||||
<select id="default-direction">
|
||||
<option value="0">North</option>
|
||||
<option value="90">East</option>
|
||||
<option value="180">South</option>
|
||||
<option value="270">West</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="save-display-btn" class="btn btn-primary">SAVE</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tuning Tab -->
|
||||
<section id="tab-tuning" class="tab-pane hidden">
|
||||
<h2>Performance Tuning</h2>
|
||||
<div class="form-group">
|
||||
<label>Flight Timeout (seconds)</label>
|
||||
<input type="number" id="flight-timeout" min="10" max="600" value="60">
|
||||
<span class="hint">Remove flights not seen in this many seconds</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Broadcast Interval (seconds)</label>
|
||||
<input type="number" id="broadcast-interval" min="1" max="30" value="1">
|
||||
<span class="hint">How often to push updates to viewers</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Cleanup Interval (seconds)</label>
|
||||
<input type="number" id="cleanup-interval" min="5" max="120" value="10">
|
||||
<span class="hint">How often to check for stale flights</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Receiver Reconnect Delay (seconds)</label>
|
||||
<input type="number" id="reconnect-delay" min="1" max="60" value="5">
|
||||
<span class="hint">Wait time before reconnecting to a lost receiver</span>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="save-tuning-btn" class="btn btn-primary">SAVE</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Security Tab -->
|
||||
<section id="tab-security" class="tab-pane hidden">
|
||||
<h2>Change Admin Password</h2>
|
||||
<div class="form-group">
|
||||
<label>Current Password</label>
|
||||
<input type="password" id="current-password" autocomplete="current-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>New Password</label>
|
||||
<input type="password" id="new-password" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Confirm New Password</label>
|
||||
<input type="password" id="confirm-password" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="change-password-btn" class="btn btn-primary">CHANGE PASSWORD</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
</div>
|
||||
|
||||
<script src="/admin/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
// ADS-Bit Admin Panel
|
||||
(function () {
|
||||
const loginScreen = document.getElementById('login-screen');
|
||||
const adminPanel = document.getElementById('admin-panel');
|
||||
let statusInterval = null;
|
||||
|
||||
// ------- Auth -------
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/check');
|
||||
if (res.ok) {
|
||||
showAdmin();
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* not logged in */ }
|
||||
showLogin();
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
loginScreen.classList.remove('hidden');
|
||||
adminPanel.classList.add('hidden');
|
||||
if (statusInterval) clearInterval(statusInterval);
|
||||
}
|
||||
|
||||
function showAdmin() {
|
||||
loginScreen.classList.add('hidden');
|
||||
adminPanel.classList.remove('hidden');
|
||||
loadConfig();
|
||||
loadThemes();
|
||||
refreshStatus();
|
||||
statusInterval = setInterval(refreshStatus, 5000);
|
||||
}
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const pw = document.getElementById('login-password').value;
|
||||
const errEl = document.getElementById('login-error');
|
||||
errEl.classList.add('hidden');
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: pw })
|
||||
});
|
||||
if (res.ok) {
|
||||
showAdmin();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
errEl.textContent = data.error || 'Login failed';
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
errEl.textContent = 'Connection error';
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('logout-btn').addEventListener('click', async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
showLogin();
|
||||
});
|
||||
|
||||
// ------- Tabs -------
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('hidden'));
|
||||
tab.classList.add('active');
|
||||
document.getElementById('tab-' + tab.dataset.tab).classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// ------- Dashboard -------
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/status');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
document.getElementById('stat-flights').textContent = data.active_flights;
|
||||
document.getElementById('stat-viewers').textContent = data.connected_viewers;
|
||||
document.getElementById('stat-receivers').textContent = data.receiver_count;
|
||||
document.getElementById('stat-uptime').textContent = data.uptime;
|
||||
|
||||
const ipsEl = document.getElementById('receiver-ips');
|
||||
if (data.receivers && data.receivers.length) {
|
||||
ipsEl.textContent = data.receivers.join(', ');
|
||||
} else {
|
||||
ipsEl.textContent = 'None connected';
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ------- Load Config -------
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/config');
|
||||
if (!res.ok) return;
|
||||
const cfg = await res.json();
|
||||
|
||||
// Receivers
|
||||
const mode = (cfg.receivers === 'AUTO') ? 'AUTO' : 'MANUAL';
|
||||
document.getElementById('receiver-mode').value = mode;
|
||||
toggleManualIps(mode);
|
||||
if (mode === 'MANUAL') {
|
||||
const ips = Array.isArray(cfg.receivers) ? cfg.receivers : [cfg.receivers];
|
||||
document.getElementById('receiver-ips-input').value = ips.join('\n');
|
||||
}
|
||||
document.getElementById('receiver-port').value = cfg.receiver_port || 30003;
|
||||
|
||||
// Location
|
||||
document.getElementById('location-name').value = cfg.location?.name || '';
|
||||
document.getElementById('location-lat').value = cfg.location?.lat || 0;
|
||||
document.getElementById('location-lon').value = cfg.location?.lon || 0;
|
||||
|
||||
// Display
|
||||
document.getElementById('site-title').value = cfg.site?.title || 'ADS-Bit';
|
||||
document.getElementById('site-subtitle').value = cfg.site?.subtitle || '';
|
||||
document.getElementById('temp-unit').value = cfg.display?.temperature_unit || 'F';
|
||||
document.getElementById('show-weather').checked = cfg.display?.show_weather !== false;
|
||||
document.getElementById('show-sidebar').checked = cfg.display?.show_sidebar !== false;
|
||||
document.getElementById('default-direction').value = cfg.display?.default_view_direction || 0;
|
||||
|
||||
// Set theme select to current theme
|
||||
const themeSelect = document.getElementById('theme-select');
|
||||
if (themeSelect.options.length > 0) {
|
||||
themeSelect.value = cfg.theme || 'desert';
|
||||
} else {
|
||||
themeSelect.dataset.pending = cfg.theme || 'desert';
|
||||
}
|
||||
|
||||
// Tuning
|
||||
document.getElementById('flight-timeout').value = cfg.tuning?.flight_timeout_seconds || 60;
|
||||
document.getElementById('broadcast-interval').value = cfg.tuning?.broadcast_interval_seconds || 1;
|
||||
document.getElementById('cleanup-interval').value = cfg.tuning?.cleanup_interval_seconds || 10;
|
||||
document.getElementById('reconnect-delay').value = cfg.tuning?.receiver_reconnect_seconds || 5;
|
||||
} catch (e) {
|
||||
console.error('Failed to load config', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ------- Load Themes -------
|
||||
async function loadThemes() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/themes');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const sel = document.getElementById('theme-select');
|
||||
sel.innerHTML = '';
|
||||
(data.themes || []).forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t;
|
||||
opt.textContent = t.charAt(0).toUpperCase() + t.slice(1);
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
// Apply pending value if config loaded first
|
||||
if (sel.dataset.pending) {
|
||||
sel.value = sel.dataset.pending;
|
||||
delete sel.dataset.pending;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ------- Receivers -------
|
||||
function toggleManualIps(mode) {
|
||||
document.getElementById('manual-ips-group').classList.toggle('hidden', mode === 'AUTO');
|
||||
}
|
||||
|
||||
document.getElementById('receiver-mode').addEventListener('change', (e) => {
|
||||
toggleManualIps(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById('scan-receivers-btn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('scan-receivers-btn');
|
||||
btn.textContent = 'SCANNING...';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/admin/scan-receivers', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
const box = document.getElementById('scan-results');
|
||||
const list = document.getElementById('scan-results-list');
|
||||
box.classList.remove('hidden');
|
||||
if (data.receivers && data.receivers.length) {
|
||||
list.textContent = data.receivers.join(', ');
|
||||
} else {
|
||||
list.textContent = 'No receivers found on network';
|
||||
}
|
||||
} catch (e) {
|
||||
toast('Scan failed', true);
|
||||
}
|
||||
btn.textContent = 'SCAN NOW';
|
||||
btn.disabled = false;
|
||||
});
|
||||
|
||||
document.getElementById('restart-receivers-btn').addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/restart-receivers', { method: 'POST' });
|
||||
if (res.ok) toast('Receivers restarted');
|
||||
else toast('Failed to restart', true);
|
||||
} catch (e) {
|
||||
toast('Error restarting receivers', true);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('save-receivers-btn').addEventListener('click', async () => {
|
||||
const mode = document.getElementById('receiver-mode').value;
|
||||
const port = parseInt(document.getElementById('receiver-port').value) || 30003;
|
||||
const body = { receiver_port: port };
|
||||
|
||||
if (mode === 'AUTO') {
|
||||
body.receivers = 'AUTO';
|
||||
} else {
|
||||
const ips = document.getElementById('receiver-ips-input').value
|
||||
.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
body.receivers = ips.length ? ips : 'AUTO';
|
||||
}
|
||||
|
||||
await saveConfig(body);
|
||||
});
|
||||
|
||||
// ------- Location -------
|
||||
document.getElementById('browser-location-btn').addEventListener('click', () => {
|
||||
if (!navigator.geolocation) {
|
||||
toast('Geolocation not supported', true);
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
document.getElementById('location-lat').value = pos.coords.latitude.toFixed(4);
|
||||
document.getElementById('location-lon').value = pos.coords.longitude.toFixed(4);
|
||||
toast('Location detected');
|
||||
},
|
||||
() => toast('Location access denied', true)
|
||||
);
|
||||
});
|
||||
|
||||
document.getElementById('save-location-btn').addEventListener('click', async () => {
|
||||
await saveConfig({
|
||||
location: {
|
||||
name: document.getElementById('location-name').value,
|
||||
lat: parseFloat(document.getElementById('location-lat').value) || 0,
|
||||
lon: parseFloat(document.getElementById('location-lon').value) || 0
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ------- Display -------
|
||||
document.getElementById('save-display-btn').addEventListener('click', async () => {
|
||||
await saveConfig({
|
||||
theme: document.getElementById('theme-select').value,
|
||||
site: {
|
||||
title: document.getElementById('site-title').value,
|
||||
subtitle: document.getElementById('site-subtitle').value
|
||||
},
|
||||
display: {
|
||||
temperature_unit: document.getElementById('temp-unit').value,
|
||||
show_weather: document.getElementById('show-weather').checked,
|
||||
show_sidebar: document.getElementById('show-sidebar').checked,
|
||||
default_view_direction: parseInt(document.getElementById('default-direction').value) || 0
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ------- Tuning -------
|
||||
document.getElementById('save-tuning-btn').addEventListener('click', async () => {
|
||||
await saveConfig({
|
||||
tuning: {
|
||||
flight_timeout_seconds: parseInt(document.getElementById('flight-timeout').value) || 60,
|
||||
broadcast_interval_seconds: parseInt(document.getElementById('broadcast-interval').value) || 1,
|
||||
cleanup_interval_seconds: parseInt(document.getElementById('cleanup-interval').value) || 10,
|
||||
receiver_reconnect_seconds: parseInt(document.getElementById('reconnect-delay').value) || 5
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ------- Security -------
|
||||
document.getElementById('change-password-btn').addEventListener('click', async () => {
|
||||
const current = document.getElementById('current-password').value;
|
||||
const newPw = document.getElementById('new-password').value;
|
||||
const confirm = document.getElementById('confirm-password').value;
|
||||
|
||||
if (newPw.length < 4) {
|
||||
toast('Password must be at least 4 characters', true);
|
||||
return;
|
||||
}
|
||||
if (newPw !== confirm) {
|
||||
toast('Passwords do not match', true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_password: current, new_password: newPw })
|
||||
});
|
||||
if (res.ok) {
|
||||
toast('Password changed');
|
||||
document.getElementById('current-password').value = '';
|
||||
document.getElementById('new-password').value = '';
|
||||
document.getElementById('confirm-password').value = '';
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast(data.error || 'Failed to change password', true);
|
||||
}
|
||||
} catch (e) {
|
||||
toast('Connection error', true);
|
||||
}
|
||||
});
|
||||
|
||||
// ------- Helpers -------
|
||||
async function saveConfig(body) {
|
||||
try {
|
||||
const res = await fetch('/api/admin/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) {
|
||||
toast('Settings saved');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast(data.error || 'Save failed', true);
|
||||
}
|
||||
} catch (e) {
|
||||
toast('Connection error', true);
|
||||
}
|
||||
}
|
||||
|
||||
function toast(msg, isError) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden', 'error');
|
||||
if (isError) el.classList.add('error');
|
||||
clearTimeout(el._timer);
|
||||
el._timer = setTimeout(() => el.classList.add('hidden'), 3000);
|
||||
}
|
||||
|
||||
// ------- Init -------
|
||||
checkAuth();
|
||||
})();
|
||||
Reference in New Issue
Block a user