Files
suphicakir 96b396149e initial commit
initial commit
2026-07-13 22:20:40 +03:00

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')">&times;</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')">&times;</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')">&times;</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, '&quot;')})">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>