5 Commits

Author SHA1 Message Date
iv3jdv 5f41744d93 Add HTTPS warnings for Push notifications 2026-04-23 14:46:58 +02:00
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
5 changed files with 216 additions and 218 deletions
+4
View File
@@ -38,6 +38,8 @@ The ecosystem consists of three main parts:
* Run: `python3 app.py`
#### 🔑 Generating VAPID Keys (Push Notifications)
> ⚠️ **IMPORTANT:** Web Push Notifications strictly require the dashboard to be accessed via a secure **HTTPS** connection (or localhost). Modern browsers will block push features over standard HTTP.
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.
@@ -100,6 +102,8 @@ L'ecosistema si compone di tre parti principali:
* Avvia: `python3 app.py`
#### 🔑 Generare le chiavi VAPID (Notifiche Push)
> ⚠️ **IMPORTANTE:** Le Notifiche Push Web richiedono tassativamente che la dashboard sia accessibile tramite una connessione sicura **HTTPS** (o localhost). I browser mobili e desktop bloccano le notifiche su connessioni HTTP non protette.
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.
+96 -73
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,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
while True:
try:
logger.info("Tentativo di connessione al broker MQTT...")
client.connect(cfg['mqtt']['broker'], cfg['mqtt']['port'], 60)
client.loop_start()
# 1. Start the telemetry "engine" ONLY ONCE
threading.Thread(target=auto_publish_task, args=(client,), daemon=True).start()
# Mantiene il processo vivo
while True:
try:
logger.info("Attempting connection to MQTT broker...")
client.connect(cfg['mqtt']['broker'], cfg['mqtt']['port'], 60)
# 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__":
+8
View File
@@ -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
+8 -10
View File
@@ -4,14 +4,12 @@
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
@@ -19,7 +17,6 @@ Install dependencies:
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.
@@ -32,6 +29,10 @@ Steps:
------------------------------------------------------------
Required to enable browser and mobile notifications.
⚠️ WARNING: Web Push Notifications strictly require the
dashboard to be accessed via a secure HTTPS connection
(or localhost). They will NOT work over standard HTTP.
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").
@@ -40,7 +41,6 @@ Required to enable browser and mobile notifications.
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'.
@@ -50,7 +50,6 @@ To be installed on each Raspberry Pi / MMDVM Node.
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/
@@ -69,14 +68,12 @@ Configuration:
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
@@ -84,7 +81,6 @@ Installazione dipendenze:
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.
@@ -97,6 +93,10 @@ Passaggi:
------------------------------------------------------------
Necessarie per abilitare le notifiche su browser e mobile.
⚠️ ATTENZIONE: Le notifiche push richiedono tassativamente
che la dashboard sia accessibile tramite una connessione
sicura HTTPS. I browser bloccano la funzione su HTTP normale.
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").
@@ -105,7 +105,6 @@ Necessarie per abilitare le notifiche su browser e mobile.
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.
@@ -115,7 +114,6 @@ Da installare su ogni Raspberry Pi / Nodo MMDVM.
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/
+102 -137
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;
#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; }
}
.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;
}
}
body.dark-mode option { background: #0f172a; color: #f8fafc; }
</style>
</head>
<body>
@@ -162,7 +126,6 @@
<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>
@@ -460,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)';
@@ -585,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') || "";
@@ -716,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) => {
@@ -737,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');
}
});
}