16 Commits

Author SHA1 Message Date
iv3jdv bb697750b7 Fix MQTT, memory persitence e UI 2026-04-22 22:03:10 +02:00
iv3jdv df8ac4ab31 Fix MQTT, memory persitence e UI 2026-04-22 22:02:25 +02:00
iv3jdv 1780a4a737 Change dashboard theme 2026-04-22 19:34:14 +02:00
iv3jdv 728233998b Change dashboard theme 2026-04-22 19:32:52 +02:00
iv3jdv 080a6af776 update 2026-04-22 14:34:26 +02:00
iv3jdv 2a8815a6bd update 2026-04-22 14:29:37 +02:00
iv3jdv 71cbc78cb7 update 2026-04-22 11:18:30 +02:00
iv3jdv 6c6073b966 Fix: default user config 2026-04-22 11:04:59 +02:00
iv3jdv b587669f0c update README 2026-04-22 11:04:19 +02:00
iv3jdv 69d7e885bd makeup 2026-04-22 02:58:07 +02:00
iv3jdv 66d22411a4 update Push Notification 2026-04-22 02:08:51 +02:00
iv3jdv 324b066f51 Add Push Notification 2026-04-22 01:43:09 +02:00
iv3jdv 541e6f1ce3 Fix: allow_unsafe_werkzeug=true 2026-04-21 22:49:16 +02:00
iv3jdv 4b58bebe2a Fix: allow_unsafe_werkzeug=true 2026-04-21 22:48:10 +02:00
iv3jdv 8959c8f1cf Merge pull request #1 from picchiosat/websocket-upgrade
Aggiunti WebSockets al frontend
2026-04-21 22:35:29 +02:00
iv3jdv 7da6471ff9 Enanced auto-healing 2026-04-21 14:32:56 +02:00
12 changed files with 721 additions and 268 deletions
+2
View File
@@ -6,6 +6,8 @@ __pycache__/
*.db
monitor.db
monitor.db-journal
monitor.db-shm
monitor.db-wal
# Ignora i file di configurazione reali (pubblicherai solo i .example.json)
config.json
+46 -4
View File
@@ -19,6 +19,7 @@ The ecosystem consists of three main parts:
### ✨ Features
* **Zero-Latency Real-Time UI:** Powered by WebSockets (Socket.IO), the dashboard updates instantly upon radio traffic or telemetry changes, completely eliminating heavy HTTP polling overhead.
* **Web Push Notifications:** Get instant alerts directly on your desktop or mobile device when a node goes offline, comes back online, or a critical service fails (even when the app is closed or in the background).
* **Centralized Telemetry:** Real-time CPU, RAM, Temperature, and Disk usage for all nodes.
* **Service Management:** Start, Stop, or Restart system daemons (MMDVMHost, DMRGateway, etc.) remotely.
* **Smart Auto-Healing:** The agent automatically detects crashed services and attempts to revive them before raising critical alerts (includes Telegram notifications).
@@ -31,11 +32,20 @@ The ecosystem consists of three main parts:
### 🚀 Installation & Setup
#### 1. Server Setup (Central Hub)
* Install dependencies: `pip install flask flask-socketio paho-mqtt psutil werkzeug`
* Configure `config.json` (use `config.example.json` as template) with your MQTT credentials.
* Install dependencies using the provided file: `pip install -r requirements.txt`
* Configure `config.json` (use `config.example.json` as template) with your MQTT and WebPush VAPID credentials.
* Define your repeaters in `clients.json`.
* Run: `python3 app.py`
#### 🔑 Generating VAPID Keys (Push Notifications)
To enable Web Push Notifications, you must generate a unique VAPID key pair for your server.
1. Go to a free online generator like [vapidkeys.com](https://vapidkeys.com/).
2. Generate the keys.
3. Open your `config.json` and paste them in the `webpush` section:
* `vapid_public_key`: Your newly generated Public Key
* `vapid_private_key`: Your newly generated Private Key
* `vapid_claim_email`: A contact email formatted as `"mailto:your@email.com"`
#### 2. Agent Setup (Remote Nodes)
The `system_monitor.py` must be installed on every node you wish to monitor.
* *(Optional)* Install `RPi.GPIO` (`pip install RPi.GPIO` or `apt install python3-rpi.gpio`) if you plan to use the physical Hardware Reset feature.
@@ -43,6 +53,17 @@ The `system_monitor.py` must be installed on every node you wish to monitor.
* Edit `node_config.json` to set the `client_id` (must match the ID in `clients.json`) and MQTT credentials.
* (Recommended) Set up a systemd service to run the agent automatically at boot.
#### ⚙️ Running as a System Service (systemd)
To keep the system running continuously and start automatically at boot, use the provided `.service` files:
1. Copy the appropriate file to the systemd directory:
* For Server: `sudo cp systemd/fleet-console.service /etc/systemd/system/`
* For Nodes: `sudo cp systemd/fleet-agent.service /etc/systemd/system/`
2. Reload the systemd daemon: `sudo systemctl daemon-reload`
3. Enable it to start on boot: `sudo systemctl enable fleet-console` (or `fleet-agent`)
4. Start the service: `sudo systemctl start fleet-console`
5. Check the status: `sudo systemctl status fleet-console`
---
<a name="italiano"></a>
@@ -60,6 +81,7 @@ L'ecosistema si compone di tre parti principali:
### ✨ Funzionalità
* **Interfaccia Real-Time a Latenza Zero:** Grazie all'integrazione di WebSockets (Socket.IO), la dashboard scatta all'istante al passaggio di traffico radio o ai cambi di telemetria, eliminando totalmente il carico del polling HTTP continuo.
* **Notifiche Push Web:** Ricevi avvisi immediati su desktop o smartphone quando un nodo va offline, torna operativo o un servizio critico si blocca (anche quando l'app è chiusa o in background).
* **Telemetria Centralizzata:** Stato in tempo reale di CPU, RAM, Temperatura e Disco di tutti i nodi.
* **Gestione Servizi:** Avvio, arresto o riavvio dei demoni di sistema (MMDVMHost, DMRGateway, ecc.) da remoto.
* **Auto-Healing Intelligente:** L'agente rileva automaticamente i servizi andati in blocco e tenta di rianimarli prima di inviare allarmi critici (include notifiche Telegram).
@@ -72,11 +94,20 @@ L'ecosistema si compone di tre parti principali:
### 🚀 Installazione e Configurazione
#### 1. Setup del Server (Hub Centrale)
* Installa le dipendenze: `pip install flask flask-socketio paho-mqtt psutil werkzeug`
* Configura `config.json` (usa `config.example.json` come base) con le credenziali MQTT.
* Installa le dipendenze usando il file fornito: `pip install -r requirements.txt`
* Configura `config.json` (usa `config.example.json` come base) con le credenziali MQTT e le chiavi VAPID per le notifiche Push.
* Definisci i tuoi ripetitori nel file `clients.json`.
* Avvia: `python3 app.py`
#### 🔑 Generare le chiavi VAPID (Notifiche Push)
Per abilitare le Notifiche Push, devi generare una coppia di chiavi VAPID univoca per il tuo server.
1. Vai su un generatore online gratuito come [vapidkeys.com](https://vapidkeys.com/).
2. Genera la coppia di chiavi.
3. Apri il tuo file `config.json` e incollale nella sezione `webpush`:
* `vapid_public_key`: La tua Chiave Pubblica appena generata
* `vapid_private_key`: La tua Chiave Privata appena generata
* `vapid_claim_email`: Un indirizzo di contatto formattato come `"mailto:tua@email.com"`
#### 2. Setup dell'Agente (Nodi Remoti)
Il file `system_monitor.py` va installato su ogni nodo che vuoi monitorare.
* *(Opzionale)* Installa `RPi.GPIO` (`pip install RPi.GPIO` o `apt install python3-rpi.gpio`) se intendi utilizzare la funzione di Reset Hardware fisico della scheda.
@@ -84,5 +115,16 @@ Il file `system_monitor.py` va installato su ogni nodo che vuoi monitorare.
* Modifica `node_config.json` impostando il `client_id` (deve corrispondere all'ID in `clients.json`) e le credenziali MQTT.
* (Consigliato) Crea un servizio systemd per avviare l'agente automaticamente al boot.
#### ⚙️ Esecuzione come Servizio di Sistema (systemd)
Per mantenere il sistema sempre attivo e avviarlo in automatico all'accensione, usa i file `.service` forniti:
1. Copia il file appropriato nella cartella di systemd:
* Per il Server: `sudo cp systemd/fleet-console.service /etc/systemd/system/`
* Per i Nodi: `sudo cp systemd/fleet-agent.service /etc/systemd/system/`
2. Ricarica i demoni di sistema: `sudo systemctl daemon-reload`
3. Abilita l'avvio automatico: `sudo systemctl enable fleet-console` (oppure `fleet-agent`)
4. Avvia il servizio: `sudo systemctl start fleet-console`
5. Controlla lo stato: `sudo systemctl status fleet-console`
---
*Created by IV3JDV @ ARIFVG - 2026*
+107 -63
View File
@@ -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,12 +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)
# --- START MODIFICATION: SPECIFIC HARDWARE RESET FOR MMDVMHOST ---
if proc_name.lower() == "mmdvmhost" and GPIO_AVAILABLE:
logger.info("Executing automatic HAT RESET before restarting MMDVMHost...")
try:
RESET_PIN = 21 # Ensure the PIN is correct for your nodes
GPIO.setwarnings(False)
GPIO.setmode(GPIO.BCM)
GPIO.setup(RESET_PIN, GPIO.OUT)
# LOW pulse to reset
GPIO.output(RESET_PIN, GPIO.LOW)
time.sleep(0.5)
GPIO.output(RESET_PIN, GPIO.HIGH)
GPIO.cleanup(RESET_PIN)
# Give the microcontroller time to restart
time.sleep(1.5)
client.publish(f"devices/{CLIENT_ID}/logs", "🔌 GPIO Pulse (MMDVM Reset) sent!")
except Exception as e:
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
@@ -200,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)
@@ -225,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:
@@ -254,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', ''))
@@ -264,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),
@@ -289,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
@@ -302,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)
@@ -315,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':
@@ -330,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:
@@ -360,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]
@@ -381,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'])
@@ -388,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__":
+153 -52
View File
@@ -8,10 +8,11 @@ import urllib.request
import threading
import time
import logging
from pywebpush import webpush, WebPushException
from logging.handlers import RotatingFileHandler
from flask_socketio import SocketIO, emit
# --- CONFIGURAZIONE LOGGING ---
# --- LOGGING CONFIGURATION ---
logging.basicConfig(
handlers=[
RotatingFileHandler('/opt/web-control/fleet_console.log', maxBytes=10000000, backupCount=3),
@@ -22,10 +23,10 @@ logging.basicConfig(
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger("FleetHub")
# Silenzia lo spam delle richieste HTTP (GET /api/states 200 OK)
# Silence HTTP request spam (GET /api/states 200 OK)
logging.getLogger('werkzeug').setLevel(logging.ERROR)
# --- PERCORSI ---
# --- PATHS ---
DB_PATH = '/opt/web-control/monitor.db'
CACHE_FILE = '/opt/web-control/telemetry_cache.json'
CONFIG_PATH = '/opt/web-control/config.json'
@@ -37,7 +38,7 @@ def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('PRAGMA journal_mode=WAL;') # <-- MAGIA: Abilita letture/scritture simultanee!
c.execute('PRAGMA journal_mode=WAL;') # <-- MAGIC: Enable simultaneous read/write!
c.execute('''CREATE TABLE IF NOT EXISTS radio_logs
(id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME, client_id TEXT,
@@ -50,23 +51,39 @@ def init_db():
(id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, password_hash TEXT,
role TEXT, allowed_nodes TEXT)''')
c.execute('''CREATE TABLE IF NOT EXISTS push_subscriptions
(id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, subscription TEXT UNIQUE)''')
c.execute('''CREATE TABLE IF NOT EXISTS audit_logs
(id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME, username TEXT,
client_id TEXT, command TEXT)''')
c.execute("SELECT COUNT(*) FROM users")
if c.fetchone()[0] == 0:
h = generate_password_hash('admin123')
c.execute("INSERT INTO users (username, password_hash, role, allowed_nodes) VALUES (?,?,?,?)",
('admin', h, 'admin', 'all'))
logger.info(">>> UTENTE DI DEFAULT CREATO - User: admin | Pass: admin123 <<<")
# Default value if missing config file
def_user = "admin"
def_pass = "admin123"
# Try read config.json
try:
with open(CONFIG_PATH, 'r') as f:
cfg = json.load(f)
def_user = cfg.get("web_admin", {}).get("default_user", "admin")
def_pass = cfg.get("web_admin", {}).get("default_pass", "admin123")
except Exception:
pass
h = generate_password_hash(def_pass)
c.execute("INSERT INTO users (username, password_hash, role, allowed_nodes) VALUES (?,?,?,?)",
(def_user, h, 'admin', 'all'))
logger.info(f">>> DEFAULT USER CREATED - User: {def_user} | Pass: {def_pass} <<<")
conn.commit()
conn.close()
init_db()
# --- CARICAMENTO DATABASE ID ---
# --- ID DATABASE LOADING ---
user_db = {}
nxdn_db = {}
@@ -117,6 +134,7 @@ app.secret_key = 'ari_fvg_secret_ultra_secure'
client_states = {}
device_configs = {}
client_telemetry = {}
last_notified_errors = {}
device_health = {}
last_seen_reflector = {}
network_mapping = {}
@@ -129,10 +147,10 @@ if os.path.exists(CACHE_FILE):
active_calls = {}
with open(CONFIG_PATH) as f: config = json.load(f)
# --- CALLBACKS MQTT ---
# --- MQTT CALLBACKS ---
def on_connect(client, userdata, flags, reason_code, properties=None):
if reason_code == 0:
logger.info("Connesso al Broker MQTT con successo! Sottoscrizione ai topic in corso...")
logger.info("Successfully connected to MQTT Broker! Subscribing to topics...")
client.subscribe([
("servizi/+/stat", 0),
("dmr-gateway/+/json", 0),
@@ -146,10 +164,10 @@ def on_connect(client, userdata, flags, reason_code, properties=None):
("data/#", 0)
])
else:
logger.error(f"Errore di connessione MQTT. Codice motivo: {reason_code}")
logger.error(f"MQTT Connection Error. Reason code: {reason_code}")
def on_disconnect(client, userdata, disconnect_flags, reason_code, properties=None):
logger.warning(f"⚠️ Disconnessione MQTT rilevata! Codice motivo: {reason_code}. Tentativo di riconnessione automatico in corso...")
logger.warning(f"⚠️ MQTT Disconnection detected! Reason code: {reason_code}. Attempting automatic reconnection...")
def on_message(client, userdata, msg):
try:
@@ -159,7 +177,7 @@ def on_message(client, userdata, msg):
if len(parts) < 2: return
cid = parts[1].lower()
# --- CATTURA CONFIGURAZIONI COMPLETE ---
# --- CAPTURE FULL CONFIGURATIONS ---
if parts[0] == 'data' and len(parts) >= 4 and parts[3] == 'full_config':
cid_conf = parts[1].lower()
svc_name = parts[2].lower()
@@ -167,19 +185,32 @@ def on_message(client, userdata, msg):
device_configs[cid_conf] = {}
try:
device_configs[cid_conf][svc_name] = json.loads(payload)
logger.debug(f"Configurazione salvata per {cid_conf} -> {svc_name}")
logger.debug(f"Configuration saved for {cid_conf} -> {svc_name}")
except Exception as e:
logger.error(f"Errore parsing config JSON: {e}")
logger.error(f"Error parsing config JSON: {e}")
# --- NODE AND SERVICE STATE MANAGEMENT ---
elif parts[0] == 'servizi':
client_states[cid] = payload
socketio.emit('dati_aggiornati') # <--- WEBSOCKET
# --- PUSH TRIGGER: NODE STATE ---
if payload.upper() == 'OFFLINE':
if last_notified_errors.get(f"{cid}_NODE") != 'OFFLINE':
broadcast_push_notification(f"💀 NODE OFFLINE: {cid.upper()}", "Connection lost with broker.")
last_notified_errors[f"{cid}_NODE"] = 'OFFLINE'
elif payload.upper() == 'ONLINE':
if last_notified_errors.get(f"{cid}_NODE") == 'OFFLINE':
broadcast_push_notification(f"🌤️ NODE ONLINE: {cid.upper()}", "Node is back online.")
del last_notified_errors[f"{cid}_NODE"]
if payload.upper() not in ['OFF', 'OFFLINE', '']:
tel = client_telemetry.get(cid, {})
if isinstance(tel, dict) and '🔄' in str(tel.get('ts1', '')):
client_telemetry[cid] = {"ts1": "In attesa...", "ts2": "In attesa...", "alt": ""}
client_telemetry[cid] = {"ts1": "Waiting...", "ts2": "Waiting...", "alt": ""}
save_cache(client_telemetry)
# --- DEVICE HEALTH MANAGEMENT ---
elif parts[0] == 'devices' and len(parts) >= 3 and parts[2] == 'services':
try:
data = json.loads(payload)
@@ -190,11 +221,30 @@ def on_message(client, userdata, msg):
"disk": round(data.get("disk_usage_percent", 0), 1),
"processes": data.get("processes", {}),
"files": data.get("files", data.get("config_files", [])),
"profiles": data.get("profiles", {"A": "PROFILO A", "B": "PROFILO B"})
"profiles": data.get("profiles", {"A": "PROFILE A", "B": "PROFILE B"})
}
socketio.emit('dati_aggiornati') # <--- WEBSOCKET
except Exception as e: logger.error(f"Errore parsing health: {e}")
# --- PUSH TRIGGER: SERVICE ERRORS ---
processes = data.get("processes", {})
for svc_name, svc_status in processes.items():
status_key = f"{cid}_{svc_name}"
s_lower = svc_status.lower()
if s_lower in ["error", "stopped", "failed"]:
if last_notified_errors.get(status_key) != s_lower:
msg_err = f"Service {svc_name} KO ({svc_status})"
if s_lower == "error": msg_err += " - Auto-healing failed! ⚠️"
broadcast_push_notification(f"🚨 ALARM: {cid.upper()}", msg_err)
last_notified_errors[status_key] = s_lower
elif s_lower == "online" and status_key in last_notified_errors:
broadcast_push_notification(f"✅ RESTORED: {cid.upper()}", f"Service {svc_name} back ONLINE.")
del last_notified_errors[status_key]
# -----------------------------------------
except Exception as e:
logger.error(f"Error parsing health data: {e}")
# --- DMR GATEWAY MANAGEMENT ---
elif len(parts) >= 4 and parts[0] == 'data' and parts[2].lower() == 'dmrgateway' and (parts[3].upper().startswith('NETWORK') or parts[3].upper().startswith('DMR NETWORK')):
try:
cid = parts[1].lower()
@@ -219,8 +269,9 @@ def on_message(client, userdata, msg):
socketio.emit('dati_aggiornati') # <--- WEBSOCKET
except Exception as e:
logger.error(f"Errore parsing DMRGateway per {cid}: {e}")
logger.error(f"Error parsing DMRGateway for {cid}: {e}")
# --- OTHER GATEWAYS MANAGEMENT ---
elif parts[0] in ['dmr-gateway', 'nxdn-gateway', 'ysf-gateway', 'p25-gateway', 'dstar-gateway']:
data = json.loads(payload)
proto = "DMR"
@@ -242,11 +293,12 @@ def on_message(client, userdata, msg):
if m: save_to_sqlite(cid, {'source_id': "🌐 " + m, 'destination_id': 'NET'}, protocol=proto)
# --- MMDVM AND TRAFFIC MANAGEMENT ---
elif parts[0] == 'mmdvm':
data = json.loads(payload)
if cid not in active_calls: active_calls[cid] = {}
if cid not in client_telemetry or not isinstance(client_telemetry.get(cid), dict):
client_telemetry[cid] = {"ts1": "In attesa...", "ts2": "In attesa...", "alt": "", "idle": True}
client_telemetry[cid] = {"ts1": "Waiting...", "ts2": "Waiting...", "alt": "", "idle": True}
if 'MMDVM' in data and data['MMDVM'].get('mode') == 'idle':
client_telemetry[cid]["idle"] = True
@@ -304,9 +356,10 @@ def on_message(client, userdata, msg):
client_telemetry[cid]["alt"] = f"{'' if act=='end' else '⚠️'} {name}: {info['src']}"
save_cache(client_telemetry)
if k in active_calls[cid]: del active_calls[cid][k]
except Exception as e: logger.error(f"ERRORE MQTT MSG: {e}")
except Exception as e:
logger.error(f"MQTT MSG ERROR: {e}")
# --- INIZIALIZZAZIONE CLIENT MQTT ---
# --- MQTT CLIENT INITIALIZATION ---
mqtt_backend = mqtt_client.Client(mqtt_client.CallbackAPIVersion.VERSION2, "flask_backend")
mqtt_backend.username_pw_set(config['mqtt']['user'], config['mqtt']['password'])
mqtt_backend.on_connect = on_connect
@@ -344,7 +397,7 @@ def get_states():
@app.route('/api/service_control', methods=['POST'])
def service_control():
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 403
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
d = request.json
cid = d.get('clientId').lower()
action = d.get('action')
@@ -378,7 +431,7 @@ def login():
@app.route('/api/command', methods=['POST'])
def cmd():
if not session.get('logged_in'): return jsonify({"success": False, "error": "Non autenticato"}), 403
if not session.get('logged_in'): return jsonify({"success": False, "error": "Not authenticated"}), 403
d = request.json
cid = d['clientId'].lower()
cmd_type = d['type']
@@ -387,7 +440,7 @@ def cmd():
allowed = session.get('allowed_nodes', '')
is_allowed = (role == 'admin' or allowed == 'all' or cid in [x.strip() for x in allowed.split(',')])
if cmd_type == 'REBOOT' and role != 'admin':
return jsonify({"success": False, "error": "Solo gli Admin possono riavviare."}), 403
return jsonify({"success": False, "error": "Only Admins can reboot."}), 403
if is_allowed:
mqtt_backend.publish(f"servizi/{cid}/cmnd", cmd_type)
conn = sqlite3.connect(DB_PATH)
@@ -396,20 +449,27 @@ def cmd():
(username, cid, cmd_type))
conn.commit()
conn.close()
client_telemetry[cid] = {"ts1": "🔄 Inviato...", "ts2": "🔄 Inviato...", "alt": ""}
client_telemetry[cid] = {"ts1": "🔄 Sent...", "ts2": "🔄 Sent...", "alt": ""}
return jsonify({"success": True})
return jsonify({"success": False, "error": "Non hai i permessi per questo nodo."}), 403
return jsonify({"success": False, "error": "You do not have permission for this node."}), 403
# --- API PER IL PULSANTE DI AGGIORNAMENTO ---
@app.route('/api/update_nodes', methods=['POST'])
def update_nodes():
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 403
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
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": "Non autorizzato"}), 403
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
@@ -420,14 +480,14 @@ def get_users():
@app.route('/api/users', methods=['POST'])
def add_user():
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 403
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
d = request.json
username = d.get('username')
password = d.get('password')
role = d.get('role', 'operator')
allowed = d.get('allowed_nodes', '')
if not username or not password:
return jsonify({"success": False, "error": "Dati mancanti"})
return jsonify({"success": False, "error": "Missing data"})
h = generate_password_hash(password)
try:
conn = sqlite3.connect(DB_PATH)
@@ -438,18 +498,18 @@ def add_user():
conn.close()
return jsonify({"success": True})
except sqlite3.IntegrityError:
return jsonify({"success": False, "error": "Username già esistente"})
return jsonify({"success": False, "error": "Username already exists"})
@app.route('/api/users/<int:user_id>', methods=['DELETE'])
def delete_user(user_id):
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 403
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT username FROM users WHERE id = ?", (user_id,))
u = c.fetchone()
if u and u[0] == session.get('user'):
conn.close()
return jsonify({"success": False, "error": "Non puoi cancellare te stesso!"})
return jsonify({"success": False, "error": "You cannot delete yourself!"})
c.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
conn.close()
@@ -458,7 +518,7 @@ def delete_user(user_id):
@app.route('/api/users/<int:user_id>', methods=['PUT'])
def update_user(user_id):
if session.get('role') != 'admin':
return jsonify({"error": "Non autorizzato"}), 403
return jsonify({"error": "Unauthorized"}), 403
data = request.json
role = data.get('role', 'operator')
@@ -486,14 +546,14 @@ def update_user(user_id):
@app.route('/api/change_password', methods=['POST'])
def change_password():
if not session.get('logged_in'):
return jsonify({"success": False, "error": "Non autenticato"}), 403
return jsonify({"success": False, "error": "Not authenticated"}), 403
d = request.json
new_pass = d.get('new_password')
user_to_change = d.get('username')
if session.get('role') != 'admin' and session.get('user') != user_to_change:
return jsonify({"success": False, "error": "Non autorizzato"}), 403
return jsonify({"success": False, "error": "Unauthorized"}), 403
if not new_pass:
return jsonify({"success": False, "error": "La password non può essere vuota"}), 400
return jsonify({"success": False, "error": "Password cannot be empty"}), 400
h = generate_password_hash(new_pass)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
@@ -505,7 +565,7 @@ def change_password():
@app.route('/api/global_command', methods=['POST'])
def global_cmd():
if session.get('role') != 'admin':
return jsonify({"success": False, "error": "Azione riservata all'Admin!"}), 403
return jsonify({"success": False, "error": "Admin action only!"}), 403
d = request.json
cmd_type = d.get('type')
clients_list = []
@@ -535,14 +595,14 @@ def auto_update_ids():
})
now = time.strftime("%H:%M")
if now == target_time:
logger.info(f">>> [AUTO-UPDATE] Orario raggiunto ({now}). Download in corso...")
logger.info(f">>> [AUTO-UPDATE] Scheduled time reached ({now}). Downloading...")
urllib.request.urlretrieve(urls["dmr"], DMR_IDS_PATH)
urllib.request.urlretrieve(urls["nxdn"], NXDN_IDS_PATH)
load_ids()
logger.info(f">>> [AUTO-UPDATE] Completato con successo.")
logger.info(f">>> [AUTO-UPDATE] Completed successfully.")
time.sleep(65)
except Exception as e:
logger.error(f">>> [AUTO-UPDATE] Errore: {e}")
logger.error(f">>> [AUTO-UPDATE] Error: {e}")
time.sleep(30)
@app.route('/api/ui_config', methods=['GET'])
@@ -551,9 +611,9 @@ def get_ui_config():
with open(CONFIG_PATH, 'r') as f:
cfg = json.load(f)
ui_cfg = cfg.get("ui", {
"profileA_Name": "PROFILO A",
"profileA_Name": "PROFILE A",
"profileA_Color": "#3498db",
"profileB_Name": "PROFILO B",
"profileB_Name": "PROFILE B",
"profileB_Color": "#9b59b6"
})
return jsonify(ui_cfg)
@@ -562,7 +622,7 @@ def get_ui_config():
@app.route('/api/config', methods=['GET'])
def get_config_api():
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 403
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
with open(CONFIG_PATH, 'r') as f:
cfg = json.load(f)
return jsonify({
@@ -573,7 +633,7 @@ def get_config_api():
@app.route('/api/config', methods=['POST'])
def save_config_api():
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 403
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
new_data = request.json
with open(CONFIG_PATH, 'r') as f:
cfg = json.load(f)
@@ -587,18 +647,18 @@ def save_config_api():
@app.route('/api/config_file/<cid>/<service>', methods=['GET'])
def get_config_file(cid, service):
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 403
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
cid = cid.lower()
service = service.lower()
config_data = device_configs.get(cid, {}).get(service)
if not config_data:
return jsonify({"error": "Configurazione non ancora ricevuta. Attendi o invia un comando UPDATE."}), 404
return jsonify({"error": "Configuration not received yet. Wait or send an UPDATE command."}), 404
return jsonify({"success": True, "data": config_data})
@app.route('/api/config_file', methods=['POST'])
def save_config_file():
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 403
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
d = request.json
cid = d.get('clientId').lower()
service = d.get('service').lower()
@@ -625,6 +685,47 @@ def serve_sw():
def serve_icon():
return send_from_directory('.', 'icon-512.png')
def broadcast_push_notification(title, body):
wp_config = config.get('webpush')
if not wp_config: return
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT id, subscription FROM push_subscriptions")
subs = c.fetchall()
for sub_id, sub_json in subs:
try:
webpush(
subscription_info=json.loads(sub_json),
data=json.dumps({"title": title, "body": body}),
vapid_private_key=wp_config['vapid_private_key'],
vapid_claims={"sub": wp_config['vapid_claim_email']}
)
except WebPushException as ex:
if ex.response and ex.response.status_code == 410:
c.execute("DELETE FROM push_subscriptions WHERE id = ?", (sub_id,))
conn.commit()
except Exception as e:
logger.error(f"Generic Push Error: {e}")
conn.close()
@app.route('/api/vapid_public_key')
def get_vapid_key():
return jsonify({"public_key": config.get('webpush', {}).get('vapid_public_key', '')})
@app.route('/api/subscribe', methods=['POST'])
def subscribe_push():
if not session.get('logged_in'): return jsonify({"error": "Unauthorized"}), 403
sub_data = request.json
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("INSERT OR IGNORE INTO push_subscriptions (username, subscription) VALUES (?, ?)",
(session.get('user'), json.dumps(sub_data)))
conn.commit()
conn.close()
return jsonify({"success": True})
if __name__ == '__main__':
threading.Thread(target=auto_update_ids, daemon=True).start()
socketio.run(app, host='0.0.0.0', port=9000)
socketio.run(app, host='0.0.0.0', port=9000, allow_unsafe_werkzeug=True)
+18 -9
View File
@@ -1,15 +1,24 @@
{
"_comment": {"Default admin username and password"
},
"web_admin": {
"user": "admin",
"pass": "admin123"
},
"mqtt": {
"broker": "127.0.0.1",
"broker": "your_mqtt_broker_address",
"port": 1883,
"user": "mmdvm",
"password": "password"
"user": "your_username",
"password": "your_password"
},
"web_admin": {
"default_user": "admin",
"default_pass": "admin123"
},
"webpush": {
"vapid_public_key": "INSERT_GENERATED_PUBLIC_KEY_HERE",
"vapid_private_key": "INSERT_GENERATED_PRIVATE_KEY_HERE",
"vapid_claim_email": "mailto:your@email.com"
},
"ui": {
"profileA_Name": "PROFILE A",
"profileA_Color": "#3b82f6",
"profileB_Name": "PROFILE B",
"profileB_Color": "#eab308"
},
"update_schedule": "03:00",
"id_urls": {
+132
View File
@@ -0,0 +1,132 @@
============================================================
INSTALLATION GUIDE - FLEET CONTROL CONSOLE
============================================================
This guide describes the steps to install the Central
Dashboard and the Remote Agents on MMDVM nodes.
------------------------------------------------------------
1. PRE-REQUISITES
------------------------------------------------------------
Ensure Python 3 is installed on all systems.
The necessary dependencies are listed in the
'requirements.txt' file.
Install dependencies:
pip install -r requirements.txt
------------------------------------------------------------
2. SERVER SETUP (CENTRAL HUB)
------------------------------------------------------------
The server handles the web interface and user permissions.
Steps:
1. Configure 'config.json' using 'config.example.json' as a template.
2. Enter MQTT credentials and VAPID keys.
3. Define repeaters in the 'clients.json' file.
4. Start the server:
python3 app.py
------------------------------------------------------------
3. GENERATING VAPID KEYS (PUSH NOTIFICATIONS)
------------------------------------------------------------
Required to enable browser and mobile notifications.
1. Go to https://vapidkeys.com/ and generate the keys.
2. Copy 'Public Key' and 'Private Key' into 'config.json'.
3. Set 'vapid_claim_email' (e.g., "mailto:your@email.com").
------------------------------------------------------------
4. AGENT SETUP (REMOTE NODES)
------------------------------------------------------------
To be installed on each Raspberry Pi / MMDVM Node.
1. Copy 'system_monitor.py' and 'node_config.json' to
'/opt/node_agent/'.
2. Edit 'node_config.json' with a unique 'client_id'.
3. (Optional) Install 'RPi.GPIO' for hardware reset.
------------------------------------------------------------
5. RUNNING AS A SERVICE (SYSTEMD)
------------------------------------------------------------
For auto-start and process monitoring.
Configuration:
1. Copy .service files to '/etc/systemd/system/':
- Server: sudo cp fleet-console.service /etc/systemd/system/
- Nodes: sudo cp fleet-agent.service /etc/systemd/system/
2. Reload systemd:
sudo systemctl daemon-reload
3. Enable start on boot:
sudo systemctl enable fleet-console (or fleet-agent)
4. Start the service:
sudo systemctl start fleet-console
============================================================
GUIDA ALL'INSTALLAZIONE - FLEET CONTROL CONSOLE
============================================================
Questa guida descrive i passaggi per installare la Dashboard
Centrale e gli Agenti Remoti sui nodi MMDVM.
------------------------------------------------------------
1. REQUISITI PRELIMINARI
------------------------------------------------------------
Assicurarsi di avere Python 3 installato su tutti i sistemi.
Le dipendenze necessarie sono elencate nel file
'requirements.txt'.
Installazione dipendenze:
pip install -r requirements.txt
------------------------------------------------------------
2. SETUP DEL SERVER (HUB CENTRALE)
------------------------------------------------------------
Il server gestisce l'interfaccia web e i permessi.
Passaggi:
1. Configura 'config.json' partendo da 'config.example.json'.
2. Inserisci le credenziali MQTT e le chiavi VAPID.
3. Definisci i ripetitori nel file 'clients.json'.
4. Avvia il server:
python3 app.py
------------------------------------------------------------
3. GENERAZIONE CHIAVI VAPID (NOTIFICHE PUSH)
------------------------------------------------------------
Necessarie per abilitare le notifiche su browser e mobile.
1. Vai su https://vapidkeys.com/ e genera le chiavi.
2. Copia 'Public Key' e 'Private Key' nel 'config.json'.
3. Imposta 'vapid_claim_email' (es. "mailto:tua@email.com").
------------------------------------------------------------
4. SETUP DELL'AGENTE (NODI REMOTI)
------------------------------------------------------------
Da installare su ogni Raspberry Pi / Nodo MMDVM.
1. Copia 'system_monitor.py' e 'node_config.json' in
'/opt/node_agent/'.
2. Modifica 'node_config.json' con il 'client_id' univoco.
3. (Opzionale) Installa 'RPi.GPIO' per il reset hardware.
------------------------------------------------------------
5. ESECUZIONE COME SERVIZIO (SYSTEMD)
------------------------------------------------------------
Per l'avvio automatico e il monitoraggio del processo.
Configurazione:
1. Copia i file .service in '/etc/systemd/system/':
- Server: sudo cp fleet-console.service /etc/systemd/system/
- Nodi: sudo cp fleet-agent.service /etc/systemd/system/
2. Ricarica systemd:
sudo systemctl daemon-reload
3. Abilita l'avvio al boot:
sudo systemctl enable fleet-console (oppure fleet-agent)
4. Avvia il servizio:
sudo systemctl start fleet-console
------------------------------------------------------------
Created by IV3JDV @ ARIFVG - 2026
============================================================
+6
View File
@@ -0,0 +1,6 @@
Flask
Flask-SocketIO
paho-mqtt
psutil
Werkzeug
pywebpush
+47 -2
View File
@@ -1,9 +1,11 @@
const CACHE_NAME = 'fleet-c2-v1';
const CACHE_NAME = 'fleet-c2-v2'; // Incrementiamo la versione
const urlsToCache = [
'/',
'/manifest.json'
'/manifest.json',
'/icon-512.png'
];
// --- 1. GESTIONE CACHE (Il tuo codice originale) ---
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
@@ -16,3 +18,46 @@ self.addEventListener('fetch', event => {
fetch(event.request).catch(() => caches.match(event.request))
);
});
// --- 2. GESTIONE NOTIFICHE PUSH (Il nuovo codice) ---
self.addEventListener('push', function(event) {
console.log('[Service Worker] Notifica Push ricevuta.');
let data = { title: 'Fleet Alert', body: 'Nuovo messaggio dal sistema.' };
if (event.data) {
try {
data = event.data.json();
} catch (e) {
data.body = event.data.text();
}
}
const options = {
body: data.body,
icon: '/icon-512.png',
badge: '/icon-512.png',
vibrate: [200, 100, 200],
data: {
dateOfArrival: Date.now(),
primaryKey: '1'
},
actions: [
{ action: 'explore', title: 'Apri Dashboard' },
{ action: 'close', title: 'Chiudi' }
]
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
self.addEventListener('notificationclick', function(event) {
event.notification.close();
if (event.action === 'explore') {
event.waitUntil(
clients.openWindow('/')
);
}
});
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Fleet Control - Remote Node Agent
After=network.target
[Service]
Type=simple
# Agent must be run as root for services restart (MMDVMHost etc.) and use GPIO
User=root
WorkingDirectory=/opt/node_agent
ExecStart=/usr/bin/python3 /opt/node_agent/system_monitor.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=Fleet Control Console - Central Hub
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/web-control
ExecStart=/usr/bin/python3 /opt/web-control/app.py
Restart=always
RestartSec=5
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=fleet-console
[Install]
WantedBy=multi-user.target
+166 -138
View File
@@ -12,147 +12,111 @@
<link rel="apple-touch-icon" href="/icon-512.png">
<meta name="apple-mobile-web-app-title" content="Fleet C2">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
<style>
/* --- TEMA NOC (Network Operations Center) --- */
:root {
--primary: #3b82f6; --success: #10b981; --danger: #ef4444; --accent: #8b5cf6;
--bg-gradient: linear-gradient(135deg, #e0e7ff 0%, #ede9fe 100%);
--card-bg: rgba(255, 255, 255, 0.6);
--border-color: rgba(255, 255, 255, 0.8);
--text-main: #1e293b; --text-muted: #64748b;
--glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07);
--topbar-bg: rgba(255, 255, 255, 0.8);
--primary: #2f81f7; --success: #2ea043; --danger: #da3633; --accent: #a371f7;
--bg-gradient: #0d1117; /* Sfondo nero/grigio profondissimo */
--card-bg: #161b22; /* Sfondo grigio antracite per i pannelli */
--border-color: #30363d;/* Bordi netti e sottili */
--text-main: #c9d1d9; /* Bianco attenuato anti-affaticamento */
--text-muted: #8b949e; /* Grigio tecnico per label */
--topbar-bg: #161b22;
}
/* Modalità scura "pura" se il toggle viene premuto */
body.dark-mode {
--bg-gradient: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #020617 100%);
--card-bg: rgba(30, 41, 59, 0.5);
--border-color: rgba(255, 255, 255, 0.08);
--text-main: #f8fafc; --text-muted: #94a3b8;
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.4);
--topbar-bg: rgba(15, 23, 42, 0.8);
--bg-gradient: #010409; --card-bg: #0d1117; --border-color: #21262d;
--text-main: #f0f6fc; --topbar-bg: #0d1117;
}
* { box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: var(--bg-gradient); background-attachment: fixed; margin: 0; padding: 0; color: var(--text-main); transition: color 0.3s; min-height: 100vh; }
body { font-family: 'Inter', sans-serif; background: var(--bg-gradient); margin: 0; padding: 0; color: var(--text-main); transition: background-color 0.3s; min-height: 100vh; }
/* Floating Top Bar */
/* Barra superiore squadrata e tecnica */
#top-bar-container { position: sticky; top: 15px; z-index: 100; padding: 0 20px; display: flex; justify-content: center; }
#top-bar { background: var(--topbar-bg); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); padding: 12px 30px; display: flex; justify-content: space-between; align-items: center; border-radius: 50px; box-shadow: var(--glass-shadow); border: 1px solid var(--border-color); width: 100%; max-width: 1400px; }
#top-bar { background: var(--topbar-bg); padding: 12px 30px; display: flex; justify-content: space-between; align-items: center; border-radius: 6px; border: 1px solid var(--border-color); width: 100%; max-width: 1400px; }
.title-brand { font-size: 1.2rem; font-weight: 800; letter-spacing: 2px; color: var(--text-main); font-family: 'JetBrains Mono', monospace; }
.title-brand { font-size: 1.3rem; font-weight: 800; letter-spacing: 1px; background: linear-gradient(to right, var(--primary), var(--accent)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.theme-switch { background: transparent; border: 1px solid var(--border-color); color: var(--text-muted); padding: 6px 14px; border-radius: 4px; cursor: pointer; font-weight: 600; font-size: 0.85rem; font-family: 'JetBrains Mono', monospace; }
.theme-switch:hover { color: var(--text-main); border-color: var(--text-main); }
.theme-switch { background: rgba(128, 128, 128, 0.1); border: 1px solid var(--border-color); color: var(--text-main); padding: 8px 18px; border-radius: 20px; cursor: pointer; font-weight: 600; transition: all 0.2s ease; font-size: 0.85rem; }
.theme-switch:hover { background: rgba(128, 128, 128, 0.2); transform: translateY(-1px); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; max-width: 1400px; margin: 30px auto; padding: 0 20px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 30px; max-width: 1400px; margin: 40px auto; padding: 0 20px; }
/* Pannelli Ripetitori - Zero ombre, bordi netti */
.card { background: var(--card-bg) !important; border-radius: 4px; padding: 20px; border: 1px solid var(--border-color); border-top: 4px solid var(--border-color) !important; display: flex; flex-direction: column; box-shadow: none !important; filter: none !important; opacity: 1 !important; }
.card.online { border-top-color: var(--success) !important; }
/* Glass Cards */
.card { background: var(--card-bg); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border-radius: 20px; padding: 25px; box-shadow: var(--glass-shadow); border: 1px solid var(--border-color); border-top: 5px solid #64748b; transition: transform 0.3s ease, box-shadow 0.3s ease; display: flex; flex-direction: column; }
.card:hover { transform: translateY(-4px); box-shadow: 0 12px 40px 0 rgba(0,0,0,0.15); }
.card.online { border-top-color: var(--success); }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid var(--border-color); padding-bottom: 10px; }
.client-name { font-size: 1.1rem; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
.badge-id { font-size: 0.75rem; font-family: 'JetBrains Mono', monospace; background: #010409; padding: 4px 8px; border-radius: 3px; border: 1px solid var(--border-color); color: var(--text-muted); }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.client-name { font-size: 1.25rem; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
.badge-id { font-size: 0.7rem; font-weight: 800; background: rgba(128, 128, 128, 0.15); padding: 4px 10px; border-radius: 12px; letter-spacing: 0.5px; border: 1px solid var(--border-color); }
/* Telemetria stile terminale */
.health-bar { display: flex; justify-content: space-between; font-size: 0.75rem; font-weight: 600; background: #010409; padding: 8px 12px; border-radius: 3px; margin-bottom: 15px; border: 1px solid var(--border-color); font-family: 'JetBrains Mono', monospace; }
.health-bar { display: flex; justify-content: space-between; font-size: 0.75rem; font-weight: 600; background: rgba(0,0,0,0.05); padding: 8px 12px; border-radius: 12px; margin-bottom: 15px; border: 1px solid rgba(128,128,128,0.1); }
body.dark-mode .health-bar { background: rgba(0,0,0,0.2); }
/* Display Stato */
.status-display { text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; font-weight: 700; padding: 8px; border-radius: 3px; background: #010409; border: 1px solid var(--border-color); margin-bottom: 15px; color: var(--text-muted); }
.card.online .status-display { color: var(--success); border-color: rgba(46, 160, 67, 0.4); }
.status-offline { color: var(--danger) !important; border-color: rgba(218, 54, 51, 0.4) !important; }
.status-display { text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; font-weight: 700; padding: 10px; border-radius: 12px; background: rgba(0,0,0,0.05); border: 1px solid var(--border-color); margin-bottom: 15px; transition: all 0.3s; }
body.dark-mode .status-display { background: rgba(0,0,0,0.2); color: #10b981; }
.status-offline { color: var(--danger) !important; opacity: 0.8; }
/* TimeSlots Override */
.ts-container { display: flex; flex-direction: column; gap: 6px; margin-bottom: 15px; }
.dmr-info { font-size: 0.85rem; font-weight: 600; font-family: 'JetBrains Mono', monospace; padding: 8px 12px; border-radius: 3px; background: #010409 !important; border: 1px solid var(--border-color); border-left: 4px solid var(--border-color) !important; color: var(--text-muted) !important; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* TimeSlots */
.ts-container { display: flex; flex-direction: column; gap: 8px; margin-bottom: 15px; }
.dmr-info { font-size: 0.85rem; font-weight: 600; padding: 10px 15px; border-radius: 12px; background: rgba(59, 130, 246, 0.1); border-left: 4px solid var(--primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: all 0.2s; }
/* Terminal Log interno ai pannelli */
.terminal-log { background: #010409; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; border-radius: 3px; padding: 10px; height: 130px; overflow-y: auto; border: 1px solid var(--border-color); line-height: 1.4; margin-bottom: 15px; box-shadow: inset 0 2px 10px rgba(0,0,0,0.5); }
/* Terminal Log */
.terminal-log { background: #0f172a; color: #10b981; font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; border-radius: 12px; padding: 12px; height: 130px; overflow-y: auto; border: 1px solid rgba(255,255,255,0.1); line-height: 1.5; margin-bottom: 15px; box-shadow: inset 0 2px 10px rgba(0,0,0,0.5); }
/* Bottoni flat */
.actions { display: none; gap: 8px; flex-wrap: wrap; margin-top: auto; }
.btn-cmd { flex: 1; padding: 8px 10px; border: 1px solid var(--border-color); background: #010409 !important; border-radius: 3px; font-weight: 600; font-size: 0.75rem; cursor: pointer; color: var(--text-main) !important; font-family: 'JetBrains Mono', monospace; transition: border-color 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 5px; box-shadow: none !important; }
.btn-cmd:hover { border-color: var(--text-main); }
/* Buttons */
.actions { display: none; gap: 10px; flex-wrap: wrap; margin-top: auto; }
.btn-cmd { flex: 1; padding: 10px 12px; border: none; border-radius: 12px; font-weight: 700; font-size: 0.8rem; cursor: pointer; color: white; transition: all 0.2s ease; box-shadow: 0 4px 6px rgba(0,0,0,0.1); display: flex; align-items: center; justify-content: center; gap: 5px; }
.btn-cmd:hover { transform: translateY(-2px); filter: brightness(1.1); box-shadow: 0 6px 12px rgba(0,0,0,0.15); }
.btn-cmd:active { transform: translateY(0); }
/* Tabella LastHeard */
.table-container { background: var(--card-bg); border-radius: 6px; border: 1px solid var(--border-color); overflow: hidden; max-height: 400px; overflow-y: auto; margin: 0 20px 40px 20px; box-shadow: none; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.85rem; font-family: 'JetBrains Mono', monospace; }
thead { background: var(--topbar-bg); position: sticky; top: 0; z-index: 1; border-bottom: 1px solid var(--border-color); }
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); }
.table-container { background: var(--card-bg); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border-radius: 20px; border: 1px solid var(--border-color); overflow: hidden; max-height: 400px; overflow-y: auto; box-shadow: var(--glass-shadow); margin: 0 20px 40px 20px; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.9rem; }
/* 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;
}
/* Table Header Frosted Glass */
thead { background: rgba(255, 255, 255, 0.9); position: sticky; top: 0; z-index: 1; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05); }
body.dark-mode thead { background: rgba(15, 23, 42, 0.95); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.3); }
th { padding: 15px; font-weight: 700; text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.5px; color: var(--text-muted); }
td { padding: 12px 15px; border-bottom: 1px solid var(--border-color); }
/* 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 pulse-glow { 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } 70% { box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); } 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); } }
.blink { animation: pulse-glow 1.5s infinite; color: var(--danger) !important; font-weight: 800 !important; background: rgba(239, 68, 68, 0.1) !important; border-left-color: var(--danger) !important; }
@keyframes tx-glow { 0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.5); } 70% { box-shadow: 0 0 0 10px rgba(59, 130, 246, 0); } 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); } }
.tx-active { animation: tx-glow 1.5s infinite; font-weight: 800 !important; color: var(--text-main) !important; background: rgba(59, 130, 246, 0.25) !important; border-left-color: var(--primary) !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); }
/* Modals (Dark Glass) */
.modal-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); backdrop-filter:blur(5px); -webkit-backdrop-filter:blur(5px); z-index:1000; align-items:center; justify-content:center; }
.modal-content { background:var(--card-bg); backdrop-filter:blur(20px); border:1px solid var(--border-color); padding:30px; border-radius:24px; box-shadow:0 25px 50px -12px rgba(0,0,0,0.5); max-height: 90vh; overflow-y: auto; }
/* Elementi Input e Form */
.auth-btn { background: #010409; color: var(--text-main); border: 1px solid var(--border-color); padding: 6px 12px; border-radius: 4px; font-weight: 600; cursor: pointer; font-size: 0.8rem; font-family: 'JetBrains Mono', monospace; transition: border-color 0.2s; }
.auth-btn:hover { border-color: var(--text-main); }
input, select { background: #010409; border: 1px solid var(--border-color); color: var(--text-main); padding: 8px 12px; border-radius: 4px; font-family: 'JetBrains Mono', monospace; outline: none; }
input:focus { border-color: var(--primary); }
input:disabled { opacity: 0.5; cursor: not-allowed; }
option { background: var(--bg-main); color: var(--text-main); }
/* Auth Buttons Style */
.auth-btn { background: var(--text-main); color: var(--bg-gradient); border: none; padding: 8px 16px; border-radius: 20px; font-weight: 700; cursor: pointer; font-size: 0.85rem; transition: 0.2s; }
.auth-btn:hover { opacity: 0.8; }
input, select { background: rgba(128,128,128,0.1); border: 1px solid var(--border-color); color: var(--text-main); padding: 10px 15px; border-radius: 12px; font-family: inherit; outline: none; transition: 0.2s; }
input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); }
input:disabled { opacity: 0.6; cursor: not-allowed; }
/* Dropdown Fix in Dark Mode */
option { background: #ffffff; color: #1e293b; }
/* --- OTTIMIZZAZIONE MOBILE (PWA / SMARTPHONE) --- */
/* Ottimizzazione Mobile */
@media (max-width: 768px) {
#top-bar-container { top: 10px; padding: 0 10px; }
#top-bar {
flex-direction: column;
border-radius: 24px;
padding: 15px;
gap: 12px;
}
.title-brand { font-size: 1.15rem; text-align: center; width: 100%; }
/* Contenitore dei tasti lingua/tema */
#top-bar > div {
width: 100%;
flex-wrap: wrap;
justify-content: center;
gap: 8px !important;
}
/* Contenitore Bottoni Login / Admin */
#auth-container {
width: 100%;
justify-content: center;
flex-wrap: wrap;
padding-top: 12px;
margin-top: 4px;
border-top: 1px solid rgba(128, 128, 128, 0.2);
}
/* Username centrato */
#auth-container > span {
width: 100%;
text-align: center;
margin-bottom: 8px;
margin-left: 0 !important;
margin-right: 0 !important;
}
/* Bottoni ingranditi perfetti per il touch */
.auth-btn {
flex: 1 1 auto;
min-width: 100px;
padding: 12px !important;
text-align: center;
font-size: 0.9rem;
}
.theme-switch {
flex: 1 1 auto;
text-align: center;
padding: 10px;
}
#top-bar { flex-direction: column; padding: 15px; gap: 12px; border-radius: 8px; }
.title-brand { font-size: 1.1rem; text-align: center; width: 100%; }
#top-bar > div { width: 100%; flex-wrap: wrap; justify-content: center; gap: 8px !important; }
#auth-container { width: 100%; justify-content: center; flex-wrap: wrap; padding-top: 12px; border-top: 1px solid var(--border-color); }
#auth-container > span { width: 100%; text-align: center; margin-bottom: 8px; font-family: 'JetBrains Mono', monospace; }
.auth-btn, .theme-switch { flex: 1 1 auto; text-align: center; }
}
body.dark-mode option { background: #0f172a; color: #f8fafc; }
</style>
</head>
<body>
@@ -162,7 +126,7 @@
<div class="title-brand">FLEET CONTROL CONSOLE</div>
<div style="display: flex; align-items: center; gap: 12px;">
<button class="theme-switch" id="lang-btn" onclick="toggleLang()" data-i18n-title="ttLang">🇮🇹 ITA</button>
<button class="theme-switch" id="theme-btn" onclick="toggleTheme()" data-i18n-title="ttTheme">🌙 DARK</button>
<button class="theme-switch" id="push-btn" onclick="subscribeToPush()">🔔 PUSH</button>
<div id="auth-container" style="display:flex; align-items:center; gap:8px;"></div>
</div>
</div>
@@ -459,12 +423,6 @@
let currentResetHatId = null;
let confirmActionCallback = null;
function toggleTheme() {
const isDark = document.body.classList.toggle('dark-mode');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
document.getElementById('theme-btn').innerText = isDark ? t('themeLight') : t('themeDark');
}
// --- NEW GLOBAL GLASSMORPHISM POPUPS ---
function customAlert(title, desc, isError = false) {
const color = isError ? 'var(--danger)' : 'var(--success)';
@@ -584,11 +542,6 @@
const grid = document.getElementById('client-grid');
const authContainer = document.getElementById('auth-container');
if (localStorage.getItem('theme') === 'dark') {
document.body.classList.add('dark-mode');
document.getElementById('theme-btn').innerText = t('themeLight');
} else { document.getElementById('theme-btn').innerText = t('themeDark'); }
const role = sessionStorage.getItem('user_role');
const allowed = sessionStorage.getItem('allowed_nodes') || "";
@@ -715,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) => {
@@ -736,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');
}
});
}
@@ -1076,7 +1042,69 @@
});
}
initUI();
// --- FUNZIONE PER ISCRIVERSI ALLE PUSH ---
async function subscribeToPush() {
if (!('serviceWorker' in navigator)) return;
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
customAlert("Error", "Push notifications permission denied.", true);
return;
}
try {
const reg = await navigator.serviceWorker.ready;
const res = await fetch('/api/vapid_public_key');
const { public_key } = await res.json();
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlB64ToUint8Array(public_key)
});
await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
customAlert("Success", "Push notifications enabled successfully!");
document.getElementById('push-btn').style.color = 'var(--success)';
} catch (e) {
console.error(e);
customAlert("Error", "Failed to enable notifications.", true);
}
}
// --- FUNZIONE PER CONTROLLARE IL COLORE DEL BOTTONE ---
async function checkPushStatus() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
try {
const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.getSubscription();
const btn = document.getElementById('push-btn');
if (subscription && btn) {
btn.style.color = 'var(--success)';
}
} catch(e) {
console.error("Errore nel controllo stato Push:", e);
}
}
// Helper per convertire la chiave VAPID
function urlB64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i);
return outputArray;
}
initUI();
checkPushStatus(); // Ora funzionerà perfettamente!
// --- MOTORE WEBSOCKET REAL-TIME ---
const socket = io();
+11
View File
@@ -0,0 +1,11 @@
import sqlite3
import json
from app import broadcast_push_notification, init_db
# Proviamo a inviare una notifica a tutti gli iscritti nel DB
print("🚀 Starting notification test...")
broadcast_push_notification(
"⚠️ ALLERTA FLOTTA",
"Test Push Notification: System Online!"
)
print("✅ Command sent.")