96b396149e
initial commit
1347 lines
49 KiB
HTML
1347 lines
49 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>APRS Multi-Beacon Control Center</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
|
<style>
|
|
:root {
|
|
--bg: #f1f5f9;
|
|
--card: #ffffff;
|
|
--border: #cbd5e1;
|
|
--text-main: #0f172a;
|
|
--text-muted: #475569;
|
|
--accent: #2563eb;
|
|
--accent-hover: #1d4ed8;
|
|
--green: #047857;
|
|
--green-bg: #d1fae5;
|
|
--red: #b91c1c;
|
|
--red-bg: #fee2e2;
|
|
--purple: #7c3aed;
|
|
--purple-hover: #6d28d9;
|
|
--gray-btn: #e2e8f0;
|
|
--gray-btn-hover: #cbd5e1;
|
|
--gray-btn-text: #334155;
|
|
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
|
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.08);
|
|
--transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Outfit', sans-serif;
|
|
background-color: var(--bg);
|
|
color: var(--text-main);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* --- Header --- */
|
|
header {
|
|
background-color: var(--card);
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 1.25rem 2rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.logo-container h1 {
|
|
font-size: 1.35rem;
|
|
font-weight: 700;
|
|
letter-spacing: -0.025em;
|
|
color: var(--text-main);
|
|
}
|
|
|
|
.logo-container p {
|
|
font-size: 0.85rem;
|
|
color: var(--text-muted);
|
|
margin-top: 0.15rem;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
button {
|
|
font-family: 'Outfit', sans-serif;
|
|
font-weight: 600;
|
|
font-size: 0.875rem;
|
|
padding: 0.5rem 1rem;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.4rem;
|
|
transition: var(--transition);
|
|
}
|
|
|
|
.btn-primary {
|
|
background-color: var(--accent);
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background-color: var(--accent-hover);
|
|
}
|
|
|
|
.btn-success {
|
|
background-color: var(--green);
|
|
color: white;
|
|
}
|
|
|
|
.btn-success:hover {
|
|
background-color: #065f46;
|
|
}
|
|
|
|
.btn-gray {
|
|
background-color: var(--gray-btn);
|
|
color: var(--gray-btn-text);
|
|
}
|
|
|
|
.btn-gray:hover {
|
|
background-color: var(--gray-btn-hover);
|
|
}
|
|
|
|
.btn-warning {
|
|
background-color: #ea580c;
|
|
color: white;
|
|
}
|
|
|
|
.btn-warning:hover {
|
|
background-color: #c2410c;
|
|
}
|
|
|
|
/* --- Stats Dashboard Banner --- */
|
|
.stats-banner {
|
|
display: flex;
|
|
gap: 2rem;
|
|
padding: 1.25rem 2rem;
|
|
max-width: 1200px;
|
|
width: 100%;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.stat-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.95rem;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.stat-item span.val {
|
|
color: var(--text-main);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.stat-item.active-beacons span.val {
|
|
color: var(--green);
|
|
}
|
|
|
|
/* --- Map Card --- */
|
|
.map-card-style {
|
|
background-color: var(--card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
margin-bottom: 1.5rem;
|
|
height: 300px;
|
|
overflow: hidden;
|
|
box-shadow: var(--shadow);
|
|
position: relative;
|
|
}
|
|
|
|
/* --- Main Content --- */
|
|
main {
|
|
flex: 1;
|
|
padding: 0 2rem 2rem 2rem;
|
|
max-width: 1200px;
|
|
width: 100%;
|
|
margin: 0 auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.welcome-card {
|
|
background-color: var(--card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 3rem;
|
|
text-align: center;
|
|
box-shadow: var(--shadow);
|
|
margin-top: 2rem;
|
|
}
|
|
|
|
.welcome-card h2 {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.welcome-card p {
|
|
color: var(--text-muted);
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
/* --- Profile Cards --- */
|
|
.profiles-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.profile-card {
|
|
background-color: var(--card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
display: flex;
|
|
position: relative;
|
|
overflow: hidden;
|
|
box-shadow: var(--shadow);
|
|
transition: var(--transition);
|
|
}
|
|
|
|
.profile-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: var(--shadow-lg);
|
|
}
|
|
|
|
.status-strip {
|
|
width: 6px;
|
|
background-color: var(--border);
|
|
}
|
|
|
|
.status-strip.active {
|
|
background-color: var(--green);
|
|
}
|
|
|
|
.status-strip.stopped {
|
|
background-color: var(--red);
|
|
}
|
|
|
|
.card-content {
|
|
flex: 1;
|
|
padding: 1.25rem 1.5rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
/* Card Row 1: Header Info & Details */
|
|
.card-row1 {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.profile-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.profile-name {
|
|
font-size: 1.15rem;
|
|
font-weight: 700;
|
|
color: var(--accent);
|
|
}
|
|
|
|
.profile-callsign {
|
|
font-size: 0.95rem;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
background-color: var(--bg);
|
|
padding: 0.15rem 0.5rem;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.status-pill {
|
|
font-size: 0.75rem;
|
|
font-weight: 700;
|
|
padding: 0.2rem 0.6rem;
|
|
border-radius: 9999px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.status-pill.active {
|
|
color: var(--green);
|
|
background-color: var(--green-bg);
|
|
}
|
|
|
|
.status-pill.stopped {
|
|
color: var(--red);
|
|
background-color: var(--red-bg);
|
|
}
|
|
|
|
.profile-details {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, auto);
|
|
gap: 1.5rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.detail-item {
|
|
display: flex;
|
|
gap: 0.35rem;
|
|
}
|
|
|
|
.detail-lbl {
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.detail-val {
|
|
font-weight: 500;
|
|
color: var(--text-main);
|
|
}
|
|
|
|
/* Card Row 2: Metrics & Actions */
|
|
.card-row2 {
|
|
border-top: 1px solid var(--bg);
|
|
padding-top: 0.75rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.profile-metrics {
|
|
display: flex;
|
|
gap: 1.5rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.metric-packets {
|
|
font-size: 0.85rem;
|
|
font-weight: 700;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.metric-packets.active {
|
|
color: var(--green);
|
|
}
|
|
|
|
.autostart-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.autostart-toggle input {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.profile-actions {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.profile-actions button {
|
|
font-size: 0.75rem;
|
|
padding: 0.35rem 0.75rem;
|
|
}
|
|
|
|
/* --- Footer --- */
|
|
footer {
|
|
text-align: center;
|
|
padding: 1.5rem;
|
|
font-size: 0.8rem;
|
|
color: var(--text-muted);
|
|
border-top: 1px solid var(--border);
|
|
background-color: var(--card);
|
|
margin-top: auto;
|
|
}
|
|
|
|
/* --- Modal Structure --- */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: rgba(15, 23, 42, 0.4);
|
|
backdrop-filter: blur(4px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.modal-overlay.open {
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.modal-content {
|
|
background-color: var(--card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
width: 100%;
|
|
max-width: 540px;
|
|
box-shadow: var(--shadow-lg);
|
|
display: flex;
|
|
flex-direction: column;
|
|
transform: scale(0.95);
|
|
transition: transform 0.2s ease;
|
|
}
|
|
|
|
.modal-overlay.open .modal-content {
|
|
transform: scale(1);
|
|
}
|
|
|
|
.modal-header {
|
|
padding: 1.25rem 1.5rem;
|
|
border-bottom: 1px solid var(--bg);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.modal-header h3 {
|
|
font-size: 1.15rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.modal-close {
|
|
background: none;
|
|
border: none;
|
|
font-size: 1.5rem;
|
|
font-weight: 300;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
line-height: 1;
|
|
}
|
|
|
|
.modal-body {
|
|
padding: 1.5rem;
|
|
overflow-y: auto;
|
|
max-height: 70vh;
|
|
}
|
|
|
|
.modal-footer {
|
|
padding: 1rem 1.5rem;
|
|
border-top: 1px solid var(--bg);
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
/* --- Forms --- */
|
|
.form-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.form-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.35rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.form-group.full-width {
|
|
grid-column: span 2;
|
|
}
|
|
|
|
label {
|
|
font-size: 0.85rem;
|
|
font-weight: 700;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
input[type="text"], input[type="number"], select {
|
|
font-family: 'Outfit', sans-serif;
|
|
font-size: 0.9rem;
|
|
padding: 0.5rem 0.75rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
background-color: var(--bg);
|
|
color: var(--text-main);
|
|
outline: none;
|
|
transition: var(--transition);
|
|
}
|
|
|
|
input[type="text"]:focus, input[type="number"]:focus, select:focus {
|
|
border-color: var(--accent);
|
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
|
}
|
|
|
|
.form-checkbox-container {
|
|
border: 1px solid var(--border);
|
|
background-color: var(--card);
|
|
border-radius: 8px;
|
|
padding: 0.75rem 1rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.checkbox-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.checkbox-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.9rem;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* --- Log Viewer Console --- */
|
|
.log-terminal {
|
|
background-color: #ffffff;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 0.8rem;
|
|
line-height: 1.4;
|
|
padding: 1rem;
|
|
height: 320px;
|
|
overflow-y: auto;
|
|
white-space: pre-wrap;
|
|
color: var(--text-main);
|
|
}
|
|
|
|
/* --- Chat Window --- */
|
|
.chat-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 400px;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.chat-history {
|
|
flex: 1;
|
|
background-color: #ffffff;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 1rem;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.65rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.chat-bubble {
|
|
max-width: 85%;
|
|
padding: 0.5rem 0.75rem;
|
|
border-radius: 6px;
|
|
line-height: 1.4;
|
|
align-self: flex-start;
|
|
}
|
|
|
|
.chat-bubble.sent {
|
|
background-color: var(--green-bg);
|
|
color: var(--green);
|
|
align-self: flex-end;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.chat-bubble.received {
|
|
background-color: #dbeafe;
|
|
color: var(--accent);
|
|
align-self: flex-start;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.chat-bubble.system {
|
|
background-color: transparent;
|
|
color: var(--text-muted);
|
|
align-self: center;
|
|
font-style: italic;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.chat-bubble-meta {
|
|
font-size: 0.7rem;
|
|
color: var(--text-muted);
|
|
margin-bottom: 0.15rem;
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.chat-input-row {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.chat-input-row input {
|
|
flex: 1;
|
|
}
|
|
|
|
.chat-char-counter {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
min-width: 35px;
|
|
text-align: right;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- Header -->
|
|
<header>
|
|
<div class="logo-container">
|
|
<h1>APRS MULTI-BEACON CONTROL CENTER</h1>
|
|
<p>Real-Time Profile & Daemon Management</p>
|
|
</div>
|
|
<div class="header-actions">
|
|
<button class="btn-success" onclick="openAddProfileModal()">+ Add Profile</button>
|
|
<button class="btn-gray" onclick="triggerImport()">Import Settings</button>
|
|
<button class="btn-gray" onclick="triggerExport()">Export Settings</button>
|
|
<button class="btn-warning" onclick="triggerUpdate()">Update App</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Stats Dashboard Banner -->
|
|
<div class="stats-banner">
|
|
<div class="stat-item">
|
|
<span>Profiles:</span>
|
|
<span class="val" id="stat-profiles">0</span>
|
|
</div>
|
|
<div class="stat-item active-beacons">
|
|
<span>Active Beacons:</span>
|
|
<span class="val" id="stat-active">0</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span>APRS Gateway Link:</span>
|
|
<span class="val" id="stat-latency">Checking...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Content -->
|
|
<main>
|
|
<!-- Interactive Map Card -->
|
|
<div id="map-card" class="map-card-style" style="display: none;">
|
|
<div id="map" style="width: 100%; height: 100%;"></div>
|
|
</div>
|
|
|
|
<!-- Hidden input for file import -->
|
|
<input type="file" id="import-file-input" accept=".json" style="display: none;" onchange="handleImportFile(this)">
|
|
|
|
<div id="welcome-message" class="welcome-card" style="display: none;">
|
|
<h2>No APRS profiles configured.</h2>
|
|
<p>Click '+ Add Profile' to create your first configuration.</p>
|
|
<button class="btn-success" onclick="openAddProfileModal()">+ Add Profile</button>
|
|
</div>
|
|
|
|
<div id="profiles-container" class="profiles-list">
|
|
<!-- Profiles cards injected here -->
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Footer -->
|
|
<footer>
|
|
APRS Multi-Beacon Control Center | by TA1XTA
|
|
</footer>
|
|
|
|
<!-- ADD/EDIT PROFILE MODAL -->
|
|
<div id="profile-modal" class="modal-overlay">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 id="modal-title">APRS Beacon Settings</h3>
|
|
<button class="modal-close" onclick="closeModal('profile-modal')">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="profile-form">
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label for="form-name">Profile Name (Single word)</label>
|
|
<input type="text" id="form-name" required placeholder="e.g. mobile">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="form-callsign">Callsign (with SSID)</label>
|
|
<input type="text" id="form-callsign" required placeholder="e.g. TA1XTA-1">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="form-passcode">APRS-IS Passcode (Auto-calc if empty)</label>
|
|
<input type="text" id="form-passcode" placeholder="e.g. 17082">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="form-interval">Interval (Minutes)</label>
|
|
<input type="number" id="form-interval" value="5" min="1" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="form-latitude">Latitude (e.g. 41.032633)</label>
|
|
<input type="text" id="form-latitude" required placeholder="41.032633">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="form-longitude">Longitude (e.g. 28.987083)</label>
|
|
<input type="text" id="form-longitude" required placeholder="28.987083">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="form-symbol">Symbol (e.g. >, X, [)</label>
|
|
<input type="text" id="form-symbol" value="X" maxlength="1">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="form-comment">Status Comment</label>
|
|
<input type="text" id="form-comment" value="APRS Background Beacon">
|
|
</div>
|
|
|
|
<!-- Thursday Check-in settings -->
|
|
<div class="form-group full-width form-checkbox-container">
|
|
<div class="checkbox-row">
|
|
<label class="checkbox-label" for="form-thursday">
|
|
<input type="checkbox" id="form-thursday" onchange="toggleThursdayFields()">
|
|
Join APRS Thursday Event (ANSRVR)
|
|
</label>
|
|
<div>
|
|
<label for="form-thursday-time" style="margin-right: 0.25rem;">Time (HH:MM):</label>
|
|
<input type="text" id="form-thursday-time" value="20:00" style="width: 70px; display: inline-block;">
|
|
</div>
|
|
</div>
|
|
<div class="form-group" id="thursday-msg-group" style="margin-top: 0.75rem; display: none;">
|
|
<label for="form-thursday-msg">Thursday Message:</label>
|
|
<input type="text" id="form-thursday-msg" value="CQ HOTG 73 FROM TURKIYE #APRSTHURSDAY">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Autostart Checkbox -->
|
|
<div class="form-group full-width">
|
|
<label class="checkbox-label" for="form-autostart">
|
|
<input type="checkbox" id="form-autostart">
|
|
Start on system boot (Autostart)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn-gray" onclick="closeModal('profile-modal')">Cancel</button>
|
|
<button class="btn-primary" onclick="saveProfile()">Save Settings</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- LOG VIEWER MODAL -->
|
|
<div id="logs-modal" class="modal-overlay">
|
|
<div class="modal-content" style="max-width: 680px;">
|
|
<div class="modal-header">
|
|
<h3 id="logs-modal-title">Live Log Monitor</h3>
|
|
<button class="modal-close" onclick="closeModal('logs-modal')">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p style="font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.5rem;">Real-time console updates (last 100 entries).</p>
|
|
<div id="log-console" class="log-terminal">Loading logs...</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn-gray" onclick="closeModal('logs-modal')">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CHAT WINDOW MODAL -->
|
|
<div id="chat-modal" class="modal-overlay">
|
|
<div class="modal-content" style="max-width: 580px;">
|
|
<div class="modal-header">
|
|
<h3 id="chat-modal-title">APRS Chat</h3>
|
|
<button class="modal-close" onclick="closeModal('chat-modal')">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="chat-container">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; margin-bottom: 0.5rem;">
|
|
<span id="chat-sender-callsign" style="font-weight: 700; color: var(--accent);">Sender: Checking...</span>
|
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
<label for="chat-recipient" style="font-size: 0.8rem;">To Callsign:</label>
|
|
<input type="text" id="chat-recipient" value="SMSGTE" style="width: 120px; font-size: 0.8rem; padding: 0.25rem 0.5rem;">
|
|
</div>
|
|
</div>
|
|
<div id="chat-box" class="chat-history">
|
|
<div class="chat-bubble system">Connecting to APRS Chat...</div>
|
|
</div>
|
|
<div class="chat-input-row">
|
|
<input type="text" id="chat-msg" placeholder="Type a message..." maxlength="67" oninput="updateChatCharCount(this)">
|
|
<span id="chat-counter" class="chat-char-counter">0/67</span>
|
|
<button class="btn-success" id="chat-send-btn" onclick="sendChatMessage()">Send</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let editMode = false;
|
|
let logPollInterval = null;
|
|
let chatPollInterval = null;
|
|
let activeChatProfile = null;
|
|
let activeLogProfile = null;
|
|
|
|
// Leaflet Map variables
|
|
let map = null;
|
|
let mapMarkers = {};
|
|
|
|
// Fetch stats & profiles on startup
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
fetchStats();
|
|
fetchProfiles();
|
|
setInterval(fetchStats, 10000);
|
|
setInterval(fetchProfiles, 10000);
|
|
|
|
// Bind Return key to chat input
|
|
document.getElementById("chat-msg").addEventListener("keypress", (e) => {
|
|
if (e.key === "Enter") {
|
|
sendChatMessage();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Fetch Global Stats
|
|
function fetchStats() {
|
|
fetch('api/status')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
document.getElementById("stat-profiles").innerText = data.total_profiles;
|
|
document.getElementById("stat-active").innerText = data.active_beacons;
|
|
|
|
const latVal = document.getElementById("stat-latency");
|
|
latVal.innerText = data.latency;
|
|
if (data.latency.includes("ms")) {
|
|
latVal.style.color = "var(--green)";
|
|
} else {
|
|
latVal.style.color = "var(--red)";
|
|
}
|
|
});
|
|
}
|
|
|
|
// Initialize Map
|
|
function initMap(lat, lon) {
|
|
if (map) return;
|
|
map = L.map('map').setView([lat, lon], 10);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© OpenStreetMap contributors'
|
|
}).addTo(map);
|
|
}
|
|
|
|
// Update Map Markers dynamically
|
|
function updateMapMarkers(profiles) {
|
|
const mapCard = document.getElementById("map-card");
|
|
const coordsProfiles = profiles.filter(p => p.latitude && p.longitude);
|
|
|
|
if (coordsProfiles.length === 0) {
|
|
mapCard.style.display = "none";
|
|
return;
|
|
}
|
|
|
|
mapCard.style.display = "block";
|
|
|
|
const first = coordsProfiles[0];
|
|
if (!map) {
|
|
initMap(first.latitude, first.longitude);
|
|
} else {
|
|
map.invalidateSize();
|
|
}
|
|
|
|
// Remove deleted markers
|
|
const profileNames = profiles.map(p => p.name);
|
|
Object.keys(mapMarkers).forEach(name => {
|
|
if (!profileNames.includes(name)) {
|
|
map.removeLayer(mapMarkers[name]);
|
|
delete mapMarkers[name];
|
|
}
|
|
});
|
|
|
|
// Add/Update markers
|
|
const bounds = [];
|
|
coordsProfiles.forEach(p => {
|
|
const lat = parseFloat(p.latitude);
|
|
const lon = parseFloat(p.longitude);
|
|
bounds.push([lat, lon]);
|
|
|
|
const statusText = p.running ? 'ACTIVE' : 'STOPPED';
|
|
const statusColor = p.running ? 'var(--green)' : 'var(--red)';
|
|
|
|
const popupContent = `
|
|
<div style="font-family: 'Outfit', sans-serif; font-size: 0.85rem; line-height: 1.4;">
|
|
<strong style="color: var(--accent); font-size: 0.95rem;">${p.name.toUpperCase()}</strong> (${p.callsign})<br/>
|
|
<span style="font-weight: 700; color: ${statusColor}">${statusText}</span><br/>
|
|
<b>Coordinates:</b> ${lat}, ${lon}<br/>
|
|
<b>Interval:</b> ${p.interval} min<br/>
|
|
<b>Comment:</b> ${p.comment}<br/>
|
|
<b>Packets Sent:</b> ${p.packets_sent}
|
|
</div>
|
|
`;
|
|
|
|
if (mapMarkers[p.name]) {
|
|
mapMarkers[p.name].setLatLng([lat, lon]);
|
|
mapMarkers[p.name].getPopup().setContent(popupContent);
|
|
} else {
|
|
const marker = L.marker([lat, lon]).addTo(map);
|
|
marker.bindPopup(popupContent);
|
|
mapMarkers[p.name] = marker;
|
|
}
|
|
});
|
|
|
|
// Pan & zoom to fit markers
|
|
if (bounds.length > 0) {
|
|
map.fitBounds(bounds, { padding: [40, 40] });
|
|
}
|
|
}
|
|
|
|
// Fetch Profiles List
|
|
function fetchProfiles() {
|
|
fetch('api/profiles')
|
|
.then(r => r.json())
|
|
.then(profiles => {
|
|
const container = document.getElementById("profiles-container");
|
|
const welcome = document.getElementById("welcome-message");
|
|
|
|
if (profiles.length === 0) {
|
|
container.style.display = "none";
|
|
welcome.style.display = "block";
|
|
updateMapMarkers([]);
|
|
return;
|
|
}
|
|
|
|
welcome.style.display = "none";
|
|
container.style.display = "flex";
|
|
|
|
// Render Map markers
|
|
updateMapMarkers(profiles);
|
|
|
|
// Generate HTML cards
|
|
let html = '';
|
|
profiles.forEach(p => {
|
|
const statusClass = p.running ? 'active' : 'stopped';
|
|
const statusText = p.running ? 'ACTIVE' : 'STOPPED';
|
|
const toggleBtnText = p.running ? 'STOP' : 'START';
|
|
const toggleBtnClass = p.running ? 'btn-gray' : 'btn-primary';
|
|
const thursText = p.aprs_thursday ? `Enabled (${p.aprs_thursday_time})` : 'Disabled';
|
|
|
|
html += `
|
|
<div class="profile-card" id="card-${p.name}">
|
|
<div class="status-strip ${statusClass}"></div>
|
|
<div class="card-content">
|
|
<div class="card-row1">
|
|
<div class="profile-info">
|
|
<span class="profile-name">${p.name.toUpperCase()}</span>
|
|
<span class="profile-callsign">${p.callsign}</span>
|
|
<span class="status-pill ${statusClass}">${statusText}</span>
|
|
</div>
|
|
<div class="profile-details">
|
|
<div class="detail-item">
|
|
<span class="detail-lbl">Interval:</span>
|
|
<span class="detail-val">${p.interval} min</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-lbl">Location:</span>
|
|
<span class="detail-val">${p.latitude}, ${p.longitude} (${p.symbol})</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-lbl">Thursday:</span>
|
|
<span class="detail-val">${thursText}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card-row2">
|
|
<div class="profile-metrics">
|
|
<span class="metric-packets ${p.packets_sent > 0 ? 'active' : ''}">Packets Sent: ${p.packets_sent}</span>
|
|
<label class="autostart-toggle">
|
|
<input type="checkbox" ${p.autostart ? 'checked' : ''} onchange="toggleAutostart('${p.name}', this.checked)">
|
|
Start on boot
|
|
</label>
|
|
</div>
|
|
<div class="profile-actions">
|
|
<button class="${toggleBtnClass}" onclick="toggleProfile('${p.name}')">${toggleBtnText}</button>
|
|
<button class="btn-gray" onclick="openLogsModal('${p.name}')">Logs</button>
|
|
<button class="btn-gray" style="background-color: #f3e8ff; color: var(--purple);" onclick="openChatModal('${p.name}', '${p.callsign}')">Chat</button>
|
|
<button class="btn-gray" style="color: #2563eb;" onclick="openMapLink('${p.callsign}')">Map</button>
|
|
<button class="btn-gray" onclick="openEditProfileModal(${JSON.stringify(p).replace(/"/g, '"')})">Edit</button>
|
|
<button class="btn-gray" style="color: var(--red);" onclick="deleteProfile('${p.name}')">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
container.innerHTML = html;
|
|
});
|
|
}
|
|
|
|
// Action: Toggle Active status
|
|
function toggleProfile(name) {
|
|
fetch(`api/profiles/${name}/toggle`, { method: 'POST' })
|
|
.then(r => r.json())
|
|
.then(res => {
|
|
fetchProfiles();
|
|
fetchStats();
|
|
});
|
|
}
|
|
|
|
// Action: Toggle Autostart checkbox
|
|
function toggleAutostart(name, enable) {
|
|
fetch(`api/profiles/${name}/autostart`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enable })
|
|
});
|
|
}
|
|
|
|
// Action: Open Map Link
|
|
function openMapLink(callsign) {
|
|
window.open(`https://aprs.fi/#!call=a%2F${callsign}`, '_blank');
|
|
}
|
|
|
|
// Action: Delete Profile
|
|
function deleteProfile(name) {
|
|
if (confirm(`Are you sure you want to delete profile '${name}'?`)) {
|
|
fetch(`api/profiles/${name}/delete`, { method: 'POST' })
|
|
.then(r => r.json())
|
|
.then(res => {
|
|
fetchProfiles();
|
|
fetchStats();
|
|
});
|
|
}
|
|
}
|
|
|
|
// Modal triggers
|
|
function openModal(id) {
|
|
document.getElementById(id).classList.add("open");
|
|
}
|
|
|
|
function closeModal(id) {
|
|
document.getElementById(id).classList.remove("open");
|
|
if (id === 'logs-modal') {
|
|
clearInterval(logPollInterval);
|
|
activeLogProfile = null;
|
|
}
|
|
if (id === 'chat-modal') {
|
|
clearInterval(chatPollInterval);
|
|
activeChatProfile = null;
|
|
}
|
|
}
|
|
|
|
// Toggle custom Thursday fields
|
|
function toggleThursdayFields() {
|
|
const active = document.getElementById("form-thursday").checked;
|
|
document.getElementById("thursday-msg-group").style.display = active ? "block" : "none";
|
|
}
|
|
|
|
// Profile Form management
|
|
function openAddProfileModal() {
|
|
editMode = false;
|
|
document.getElementById("modal-title").innerText = "APRS Beacon Settings";
|
|
document.getElementById("form-name").disabled = false;
|
|
document.getElementById("profile-form").reset();
|
|
toggleThursdayFields();
|
|
openModal('profile-modal');
|
|
}
|
|
|
|
function openEditProfileModal(p) {
|
|
editMode = true;
|
|
document.getElementById("modal-title").innerText = "Edit APRS Configuration";
|
|
|
|
document.getElementById("form-name").value = p.name;
|
|
document.getElementById("form-name").disabled = true;
|
|
|
|
document.getElementById("form-callsign").value = p.callsign;
|
|
document.getElementById("form-passcode").value = p.passcode;
|
|
document.getElementById("form-interval").value = p.interval;
|
|
document.getElementById("form-latitude").value = p.latitude;
|
|
document.getElementById("form-longitude").value = p.longitude;
|
|
document.getElementById("form-symbol").value = p.symbol;
|
|
document.getElementById("form-comment").value = p.comment;
|
|
|
|
document.getElementById("form-thursday").checked = p.aprs_thursday;
|
|
document.getElementById("form-thursday-time").value = p.aprs_thursday_time;
|
|
document.getElementById("form-thursday-msg").value = p.aprs_thursday_msg;
|
|
toggleThursdayFields();
|
|
|
|
document.getElementById("form-autostart").checked = p.autostart;
|
|
|
|
openModal('profile-modal');
|
|
}
|
|
|
|
function saveProfile() {
|
|
const name = document.getElementById("form-name").value.trim().toLowerCase();
|
|
const callsign = document.getElementById("form-callsign").value.trim().toUpperCase();
|
|
|
|
if (!name || !callsign) {
|
|
alert("Profile name and callsign are required.");
|
|
return;
|
|
}
|
|
|
|
const data = {
|
|
name,
|
|
callsign,
|
|
passcode: document.getElementById("form-passcode").value.trim(),
|
|
interval: document.getElementById("form-interval").value,
|
|
latitude: document.getElementById("form-latitude").value.trim(),
|
|
longitude: document.getElementById("form-longitude").value.trim(),
|
|
symbol: document.getElementById("form-symbol").value.trim(),
|
|
comment: document.getElementById("form-comment").value.trim(),
|
|
aprs_thursday: document.getElementById("form-thursday").checked,
|
|
aprs_thursday_time: document.getElementById("form-thursday-time").value.trim(),
|
|
aprs_thursday_msg: document.getElementById("form-thursday-msg").value.trim(),
|
|
autostart: document.getElementById("form-autostart").checked
|
|
};
|
|
|
|
fetch('api/profiles/save', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
})
|
|
.then(r => r.json())
|
|
.then(res => {
|
|
if (res.success) {
|
|
closeModal('profile-modal');
|
|
fetchProfiles();
|
|
fetchStats();
|
|
} else {
|
|
alert(res.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Log Viewer implementation
|
|
function openLogsModal(name) {
|
|
activeLogProfile = name;
|
|
document.getElementById("logs-modal-title").innerText = `Live Log Monitor - ${name.toUpperCase()}`;
|
|
document.getElementById("log-console").innerText = "Loading logs...";
|
|
openModal('logs-modal');
|
|
pollLogs();
|
|
logPollInterval = setInterval(pollLogs, 2000);
|
|
}
|
|
|
|
function pollLogs() {
|
|
if (!activeLogProfile) return;
|
|
fetch(`api/profiles/${activeLogProfile}/logs`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const consoleElement = document.getElementById("log-console");
|
|
consoleElement.innerText = data.logs;
|
|
consoleElement.scrollTop = consoleElement.scrollHeight; // Auto-scroll
|
|
});
|
|
}
|
|
|
|
// Chat Window implementation
|
|
function openChatModal(name, callsign) {
|
|
activeChatProfile = name;
|
|
document.getElementById("chat-modal-title").innerText = `APRS Chat`;
|
|
document.getElementById("chat-sender-callsign").innerText = `Sender: ${callsign}`;
|
|
document.getElementById("chat-box").innerHTML = '<div class="chat-bubble system">Connecting to APRS Chat...</div>';
|
|
openModal('chat-modal');
|
|
pollChat();
|
|
chatPollInterval = setInterval(pollChat, 3000);
|
|
}
|
|
|
|
function pollChat() {
|
|
if (!activeChatProfile) return;
|
|
fetch(`api/chat/messages?profile=${activeChatProfile}`)
|
|
.then(r => r.json())
|
|
.then(msgs => {
|
|
const chatBox = document.getElementById("chat-box");
|
|
if (msgs.length === 0) {
|
|
chatBox.innerHTML = '<div class="chat-bubble system">No messages. Send a message to get started.</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
msgs.forEach(m => {
|
|
let bubbleClass = 'system';
|
|
if (m.sender === 'YOU') bubbleClass = 'sent';
|
|
else if (m.sender !== 'SYSTEM') bubbleClass = 'received';
|
|
|
|
html += `
|
|
<div class="chat-bubble ${bubbleClass}">
|
|
${m.sender !== 'SYSTEM' && m.sender !== 'YOU' ? `<div class="chat-bubble-meta"><span>${m.sender}</span><span>${m.timestamp}</span></div>` : ''}
|
|
${m.text}
|
|
${m.sender === 'YOU' ? `<div class="chat-bubble-meta" style="justify-content: flex-end; color: rgba(4, 120, 87, 0.75);"><span>${m.timestamp}</span></div>` : ''}
|
|
</div>
|
|
`;
|
|
});
|
|
chatBox.innerHTML = html;
|
|
chatBox.scrollTop = chatBox.scrollHeight;
|
|
});
|
|
}
|
|
|
|
// Character counter
|
|
function updateChatCharCount(input) {
|
|
const count = input.value.length;
|
|
document.getElementById("chat-counter").innerText = `${count}/67`;
|
|
}
|
|
|
|
// Send Chat
|
|
function sendChatMessage() {
|
|
const toCallsign = document.getElementById("chat-recipient").value.trim().toUpperCase();
|
|
const message = document.getElementById("chat-msg").value.trim();
|
|
const sendBtn = document.getElementById("chat-send-btn");
|
|
|
|
if (!toCallsign || !message) return;
|
|
|
|
sendBtn.disabled = true;
|
|
sendBtn.innerText = "Sending...";
|
|
|
|
fetch('api/chat/send', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
profile: activeChatProfile,
|
|
to_callsign: toCallsign,
|
|
message: message
|
|
})
|
|
})
|
|
.then(r => r.json())
|
|
.then(res => {
|
|
sendBtn.disabled = false;
|
|
sendBtn.innerText = "Send";
|
|
|
|
if (res.success) {
|
|
document.getElementById("chat-msg").value = '';
|
|
document.getElementById("chat-counter").innerText = "0/67";
|
|
pollChat();
|
|
} else {
|
|
alert("Send Error: " + res.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
// JSON Settings Export (Download as file)
|
|
function triggerExport() {
|
|
fetch('api/profiles')
|
|
.then(r => r.json())
|
|
.then(profiles => {
|
|
if (profiles.length === 0) {
|
|
alert("No profiles to export.");
|
|
return;
|
|
}
|
|
const backup = {};
|
|
profiles.forEach(p => {
|
|
backup[p.name] = {
|
|
callsign: p.callsign,
|
|
passcode: p.passcode,
|
|
latitude: p.latitude,
|
|
longitude: p.longitude,
|
|
use_termux_gps: false,
|
|
symbol_table: "/",
|
|
symbol_code: p.symbol,
|
|
comment: p.comment,
|
|
interval_minutes: p.interval,
|
|
aprs_thursday: p.aprs_thursday,
|
|
aprs_thursday_time: p.aprs_thursday_time,
|
|
aprs_thursday_msg: p.aprs_thursday_msg,
|
|
server: "rotate.aprs2.net",
|
|
port: 14580
|
|
};
|
|
});
|
|
|
|
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(backup, null, 4));
|
|
const downloadAnchor = document.createElement('a');
|
|
downloadAnchor.setAttribute("href", dataStr);
|
|
downloadAnchor.setAttribute("download", `aprs_backup_${new Date().toISOString().slice(0,10)}.json`);
|
|
document.body.appendChild(downloadAnchor);
|
|
downloadAnchor.click();
|
|
downloadAnchor.remove();
|
|
});
|
|
}
|
|
|
|
// Trigger file import dialog
|
|
function triggerImport() {
|
|
document.getElementById('import-file-input').click();
|
|
}
|
|
|
|
// Handle uploaded JSON file and import profiles
|
|
function handleImportFile(input) {
|
|
const file = input.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = function(e) {
|
|
try {
|
|
const data = JSON.parse(e.target.result);
|
|
let profiles = [];
|
|
|
|
if (data.profiles) {
|
|
profiles = Object.entries(data.profiles).map(([name, p]) => ({ name, ...p }));
|
|
} else if (Array.isArray(data)) {
|
|
profiles = data;
|
|
} else if (typeof data === 'object') {
|
|
profiles = Object.entries(data).map(([name, p]) => ({ name, ...p }));
|
|
}
|
|
|
|
if (profiles.length === 0) {
|
|
alert("No profiles found in the backup file.");
|
|
return;
|
|
}
|
|
|
|
if (confirm(`Do you want to import ${profiles.length} profile(s)?`)) {
|
|
let completed = 0;
|
|
profiles.forEach(p => {
|
|
const payload = {
|
|
name: p.name || p.profile_name,
|
|
callsign: p.callsign,
|
|
passcode: p.passcode ? String(p.passcode) : "",
|
|
interval: p.interval_minutes || p.interval || 5,
|
|
latitude: p.latitude,
|
|
longitude: p.longitude,
|
|
symbol: p.symbol_code || p.symbol || "X",
|
|
comment: p.comment,
|
|
aprs_thursday: p.aprs_thursday || false,
|
|
aprs_thursday_time: p.aprs_thursday_time || "20:00",
|
|
aprs_thursday_msg: p.aprs_thursday_msg || "",
|
|
autostart: false
|
|
};
|
|
|
|
fetch('api/profiles/save', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
})
|
|
.then(r => r.json())
|
|
.then(res => {
|
|
completed++;
|
|
if (completed === profiles.length) {
|
|
alert("All profiles imported successfully!");
|
|
fetchProfiles();
|
|
fetchStats();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
} catch(err) {
|
|
alert("Error parsing backup file: " + err);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
input.value = ''; // Reset
|
|
}
|
|
|
|
// Application Self Update check
|
|
function triggerUpdate() {
|
|
if (confirm("Check and fetch latest software updates?")) {
|
|
alert("Checking updates...");
|
|
// Handled in background loop natively daily, or via manual click if needed
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|