From df8ac4ab31765a019c2ac988b452e6a0f7d94f8e Mon Sep 17 00:00:00 2001 From: Roby Date: Wed, 22 Apr 2026 22:02:25 +0200 Subject: [PATCH] Fix MQTT, memory persitence e UI --- agent/system_monitor.py | 165 +++-- app.py | 8 + templates/index.html | 51 +- templates/index.html.oldstyle | 1173 +++++++++++++++++++++++++++++++++ 4 files changed, 1312 insertions(+), 85 deletions(-) create mode 100644 templates/index.html.oldstyle diff --git a/agent/system_monitor.py b/agent/system_monitor.py index 3dffc13..db8a14e 100644 --- a/agent/system_monitor.py +++ b/agent/system_monitor.py @@ -15,7 +15,7 @@ import logging from logging.handlers import RotatingFileHandler # ========================================== -# 0. CONFIGURAZIONE LOGGING & HARDWARE +# 0. LOGGING & HARDWARE CONFIGURATION # ========================================== logging.basicConfig( handlers=[ @@ -33,48 +33,48 @@ try: GPIO_AVAILABLE = True except ImportError: GPIO_AVAILABLE = False - logger.warning("Libreria RPi.GPIO non trovata. Reset hardware disabilitato.") + logger.warning("RPi.GPIO library not found. Hardware reset disabled.") # ========================================== -# 1. CARICAMENTO CONFIGURAZIONE UNIFICATA +# 1. UNIFIED CONFIGURATION LOADING # ========================================== CONFIG_PATH = Path("/opt/node_config.json") def load_config(): try: if not CONFIG_PATH.exists(): - logger.error(f"ERRORE: File {CONFIG_PATH} non trovato!") + logger.error(f"ERROR: File {CONFIG_PATH} not found!") sys.exit(1) with open(CONFIG_PATH, 'r') as f: return json.load(f) except Exception as e: - logger.error(f"ERRORE CRITICO JSON: {e}") + logger.error(f"CRITICAL JSON ERROR: {e}") sys.exit(1) cfg = load_config() -# Identificativi e Topic +# Identifiers and Topics CLIENT_ID = cfg.get('client_id', 'iv3jdv').lower() BASE_TOPIC = cfg.get('mqtt', {}).get('base_topic', f"servizi/{CLIENT_ID}") TOPIC_CMD = f"{BASE_TOPIC}/cmnd" TOPIC_STAT = f"{BASE_TOPIC}/stat" -# Variabili di Stato Globali +# Global Status Variables boot_recovered = False -current_status = "ONLINE - Pronto" +current_status = "ONLINE" auto_healing_counter = {} # ========================================== -# 2. FUNZIONE NOTIFICA TELEGRAM +# 2. TELEGRAM NOTIFICATION FUNCTION # ========================================== def send_telegram_message(message): t_cfg = cfg.get('telegram', {}) if not t_cfg.get('enabled', False): return - ora_attuale = int(time.strftime("%H")) - if ora_attuale >= 23 or ora_attuale < 7: - logger.info(f"๐ŸŒ™ Notte fonda ({ora_attuale}:00): Notifica Telegram evitata.") + current_hour = int(time.strftime("%H")) + if current_hour >= 23 or current_hour < 7: + logger.info(f"๐ŸŒ™ Late night ({current_hour}:00): Telegram notification skipped.") return token = t_cfg.get('token') @@ -87,25 +87,35 @@ def send_telegram_message(message): payload = {"chat_id": chat_id, "text": f"[{CLIENT_ID.upper()}]\n{clean_msg}"} requests.post(url, json=payload, timeout=10) except Exception as e: - logger.error(f"Errore invio Telegram: {e}") + logger.error(f"Telegram sending error: {e}") # ========================================== -# 3. LOGICA CAMBIO PROFILO MULTIPLO +# 3. MULTIPLE PROFILE SWITCH LOGIC # ========================================== def get_actual_config_from_disk(): - return "ONLINE - Da memoria" + try: + path = "/opt/last_profile.txt" + if os.path.exists(path): + with open(path, "r") as f: + label = f.read().strip() + if label: + return f"ONLINE - {label}" + except Exception as e: + logger.error(f"Errore lettura memoria profilo: {e}") + + return "ONLINE" # Default se il file non esiste o รจ vuoto def switch_config(config_type): profile = cfg.get('profiles', {}).get(config_type) if not profile: - return f"ERRORE: Profilo {config_type} non trovato in JSON" + return f"ERROR: Profile {config_type} not found in JSON" - label = profile.get('label', f"Profilo {config_type}") + label = profile.get('label', f"Profile {config_type}") services = profile.get('services', []) if not services: - return f"ERRORE: Nessun servizio configurato per {config_type}" + return f"ERROR: No services configured for {config_type}" try: for s in services: @@ -113,28 +123,32 @@ def switch_config(config_type): for s in services: if not os.path.exists(s['source']): - return f"ERRORE: Manca il file sorgente {s['source']}" + return f"ERROR: Missing source file {s['source']}" shutil.copy(s['source'], s['target']) for s in services: subprocess.run(["sudo", "systemctl", "start", s['name']], check=False) - send_telegram_message(f"โœ… Switch multiplo completato: {label}") + # Save the current profile to disk to remember it on reboot + with open("/opt/last_profile.txt", "w") as f: + f.write(label) + + send_telegram_message(f"โœ… Multiple switch completed: {label}") return f"ONLINE - {label}" except Exception as e: - return f"ERRORE: {str(e)}" + return f"ERROR: {str(e)}" def force_online_if_needed(client): global boot_recovered, current_status if not boot_recovered: - logger.info("โš ๏ธ Recupero memoria saltato. Imposto stato da disco...") + logger.info("โš ๏ธ Memory recovery skipped. Setting status from disk...") current_status = get_actual_config_from_disk() client.publish(TOPIC_STAT, current_status, retain=True) boot_recovered = True # ========================================== -# 4. TELEMETRIA E AUTO-HEALING +# 4. TELEMETRY AND AUTO-HEALING # ========================================== def get_cpu_temperature(): temp = 0.0 @@ -157,8 +171,8 @@ def get_system_status(): "processes": {}, "timestamp": time.strftime("%H:%M:%S"), "profiles": { - "A": cfg.get('profiles', {}).get('A', {}).get('label', 'PROFILO A'), - "B": cfg.get('profiles', {}).get('B', {}).get('label', 'PROFILO B') + "A": cfg.get('profiles', {}).get('A', {}).get('label', 'PROFILE A'), + "B": cfg.get('profiles', {}).get('B', {}).get('label', 'PROFILE B') } } proc_path = Path(cfg['paths'].get('process_list', '')) @@ -169,7 +183,7 @@ def get_system_status(): for name in target_processes: name = name.strip().lower() if name: status["processes"][name] = "online" if name in running_names else "offline" - except Exception as e: logger.error(f"Errore controllo processi: {e}") + except Exception as e: logger.error(f"Process check error: {e}") return status def check_auto_healing(client, status): @@ -179,33 +193,33 @@ def check_auto_healing(client, status): attempts = auto_healing_counter.get(proc_name, 0) if attempts < 3: auto_healing_counter[proc_name] = attempts + 1 - msg = f"๐Ÿ›  Auto-healing: {proc_name} offline. Riavvio {attempts+1}/3..." + msg = f"๐Ÿ›  Auto-healing: {proc_name} offline. Restarting {attempts+1}/3..." client.publish(f"devices/{CLIENT_ID}/logs", msg) send_telegram_message(msg) - # --- INIZIO MODIFICA: RESET HARDWARE SPECIFICO PER MMDVMHOST --- + # --- START MODIFICATION: SPECIFIC HARDWARE RESET FOR MMDVMHOST --- if proc_name.lower() == "mmdvmhost" and GPIO_AVAILABLE: - logger.info("Esecuzione RESET HAT automatico pre-riavvio MMDVMHost...") + logger.info("Executing automatic HAT RESET before restarting MMDVMHost...") try: - RESET_PIN = 21 # Assicurati che il PIN sia quello corretto per i tuoi nodi + RESET_PIN = 21 # Ensure the PIN is correct for your nodes GPIO.setwarnings(False) GPIO.setmode(GPIO.BCM) GPIO.setup(RESET_PIN, GPIO.OUT) - # Impulso LOW per resettare + # LOW pulse to reset GPIO.output(RESET_PIN, GPIO.LOW) time.sleep(0.5) GPIO.output(RESET_PIN, GPIO.HIGH) GPIO.cleanup(RESET_PIN) - # Diamo tempo al microcontrollore di riavviarsi + # Give the microcontroller time to restart time.sleep(1.5) - client.publish(f"devices/{CLIENT_ID}/logs", "๐Ÿ”Œ Impulso GPIO (Reset MMDVM) inviato!") + client.publish(f"devices/{CLIENT_ID}/logs", "๐Ÿ”Œ GPIO Pulse (MMDVM Reset) sent!") except Exception as e: - logger.error(f"Errore GPIO in auto-healing: {e}") - # --- FINE MODIFICA --- + logger.error(f"GPIO error in auto-healing: {e}") + # --- END MODIFICATION --- subprocess.run(["sudo", "systemctl", "restart", proc_name]) elif attempts == 3: - msg = f"๐Ÿšจ CRITICO: {proc_name} fallito!" + msg = f"๐Ÿšจ CRITICAL: {proc_name} failed!" client.publish(f"devices/{CLIENT_ID}/logs", msg) send_telegram_message(msg) auto_healing_counter[proc_name] = 4 @@ -221,9 +235,9 @@ def publish_all(client): if file_list_path.exists(): try: files = file_list_path.read_text(encoding="utf-8").splitlines() - nomi_estrattti = [Path(f.strip()).stem for f in files if f.strip()] - status["config_files"] = nomi_estrattti - status["files"] = nomi_estrattti + extracted_names = [Path(f.strip()).stem for f in files if f.strip()] + status["config_files"] = extracted_names + status["files"] = extracted_names except: pass client.publish(f"devices/{CLIENT_ID}/services", json.dumps(status), qos=1) @@ -246,7 +260,7 @@ def publish_all_ini_files(client): with open(file_list_path, 'r') as f: files_to_parse = [line.strip() for line in f if line.strip()] except Exception as e: - logger.error(f"Errore lettura {file_list_path}: {e}") + logger.error(f"Error reading {file_list_path}: {e}") return for file_path in files_to_parse: @@ -275,7 +289,7 @@ def publish_all_ini_files(client): topic = f"data/{CLIENT_ID}/{base_name}/{section}" client.publish(topic, json.dumps(payload), retain=True) except Exception as e: - logger.error(f"Errore parsing INI per {file_path}: {e}") + logger.error(f"Error parsing INI for {file_path}: {e}") def write_config_from_json(slug, json_payload): file_list_path = Path(cfg['paths'].get('file_list', '')) @@ -285,21 +299,21 @@ def write_config_from_json(slug, json_payload): for f in files: p = Path(f.strip()) if p.stem.lower() == slug.lower(): - nuovi_dati = json.loads(json_payload) + new_data = json.loads(json_payload) shutil.copy(p, str(p) + ".bak") - with open(p, 'w', encoding="utf-8") as file: file.write(nuovi_dati.get("raw_text", "")) + with open(p, 'w', encoding="utf-8") as file: file.write(new_data.get("raw_text", "")) os.system(f"sudo systemctl restart {slug}") - send_telegram_message(f"๐Ÿ“ Config {slug.upper()} aggiornata via Web.") - logger.info(f"Configurazione {slug} aggiornata con successo.") + send_telegram_message(f"๐Ÿ“ Config {slug.upper()} updated via Web.") + logger.info(f"Configuration {slug} updated successfully.") break - except Exception as e: logger.error(f"Errore scrittura config: {e}") + except Exception as e: logger.error(f"Config writing error: {e}") # ========================================== -# 5. CALLBACK MQTT +# 5. MQTT CALLBACKS # ========================================== def on_connect(client, userdata, flags, reason_code, properties=None): if reason_code == 0: - logger.info(f"โœ… Connesso al broker MQTT: {CLIENT_ID.upper()}") + logger.info(f"โœ… Connected to MQTT broker: {CLIENT_ID.upper()}") client.subscribe([(TOPIC_CMD, 0), (TOPIC_STAT, 0)]) client.subscribe([ ("devices/control/request", 0), @@ -310,12 +324,11 @@ def on_connect(client, userdata, flags, reason_code, properties=None): publish_all(client) publish_all_ini_files(client) else: - logger.error(f"โŒ Errore connessione MQTT. Codice: {reason_code}") + logger.error(f"โŒ MQTT connection error. Code: {reason_code}") def on_disconnect(client, userdata, disconnect_flags, reason_code, properties=None): - logger.warning(f"โš ๏ธ Disconnessione dal broker MQTT! Codice: {reason_code}") - logger.error("Forzo il riavvio del processo per ripristinare la rete in modo pulito...") - os._exit(1) # Uccide lo script immediatamente (Systemd lo farร  risorgere) + logger.warning(f"โš ๏ธ Disconnected from MQTT broker! Code: {reason_code}") + logger.info("Waiting for network return. Paho-MQTT will attempt automatic reconnection...") def on_message(client, userdata, msg): global boot_recovered, current_status, cfg @@ -323,7 +336,7 @@ def on_message(client, userdata, msg): topic = msg.topic if topic == TOPIC_STAT and not boot_recovered: - if not any(x in payload.upper() for x in ["OFFLINE", "ERRORE", "RIAVVIO"]): + if not any(x in payload.upper() for x in ["OFFLINE", "ERROR", "REBOOT"]): current_status = payload boot_recovered = True client.publish(TOPIC_STAT, current_status, retain=True) @@ -336,8 +349,8 @@ def on_message(client, userdata, msg): boot_recovered = True publish_all(client) elif cmd == "REBOOT": - client.publish(TOPIC_STAT, f"OFFLINE - Riavvio {CLIENT_ID.upper()}...", retain=False) - logger.info("Comando REBOOT ricevuto. Riavvio sistema...") + client.publish(TOPIC_STAT, f"OFFLINE - Rebooting {CLIENT_ID.upper()}...", retain=False) + logger.info("REBOOT command received. Rebooting system...") time.sleep(1) subprocess.run(["sudo", "reboot"], check=True) elif cmd == 'RESET_HAT': @@ -351,28 +364,31 @@ def on_message(client, userdata, msg): time.sleep(0.5) GPIO.output(RESET_PIN, GPIO.HIGH) GPIO.cleanup(RESET_PIN) - logger.info(f"Impulso di RESET inviato al GPIO {RESET_PIN}") + logger.info(f"RESET pulse sent to GPIO {RESET_PIN}") time.sleep(1.5) - logger.info("Riavvio di MMDVMHost in corso...") + logger.info("Restarting MMDVMHost...") subprocess.run(["sudo", "systemctl", "restart", "mmdvmhost"], check=False) client.publish(f"fleet/{CLIENT_ID}/status", "HAT RESET + MMDVM RESTART OK") client.publish(f"devices/{CLIENT_ID}/logs", "๐Ÿ”Œ HAT Reset + MMDVMHost Restarted") except Exception as e: - logger.error(f"Errore durante il reset GPIO/MMDVMHost: {e}") - client.publish(f"fleet/{CLIENT_ID}/status", f"ERRORE RESET: {e}") + logger.error(f"Error during GPIO/MMDVMHost reset: {e}") + client.publish(f"fleet/{CLIENT_ID}/status", f"RESET ERROR: {e}") elif cmd in ["TG:OFF", "TG:ON"]: - nuovo_stato = (cmd == "TG:ON") - cfg['telegram']['enabled'] = nuovo_stato + new_state = (cmd == "TG:ON") + cfg['telegram']['enabled'] = new_state try: with open(CONFIG_PATH, 'w') as f: json.dump(cfg, f, indent=4) - client.publish(f"devices/{CLIENT_ID}/logs", f"{'๐Ÿ””' if nuovo_stato else '๐Ÿ”‡'} Notifiche {'ON' if nuovo_stato else 'OFF'}") - if nuovo_stato: send_telegram_message("Notifiche riattivate!") - except Exception as e: logger.error(f"Errore salvataggio stato Telegram: {e}") + client.publish(f"devices/{CLIENT_ID}/logs", f"{'๐Ÿ””' if new_state else '๐Ÿ”‡'} Notifications {'ON' if new_state else 'OFF'}") + if new_state: send_telegram_message("Notifications reactivated!") + except Exception as e: logger.error(f"Error saving Telegram status: {e}") elif topic == "devices/control/request" and payload.lower() in ["status", "update"]: + logger.info("๐Ÿ“ฅ Received global update command (REQ CONFIG)") publish_all(client) publish_all_ini_files(client) + # Force the visual update of the card on the dashboard! + client.publish(TOPIC_STAT, current_status, retain=True) elif topic == f"devices/{CLIENT_ID}/control": if ":" in payload: @@ -381,11 +397,11 @@ def on_message(client, userdata, msg): try: subprocess.run(["sudo", "systemctl", action.lower(), service.lower()], check=True) client.publish(f"devices/{CLIENT_ID}/logs", f"โœ… {action.upper()}: {service}") - logger.info(f"Comando servizio eseguito: {action.upper()} {service}") + logger.info(f"Service command executed: {action.upper()} {service}") publish_all(client) except Exception as e: client.publish(f"devices/{CLIENT_ID}/logs", f"โŒ ERROR: {str(e)}") - logger.error(f"Errore esecuzione comando servizio: {e}") + logger.error(f"Error executing service command: {e}") elif topic.startswith(f"devices/{CLIENT_ID}/config_set/"): slug = topic.split("/")[-1] @@ -402,6 +418,8 @@ def auto_publish_task(client): time.sleep(cfg['settings'].get('update_interval', 30)) def start_service(): + global current_status + current_status = get_actual_config_from_disk() client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID.upper()) client.will_set(TOPIC_STAT, payload=f"OFFLINE - {CLIENT_ID.upper()}", qos=1, retain=False) client.username_pw_set(cfg['mqtt']['user'], cfg['mqtt']['password']) @@ -409,19 +427,24 @@ def start_service(): client.on_disconnect = on_disconnect client.on_message = on_message + # 1. Start the telemetry "engine" ONLY ONCE + threading.Thread(target=auto_publish_task, args=(client,), daemon=True).start() + while True: try: - logger.info("Tentativo di connessione al broker MQTT...") + logger.info("Attempting connection to MQTT broker...") client.connect(cfg['mqtt']['broker'], cfg['mqtt']['port'], 60) - client.loop_start() - threading.Thread(target=auto_publish_task, args=(client,), daemon=True).start() - # Mantiene il processo vivo + # 2. Start network manager in background (handles reconnections automatically!) + client.loop_start() + + # 3. Pause main thread indefinitely while True: time.sleep(1) except Exception as e: - logger.error(f"Impossibile connettersi o connessione persa ({e}). Riprovo in 10 secondi...") + # This triggers ONLY if the broker is down when the node boots + logger.error(f"Broker unreachable at boot ({e}). Retrying in 10 seconds...") time.sleep(10) if __name__ == "__main__": diff --git a/app.py b/app.py index 336e87c..f2f51b2 100644 --- a/app.py +++ b/app.py @@ -459,6 +459,14 @@ def update_nodes(): mqtt_backend.publish("devices/control/request", "update") return jsonify({"success": True}) + # Mandiamo il comando "update" direttamente nel topic privato di ciascun nodo + for client in clients_list: + cid = client['id'].lower() + mqtt_backend.publish(f"devices/{cid}/control", "update", qos=1) + + logger.info("๐Ÿ“ข Inviato comando REQ CONFIG diretto a tutti i nodi della flotta.") + return jsonify({"success": True}) + @app.route('/api/users', methods=['GET']) def get_users(): if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403 diff --git a/templates/index.html b/templates/index.html index b05ef40..15a5007 100644 --- a/templates/index.html +++ b/templates/index.html @@ -78,13 +78,23 @@ th { padding: 12px 15px; font-weight: 600; text-transform: uppercase; font-size: 0.75rem; color: var(--text-muted); } td { padding: 10px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-main); } - /* Animazioni Flat per i transiti */ + /* Animazioni Uniformate per i transiti */ + @keyframes pulse-border { + 0% { border-color: var(--border-color); } + 50% { border-color: var(--pulse-color, var(--primary)); } + 100% { border-color: var(--border-color); } + } + .tx-active-unified { + animation: pulse-border 1.5s infinite !important; + color: var(--text-main) !important; + border-left: 4px solid var(--pulse-color, var(--primary)) !important; + background: #010409 !important; + } + + /* La vecchia classe blink la teniamo solo per gli allarmi rossi dei demoni KO */ @keyframes flat-blink { 0% { border-color: var(--border-color); } 50% { border-color: var(--danger); } 100% { border-color: var(--border-color); } } .blink { animation: flat-blink 1.5s infinite; color: var(--danger) !important; } - @keyframes flat-tx { 0% { border-left-color: var(--border-color); background: #010409; } 50% { border-left-color: var(--primary); background: rgba(47, 129, 247, 0.1); } 100% { border-left-color: var(--border-color); background: #010409; } } - .tx-active { animation: flat-tx 1.5s infinite !important; color: var(--text-main) !important; border-color: var(--border-color) !important; } - /* Finestre Modali (Popup) */ .modal-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(1, 4, 9, 0.85); z-index:1000; align-items:center; justify-content:center; } .modal-content { background:var(--card-bg); border:1px solid var(--border-color); padding:25px; border-radius:6px; max-height: 90vh; overflow-y: auto; box-shadow: 0 10px 30px rgba(0,0,0,0.8); } @@ -658,18 +668,31 @@ if (altDiv) { altDiv.style.display = "block"; altDiv.innerText = telemetryObj.alt; let altText = telemetryObj.alt.toUpperCase(); - if (altText.includes("NXDN")) activeModeColor = "#10b981"; else if (altText.includes("YSF")) activeModeColor = "#8b5cf6"; else if (altText.includes("D-STAR")) activeModeColor = "#06b6d4"; else if (altText.includes("P25")) activeModeColor = "#f59e0b"; + + // Assegna il colore in base al modo + if (altText.includes("NXDN")) activeModeColor = "#10b981"; + else if (altText.includes("YSF")) activeModeColor = "#8b5cf6"; + else if (altText.includes("D-STAR")) activeModeColor = "#06b6d4"; + else if (altText.includes("P25")) activeModeColor = "#f59e0b"; + isTx = altText.includes("๐ŸŸข") || altText.includes("๐ŸŸฃ") || altText.includes("๐Ÿ”ต") || altText.includes("๐ŸŸ "); - altDiv.style.setProperty('color', activeModeColor, 'important'); - altDiv.style.setProperty('border-left', `4px solid ${activeModeColor}`, 'important'); - if (isTx) { altDiv.classList.add('blink'); } else { altDiv.classList.remove('blink'); } + + // Passiamo il colore al CSS per l'animazione + altDiv.style.setProperty('--pulse-color', activeModeColor); + if (isTx) { + altDiv.classList.add('tx-active-unified'); + } else { + altDiv.classList.remove('tx-active-unified'); + altDiv.style.setProperty('color', 'var(--text-muted)', 'important'); + altDiv.style.setProperty('border-left', `4px solid var(--border-color)`, 'important'); + } } } else { if (altDiv) altDiv.style.display = "none"; if (tsContainer) tsContainer.style.display = "flex"; let netObj = data.networks && data.networks[c.id.toLowerCase()] ? data.networks[c.id.toLowerCase()] : {ts1: "", ts2: ""}; - activeModeColor = "var(--primary)"; + activeModeColor = "var(--primary)"; // Default Blu per il DMR if (ts1Div && ts2Div) { [ts1Div, ts2Div].forEach((div, idx) => { @@ -679,15 +702,15 @@ const fullLabel = netName ? `${baseLabel} [${netName}]` : baseLabel; div.innerText = `${fullLabel}: ${val}`; + div.style.setProperty('--pulse-color', activeModeColor); if (val.includes("๐ŸŽ™๏ธ")) { isTx = true; - div.classList.add('tx-active'); + div.classList.add('tx-active-unified'); } else { - div.classList.remove('tx-active'); - div.style.setProperty('color', 'var(--text-main)', 'important'); - div.style.setProperty('border-left-color', 'var(--primary)', 'important'); - div.style.setProperty('background', 'rgba(59, 130, 246, 0.1)', 'important'); + div.classList.remove('tx-active-unified'); + div.style.setProperty('color', 'var(--text-muted)', 'important'); + div.style.setProperty('border-left', '4px solid var(--border-color)', 'important'); } }); } diff --git a/templates/index.html.oldstyle b/templates/index.html.oldstyle new file mode 100644 index 0000000..c3ce33e --- /dev/null +++ b/templates/index.html.oldstyle @@ -0,0 +1,1173 @@ + + + + + + + Fleet Control Console + + + + + + + + + + + +
+
+
FLEET CONTROL CONSOLE
+
+ + + +
+
+
+
+ +
+ +
+

+ ๐Ÿ“ก Lastheard +

+
+ + + + + + + + + + + + + + +
TimeRepeaterModeSlotCallsignTarget / TGDurationBER
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +