fix: Persist tracked satellites to database (fixes #135)

Satellites added via CelesTrak import or TLE paste are now stored in
SQLite and survive page reloads and app restarts. Adds CRUD API
endpoints and wires frontend sidebar + dashboard to use them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-14 20:15:21 +00:00
parent b04e335f49
commit 332735cecf
4 changed files with 379 additions and 65 deletions

View File

@@ -16,6 +16,13 @@ from flask import Blueprint, jsonify, request, render_template, Response
from config import SHARED_OBSERVER_LOCATION_ENABLED
from data.satellites import TLE_SATELLITES
from utils.database import (
get_tracked_satellites,
add_tracked_satellite,
bulk_add_tracked_satellites,
update_tracked_satellite,
remove_tracked_satellite,
)
from utils.logging import satellite_logger as logger
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
@@ -31,18 +38,38 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
_tle_cache = dict(TLE_SATELLITES)
def _load_db_satellites_into_cache():
"""Load user-tracked satellites from DB into the TLE cache."""
global _tle_cache
try:
db_sats = get_tracked_satellites()
loaded = 0
for sat in db_sats:
if sat['tle_line1'] and sat['tle_line2']:
# Use a cache key derived from name (sanitised)
cache_key = sat['name'].replace(' ', '-').upper()
if cache_key not in _tle_cache:
_tle_cache[cache_key] = (sat['name'], sat['tle_line1'], sat['tle_line2'])
loaded += 1
if loaded:
logger.info(f"Loaded {loaded} user-tracked satellites into TLE cache")
except Exception as e:
logger.warning(f"Failed to load DB satellites into TLE cache: {e}")
def init_tle_auto_refresh():
"""Initialize TLE auto-refresh. Called by app.py after initialization."""
import threading
def _auto_refresh_tle():
try:
_load_db_satellites_into_cache()
updated = refresh_tle_data()
if updated:
logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}")
except Exception as e:
logger.warning(f"Auto TLE refresh failed: {e}")
# Start auto-refresh in background
threading.Timer(2.0, _auto_refresh_tle).start()
logger.info("TLE auto-refresh scheduled")
@@ -168,13 +195,13 @@ def predict_passes():
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
norad_to_name = {
25544: 'ISS',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3'
}
sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3'])
norad_to_name = {
25544: 'ISS',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3'
}
sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3'])
satellites = []
for sat in sat_input:
if isinstance(sat, int) and sat in norad_to_name:
@@ -183,11 +210,11 @@ def predict_passes():
satellites.append(sat)
passes = []
colors = {
'ISS': '#00ffff',
'METEOR-M2': '#9370DB',
'METEOR-M2-3': '#ff00ff'
}
colors = {
'ISS': '#00ffff',
'METEOR-M2': '#9370DB',
'METEOR-M2-3': '#ff00ff'
}
name_to_norad = {v: k for k, v in norad_to_name.items()}
ts = load.timescale()
@@ -319,11 +346,11 @@ def get_satellite_position():
sat_input = data.get('satellites', [])
include_track = bool(data.get('includeTrack', True))
norad_to_name = {
25544: 'ISS',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3'
}
norad_to_name = {
25544: 'ISS',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3'
}
satellites = []
for sat in sat_input:
@@ -543,3 +570,75 @@ def fetch_celestrak(category):
except Exception as e:
logger.error(f"Error fetching CelesTrak data: {e}")
return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'})
# =============================================================================
# Tracked Satellites CRUD
# =============================================================================
@satellite_bp.route('/tracked', methods=['GET'])
def list_tracked_satellites():
"""Return all tracked satellites from the database."""
enabled_only = request.args.get('enabled', '').lower() == 'true'
sats = get_tracked_satellites(enabled_only=enabled_only)
return jsonify({'status': 'success', 'satellites': sats})
@satellite_bp.route('/tracked', methods=['POST'])
def add_tracked_satellites_endpoint():
"""Add one or more tracked satellites."""
global _tle_cache
data = request.json
if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
# Accept a single satellite dict or a list
sat_list = data if isinstance(data, list) else [data]
added = 0
for sat in sat_list:
norad_id = str(sat.get('norad_id', sat.get('norad', '')))
name = sat.get('name', '')
if not norad_id or not name:
continue
tle1 = sat.get('tle_line1', sat.get('tle1'))
tle2 = sat.get('tle_line2', sat.get('tle2'))
enabled = sat.get('enabled', True)
if add_tracked_satellite(norad_id, name, tle1, tle2, enabled):
added += 1
# Also inject into TLE cache if we have TLE data
if tle1 and tle2:
cache_key = name.replace(' ', '-').upper()
_tle_cache[cache_key] = (name, tle1, tle2)
return jsonify({
'status': 'success',
'added': added,
'satellites': get_tracked_satellites(),
})
@satellite_bp.route('/tracked/<norad_id>', methods=['PUT'])
def update_tracked_satellite_endpoint(norad_id):
"""Update the enabled state of a tracked satellite."""
data = request.json or {}
enabled = data.get('enabled')
if enabled is None:
return jsonify({'status': 'error', 'message': 'Missing enabled field'}), 400
ok = update_tracked_satellite(str(norad_id), bool(enabled))
if ok:
return jsonify({'status': 'success'})
return jsonify({'status': 'error', 'message': 'Satellite not found'}), 404
@satellite_bp.route('/tracked/<norad_id>', methods=['DELETE'])
def delete_tracked_satellite_endpoint(norad_id):
"""Remove a tracked satellite by NORAD ID."""
ok, msg = remove_tracked_satellite(str(norad_id))
if ok:
return jsonify({'status': 'success', 'message': msg})
status_code = 403 if 'builtin' in msg.lower() else 404
return jsonify({'status': 'error', 'message': msg}), status_code

View File

@@ -10621,10 +10621,7 @@
}
// Satellite management
let trackedSatellites = [
{ id: 'ISS', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true },
{ id: 'METEOR-M2', name: 'Meteor-M 2', norad: '40069', builtin: true, checked: true }
];
let trackedSatellites = [];
function renderSatelliteList() {
const list = document.getElementById('satTrackingList');
@@ -10643,14 +10640,27 @@
}
function toggleSatellite(idx) {
trackedSatellites[idx].checked = !trackedSatellites[idx].checked;
const sat = trackedSatellites[idx];
sat.checked = !sat.checked;
fetch(`/satellite/tracked/${sat.norad}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: sat.checked })
}).catch(() => {});
}
function removeSatellite(idx) {
if (!trackedSatellites[idx].builtin) {
trackedSatellites.splice(idx, 1);
renderSatelliteList();
}
const sat = trackedSatellites[idx];
if (sat.builtin) return;
fetch(`/satellite/tracked/${sat.norad}`, { method: 'DELETE' })
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
trackedSatellites.splice(idx, 1);
renderSatelliteList();
}
})
.catch(() => {});
}
function getSelectedSatellites() {
@@ -10686,7 +10696,7 @@
}
const lines = tleText.split('\\n').map(l => l.trim()).filter(l => l);
let added = 0;
const toAdd = [];
for (let i = 0; i < lines.length; i += 3) {
if (i + 2 < lines.length) {
@@ -10696,32 +10706,33 @@
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
const norad = line1.substring(2, 7).trim();
const id = name.replace(/[^a-zA-Z0-9]/g, '-').toUpperCase();
// Check if already exists
if (!trackedSatellites.find(s => s.norad === norad)) {
trackedSatellites.push({
id: id,
name: name,
norad: norad,
builtin: false,
checked: true,
tle: [name, line1, line2]
});
added++;
toAdd.push({ norad_id: norad, name: name, tle1: line1, tle2: line2, enabled: true });
}
}
}
}
if (added > 0) {
renderSatelliteList();
document.getElementById('tleInput').value = '';
closeSatModal();
showInfo(`Added ${added} satellite(s)`);
} else {
if (toAdd.length === 0) {
alert('No valid TLE data found. Format: Name, Line 1, Line 2 (3 lines per satellite)');
return;
}
fetch('/satellite/tracked', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(toAdd)
})
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
_loadSatellitesFromAPI();
document.getElementById('tleInput').value = '';
closeSatModal();
showInfo(`Added ${data.added} satellite(s)`);
}
})
.catch(() => alert('Failed to save satellites'));
}
function fetchCelestrak() {
@@ -10737,35 +10748,76 @@
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.satellites) {
let added = 0;
data.satellites.forEach(sat => {
const noradStr = String(sat.norad);
if (!trackedSatellites.find(s => s.norad === noradStr)) {
trackedSatellites.push({
id: sat.name.replace(/[^a-zA-Z0-9-]/g, '-'),
name: sat.name,
norad: noradStr,
builtin: false,
checked: false, // Don't auto-select
tle: [sat.name, sat.tle1, sat.tle2]
});
added++;
const toAdd = data.satellites
.filter(sat => !trackedSatellites.find(s => s.norad === String(sat.norad)))
.map(sat => ({
norad_id: String(sat.norad),
name: sat.name,
tle1: sat.tle1,
tle2: sat.tle2,
enabled: false
}));
if (toAdd.length === 0) {
status.innerHTML = `<span style="color: var(--accent-green);">All ${data.satellites.length} satellites already tracked</span>`;
return;
}
fetch('/satellite/tracked', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(toAdd)
})
.then(r => r.json())
.then(result => {
if (result.status === 'success') {
_loadSatellitesFromAPI();
status.innerHTML = `<span style="color: var(--accent-green);">Added ${result.added} satellites (${data.satellites.length} total in category)</span>`;
}
})
.catch(() => {
status.innerHTML = `<span style="color: var(--accent-red);">Failed to save satellites</span>`;
});
renderSatelliteList();
status.innerHTML = `<span style="color: var(--accent-green);">Added ${added} satellites (${data.satellites.length} total in category)</span>`;
} else {
status.innerHTML = `<span style="color: var(--accent-red);">Error: ${data.message || 'Failed to fetch'}</span>`;
}
})
.catch(err => {
.catch(() => {
status.innerHTML = `<span style="color: var(--accent-red);">Network error</span>`;
});
}
function _loadSatellitesFromAPI() {
fetch('/satellite/tracked')
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.satellites) {
trackedSatellites = data.satellites.map(sat => ({
id: sat.name.replace(/[^a-zA-Z0-9]/g, '-').toUpperCase(),
name: sat.name,
norad: sat.norad_id,
builtin: sat.builtin,
checked: sat.enabled,
tle: sat.tle_line1 ? [sat.name, sat.tle_line1, sat.tle_line2] : null
}));
renderSatelliteList();
}
})
.catch(() => {
// Fallback to hardcoded defaults if API fails
if (trackedSatellites.length === 0) {
trackedSatellites = [
{ id: 'ISS', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true },
{ id: 'METEOR-M2', name: 'Meteor-M 2', norad: '40069', builtin: true, checked: true }
];
renderSatelliteList();
}
});
}
// Initialize satellite list when satellite mode is loaded
function initSatelliteList() {
renderSatelliteList();
_loadSatellitesFromAPI();
}
// Utility function

View File

@@ -269,12 +269,42 @@
let currentLocationSource = 'local';
let agents = [];
const satellites = {
let satellites = {
25544: { name: 'ISS (ZARYA)', color: '#00ffff' },
40069: { name: 'METEOR-M2', color: '#9370DB' },
57166: { name: 'METEOR-M2-3', color: '#ff00ff' }
};
const satColors = ['#00ffff', '#9370DB', '#ff00ff', '#00ff00', '#ff6600', '#ffff00', '#ff69b4', '#7b68ee'];
function loadDashboardSatellites() {
fetch('/satellite/tracked?enabled=true')
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.satellites && data.satellites.length > 0) {
const newSats = {};
const select = document.getElementById('satSelect');
select.innerHTML = '';
data.satellites.forEach((sat, i) => {
const norad = parseInt(sat.norad_id);
newSats[norad] = {
name: sat.name,
color: satellites[norad]?.color || satColors[i % satColors.length]
};
const opt = document.createElement('option');
opt.value = norad;
opt.textContent = sat.name;
select.appendChild(opt);
});
satellites = newSats;
// Default to ISS if available
if (newSats[25544]) select.value = '25544';
selectedSatellite = parseInt(select.value);
}
})
.catch(() => {});
}
function onSatelliteChange() {
const select = document.getElementById('satSelect');
selectedSatellite = parseInt(select.value);
@@ -331,6 +361,7 @@
}
document.addEventListener('DOMContentLoaded', () => {
loadDashboardSatellites();
setupEmbeddedMode();
const usedShared = applySharedObserverLocation();
initGroundMap();

View File

@@ -531,6 +531,30 @@ def init_db() -> None:
ON push_payloads(agent_id, received_at)
''')
# Tracked satellites table for persistent satellite management
conn.execute('''
CREATE TABLE IF NOT EXISTS tracked_satellites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
norad_id TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
tle_line1 TEXT,
tle_line2 TEXT,
enabled BOOLEAN DEFAULT 1,
builtin BOOLEAN DEFAULT 0,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Seed builtin satellites if not already present
conn.execute('''
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
VALUES ('25544', 'ISS (ZARYA)', NULL, NULL, 1, 1)
''')
conn.execute('''
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1)
''')
logger.info("Database initialized successfully")
@@ -2170,3 +2194,111 @@ def cleanup_old_payloads(max_age_hours: int = 24) -> int:
''', (f'-{max_age_hours} hours',))
return cursor.rowcount
# =============================================================================
# Tracked Satellites Functions
# =============================================================================
def get_tracked_satellites(enabled_only: bool = False) -> list[dict]:
"""Return all tracked satellites, optionally filtered to enabled only."""
with get_db() as conn:
if enabled_only:
rows = conn.execute(
'SELECT norad_id, name, tle_line1, tle_line2, enabled, builtin, added_at '
'FROM tracked_satellites WHERE enabled = 1 ORDER BY builtin DESC, name'
).fetchall()
else:
rows = conn.execute(
'SELECT norad_id, name, tle_line1, tle_line2, enabled, builtin, added_at '
'FROM tracked_satellites ORDER BY builtin DESC, name'
).fetchall()
return [
{
'norad_id': r[0],
'name': r[1],
'tle_line1': r[2],
'tle_line2': r[3],
'enabled': bool(r[4]),
'builtin': bool(r[5]),
'added_at': r[6],
}
for r in rows
]
def add_tracked_satellite(
norad_id: str,
name: str,
tle_line1: str | None = None,
tle_line2: str | None = None,
enabled: bool = True,
builtin: bool = False,
) -> bool:
"""Insert a tracked satellite. Returns True if inserted, False if duplicate."""
with get_db() as conn:
try:
conn.execute(
'INSERT OR IGNORE INTO tracked_satellites '
'(norad_id, name, tle_line1, tle_line2, enabled, builtin) '
'VALUES (?, ?, ?, ?, ?, ?)',
(str(norad_id), name, tle_line1, tle_line2, int(enabled), int(builtin)),
)
return conn.total_changes > 0
except sqlite3.Error as e:
logger.error(f"Error adding tracked satellite {norad_id}: {e}")
return False
def bulk_add_tracked_satellites(satellites_list: list[dict]) -> int:
"""Insert many tracked satellites at once. Returns count of newly inserted."""
added = 0
with get_db() as conn:
for sat in satellites_list:
try:
cursor = conn.execute(
'INSERT OR IGNORE INTO tracked_satellites '
'(norad_id, name, tle_line1, tle_line2, enabled, builtin) '
'VALUES (?, ?, ?, ?, ?, ?)',
(
str(sat['norad_id']),
sat['name'],
sat.get('tle_line1'),
sat.get('tle_line2'),
int(sat.get('enabled', True)),
int(sat.get('builtin', False)),
),
)
if cursor.rowcount > 0:
added += 1
except (sqlite3.Error, KeyError) as e:
logger.warning(f"Error bulk-adding satellite: {e}")
return added
def update_tracked_satellite(norad_id: str, enabled: bool) -> bool:
"""Toggle enabled state for a tracked satellite."""
with get_db() as conn:
cursor = conn.execute(
'UPDATE tracked_satellites SET enabled = ? WHERE norad_id = ?',
(int(enabled), str(norad_id)),
)
return cursor.rowcount > 0
def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]:
"""Delete a tracked satellite by NORAD ID. Refuses to delete builtins."""
with get_db() as conn:
row = conn.execute(
'SELECT builtin FROM tracked_satellites WHERE norad_id = ?',
(str(norad_id),),
).fetchone()
if row is None:
return False, 'Satellite not found'
if row[0]:
return False, 'Cannot remove builtin satellite'
conn.execute(
'DELETE FROM tracked_satellites WHERE norad_id = ?',
(str(norad_id),),
)
return True, 'Removed'