7 Commits

Author SHA1 Message Date
iv3jdv 5fe69b5846 update images 2026-04-23 18:02:46 +02:00
iv3jdv d4b901410e feat: add D-Star source_ext support in dashboard and database 2026-04-23 17:43:34 +02:00
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
6 changed files with 239 additions and 226 deletions
+4
View File
@@ -38,6 +38,8 @@ The ecosystem consists of three main parts:
* Run: `python3 app.py` * Run: `python3 app.py`
#### 🔑 Generating VAPID Keys (Push Notifications) #### 🔑 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. 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/). 1. Go to a free online generator like [vapidkeys.com](https://vapidkeys.com/).
2. Generate the keys. 2. Generate the keys.
@@ -100,6 +102,8 @@ L'ecosistema si compone di tre parti principali:
* Avvia: `python3 app.py` * Avvia: `python3 app.py`
#### 🔑 Generare le chiavi VAPID (Notifiche Push) #### 🔑 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. 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/). 1. Vai su un generatore online gratuito come [vapidkeys.com](https://vapidkeys.com/).
2. Genera la coppia di chiavi. 2. Genera la coppia di chiavi.
+96 -73
View File
@@ -15,7 +15,7 @@ import logging
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
# ========================================== # ==========================================
# 0. CONFIGURAZIONE LOGGING & HARDWARE # 0. LOGGING & HARDWARE CONFIGURATION
# ========================================== # ==========================================
logging.basicConfig( logging.basicConfig(
handlers=[ handlers=[
@@ -33,48 +33,48 @@ try:
GPIO_AVAILABLE = True GPIO_AVAILABLE = True
except ImportError: except ImportError:
GPIO_AVAILABLE = False 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") CONFIG_PATH = Path("/opt/node_config.json")
def load_config(): def load_config():
try: try:
if not CONFIG_PATH.exists(): 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) sys.exit(1)
with open(CONFIG_PATH, 'r') as f: with open(CONFIG_PATH, 'r') as f:
return json.load(f) return json.load(f)
except Exception as e: except Exception as e:
logger.error(f"ERRORE CRITICO JSON: {e}") logger.error(f"CRITICAL JSON ERROR: {e}")
sys.exit(1) sys.exit(1)
cfg = load_config() cfg = load_config()
# Identificativi e Topic # Identifiers and Topics
CLIENT_ID = cfg.get('client_id', 'iv3jdv').lower() CLIENT_ID = cfg.get('client_id', 'iv3jdv').lower()
BASE_TOPIC = cfg.get('mqtt', {}).get('base_topic', f"servizi/{CLIENT_ID}") BASE_TOPIC = cfg.get('mqtt', {}).get('base_topic', f"servizi/{CLIENT_ID}")
TOPIC_CMD = f"{BASE_TOPIC}/cmnd" TOPIC_CMD = f"{BASE_TOPIC}/cmnd"
TOPIC_STAT = f"{BASE_TOPIC}/stat" TOPIC_STAT = f"{BASE_TOPIC}/stat"
# Variabili di Stato Globali # Global Status Variables
boot_recovered = False boot_recovered = False
current_status = "ONLINE - Pronto" current_status = "ONLINE"
auto_healing_counter = {} auto_healing_counter = {}
# ========================================== # ==========================================
# 2. FUNZIONE NOTIFICA TELEGRAM # 2. TELEGRAM NOTIFICATION FUNCTION
# ========================================== # ==========================================
def send_telegram_message(message): def send_telegram_message(message):
t_cfg = cfg.get('telegram', {}) t_cfg = cfg.get('telegram', {})
if not t_cfg.get('enabled', False): return if not t_cfg.get('enabled', False): return
ora_attuale = int(time.strftime("%H")) current_hour = int(time.strftime("%H"))
if ora_attuale >= 23 or ora_attuale < 7: if current_hour >= 23 or current_hour < 7:
logger.info(f"🌙 Notte fonda ({ora_attuale}:00): Notifica Telegram evitata.") logger.info(f"🌙 Late night ({current_hour}:00): Telegram notification skipped.")
return return
token = t_cfg.get('token') 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}"} payload = {"chat_id": chat_id, "text": f"[{CLIENT_ID.upper()}]\n{clean_msg}"}
requests.post(url, json=payload, timeout=10) requests.post(url, json=payload, timeout=10)
except Exception as e: 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(): 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): def switch_config(config_type):
profile = cfg.get('profiles', {}).get(config_type) profile = cfg.get('profiles', {}).get(config_type)
if not profile: 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', []) services = profile.get('services', [])
if not services: if not services:
return f"ERRORE: Nessun servizio configurato per {config_type}" return f"ERROR: No services configured for {config_type}"
try: try:
for s in services: for s in services:
@@ -113,28 +123,32 @@ def switch_config(config_type):
for s in services: for s in services:
if not os.path.exists(s['source']): 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']) shutil.copy(s['source'], s['target'])
for s in services: for s in services:
subprocess.run(["sudo", "systemctl", "start", s['name']], check=False) 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}" return f"ONLINE - {label}"
except Exception as e: except Exception as e:
return f"ERRORE: {str(e)}" return f"ERROR: {str(e)}"
def force_online_if_needed(client): def force_online_if_needed(client):
global boot_recovered, current_status global boot_recovered, current_status
if not boot_recovered: 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() current_status = get_actual_config_from_disk()
client.publish(TOPIC_STAT, current_status, retain=True) client.publish(TOPIC_STAT, current_status, retain=True)
boot_recovered = True boot_recovered = True
# ========================================== # ==========================================
# 4. TELEMETRIA E AUTO-HEALING # 4. TELEMETRY AND AUTO-HEALING
# ========================================== # ==========================================
def get_cpu_temperature(): def get_cpu_temperature():
temp = 0.0 temp = 0.0
@@ -157,8 +171,8 @@ def get_system_status():
"processes": {}, "processes": {},
"timestamp": time.strftime("%H:%M:%S"), "timestamp": time.strftime("%H:%M:%S"),
"profiles": { "profiles": {
"A": cfg.get('profiles', {}).get('A', {}).get('label', 'PROFILO A'), "A": cfg.get('profiles', {}).get('A', {}).get('label', 'PROFILE A'),
"B": cfg.get('profiles', {}).get('B', {}).get('label', 'PROFILO B') "B": cfg.get('profiles', {}).get('B', {}).get('label', 'PROFILE B')
} }
} }
proc_path = Path(cfg['paths'].get('process_list', '')) proc_path = Path(cfg['paths'].get('process_list', ''))
@@ -169,7 +183,7 @@ def get_system_status():
for name in target_processes: for name in target_processes:
name = name.strip().lower() name = name.strip().lower()
if name: status["processes"][name] = "online" if name in running_names else "offline" 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 return status
def check_auto_healing(client, 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) attempts = auto_healing_counter.get(proc_name, 0)
if attempts < 3: if attempts < 3:
auto_healing_counter[proc_name] = attempts + 1 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) client.publish(f"devices/{CLIENT_ID}/logs", msg)
send_telegram_message(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: 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: 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.setwarnings(False)
GPIO.setmode(GPIO.BCM) GPIO.setmode(GPIO.BCM)
GPIO.setup(RESET_PIN, GPIO.OUT) GPIO.setup(RESET_PIN, GPIO.OUT)
# Impulso LOW per resettare # LOW pulse to reset
GPIO.output(RESET_PIN, GPIO.LOW) GPIO.output(RESET_PIN, GPIO.LOW)
time.sleep(0.5) time.sleep(0.5)
GPIO.output(RESET_PIN, GPIO.HIGH) GPIO.output(RESET_PIN, GPIO.HIGH)
GPIO.cleanup(RESET_PIN) GPIO.cleanup(RESET_PIN)
# Diamo tempo al microcontrollore di riavviarsi # Give the microcontroller time to restart
time.sleep(1.5) 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: except Exception as e:
logger.error(f"Errore GPIO in auto-healing: {e}") logger.error(f"GPIO error in auto-healing: {e}")
# --- FINE MODIFICA --- # --- END MODIFICATION ---
subprocess.run(["sudo", "systemctl", "restart", proc_name]) subprocess.run(["sudo", "systemctl", "restart", proc_name])
elif attempts == 3: elif attempts == 3:
msg = f"🚨 CRITICO: {proc_name} fallito!" msg = f"🚨 CRITICAL: {proc_name} failed!"
client.publish(f"devices/{CLIENT_ID}/logs", msg) client.publish(f"devices/{CLIENT_ID}/logs", msg)
send_telegram_message(msg) send_telegram_message(msg)
auto_healing_counter[proc_name] = 4 auto_healing_counter[proc_name] = 4
@@ -221,9 +235,9 @@ def publish_all(client):
if file_list_path.exists(): if file_list_path.exists():
try: try:
files = file_list_path.read_text(encoding="utf-8").splitlines() files = file_list_path.read_text(encoding="utf-8").splitlines()
nomi_estrattti = [Path(f.strip()).stem for f in files if f.strip()] extracted_names = [Path(f.strip()).stem for f in files if f.strip()]
status["config_files"] = nomi_estrattti status["config_files"] = extracted_names
status["files"] = nomi_estrattti status["files"] = extracted_names
except: pass except: pass
client.publish(f"devices/{CLIENT_ID}/services", json.dumps(status), qos=1) 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: with open(file_list_path, 'r') as f:
files_to_parse = [line.strip() for line in f if line.strip()] files_to_parse = [line.strip() for line in f if line.strip()]
except Exception as e: except Exception as e:
logger.error(f"Errore lettura {file_list_path}: {e}") logger.error(f"Error reading {file_list_path}: {e}")
return return
for file_path in files_to_parse: 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}" topic = f"data/{CLIENT_ID}/{base_name}/{section}"
client.publish(topic, json.dumps(payload), retain=True) client.publish(topic, json.dumps(payload), retain=True)
except Exception as e: 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): def write_config_from_json(slug, json_payload):
file_list_path = Path(cfg['paths'].get('file_list', '')) 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: for f in files:
p = Path(f.strip()) p = Path(f.strip())
if p.stem.lower() == slug.lower(): if p.stem.lower() == slug.lower():
nuovi_dati = json.loads(json_payload) new_data = json.loads(json_payload)
shutil.copy(p, str(p) + ".bak") 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}") os.system(f"sudo systemctl restart {slug}")
send_telegram_message(f"📝 Config {slug.upper()} aggiornata via Web.") send_telegram_message(f"📝 Config {slug.upper()} updated via Web.")
logger.info(f"Configurazione {slug} aggiornata con successo.") logger.info(f"Configuration {slug} updated successfully.")
break 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): def on_connect(client, userdata, flags, reason_code, properties=None):
if reason_code == 0: 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([(TOPIC_CMD, 0), (TOPIC_STAT, 0)])
client.subscribe([ client.subscribe([
("devices/control/request", 0), ("devices/control/request", 0),
@@ -310,12 +324,11 @@ def on_connect(client, userdata, flags, reason_code, properties=None):
publish_all(client) publish_all(client)
publish_all_ini_files(client) publish_all_ini_files(client)
else: 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): def on_disconnect(client, userdata, disconnect_flags, reason_code, properties=None):
logger.warning(f"⚠️ Disconnessione dal broker MQTT! Codice: {reason_code}") logger.warning(f"⚠️ Disconnected from MQTT broker! Code: {reason_code}")
logger.error("Forzo il riavvio del processo per ripristinare la rete in modo pulito...") logger.info("Waiting for network return. Paho-MQTT will attempt automatic reconnection...")
os._exit(1) # Uccide lo script immediatamente (Systemd lo farà risorgere)
def on_message(client, userdata, msg): def on_message(client, userdata, msg):
global boot_recovered, current_status, cfg global boot_recovered, current_status, cfg
@@ -323,7 +336,7 @@ def on_message(client, userdata, msg):
topic = msg.topic topic = msg.topic
if topic == TOPIC_STAT and not boot_recovered: 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 current_status = payload
boot_recovered = True boot_recovered = True
client.publish(TOPIC_STAT, current_status, retain=True) client.publish(TOPIC_STAT, current_status, retain=True)
@@ -336,8 +349,8 @@ def on_message(client, userdata, msg):
boot_recovered = True boot_recovered = True
publish_all(client) publish_all(client)
elif cmd == "REBOOT": elif cmd == "REBOOT":
client.publish(TOPIC_STAT, f"OFFLINE - Riavvio {CLIENT_ID.upper()}...", retain=False) client.publish(TOPIC_STAT, f"OFFLINE - Rebooting {CLIENT_ID.upper()}...", retain=False)
logger.info("Comando REBOOT ricevuto. Riavvio sistema...") logger.info("REBOOT command received. Rebooting system...")
time.sleep(1) time.sleep(1)
subprocess.run(["sudo", "reboot"], check=True) subprocess.run(["sudo", "reboot"], check=True)
elif cmd == 'RESET_HAT': elif cmd == 'RESET_HAT':
@@ -351,28 +364,31 @@ def on_message(client, userdata, msg):
time.sleep(0.5) time.sleep(0.5)
GPIO.output(RESET_PIN, GPIO.HIGH) GPIO.output(RESET_PIN, GPIO.HIGH)
GPIO.cleanup(RESET_PIN) 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) time.sleep(1.5)
logger.info("Riavvio di MMDVMHost in corso...") logger.info("Restarting MMDVMHost...")
subprocess.run(["sudo", "systemctl", "restart", "mmdvmhost"], check=False) subprocess.run(["sudo", "systemctl", "restart", "mmdvmhost"], check=False)
client.publish(f"fleet/{CLIENT_ID}/status", "HAT RESET + MMDVM RESTART OK") client.publish(f"fleet/{CLIENT_ID}/status", "HAT RESET + MMDVM RESTART OK")
client.publish(f"devices/{CLIENT_ID}/logs", "🔌 HAT Reset + MMDVMHost Restarted") client.publish(f"devices/{CLIENT_ID}/logs", "🔌 HAT Reset + MMDVMHost Restarted")
except Exception as e: except Exception as e:
logger.error(f"Errore durante il reset GPIO/MMDVMHost: {e}") logger.error(f"Error during GPIO/MMDVMHost reset: {e}")
client.publish(f"fleet/{CLIENT_ID}/status", f"ERRORE RESET: {e}") client.publish(f"fleet/{CLIENT_ID}/status", f"RESET ERROR: {e}")
elif cmd in ["TG:OFF", "TG:ON"]: elif cmd in ["TG:OFF", "TG:ON"]:
nuovo_stato = (cmd == "TG:ON") new_state = (cmd == "TG:ON")
cfg['telegram']['enabled'] = nuovo_stato cfg['telegram']['enabled'] = new_state
try: try:
with open(CONFIG_PATH, 'w') as f: json.dump(cfg, f, indent=4) 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'}") client.publish(f"devices/{CLIENT_ID}/logs", f"{'🔔' if new_state else '🔇'} Notifications {'ON' if new_state else 'OFF'}")
if nuovo_stato: send_telegram_message("Notifiche riattivate!") if new_state: send_telegram_message("Notifications reactivated!")
except Exception as e: logger.error(f"Errore salvataggio stato Telegram: {e}") except Exception as e: logger.error(f"Error saving Telegram status: {e}")
elif topic == "devices/control/request" and payload.lower() in ["status", "update"]: elif topic == "devices/control/request" and payload.lower() in ["status", "update"]:
logger.info("📥 Received global update command (REQ CONFIG)")
publish_all(client) publish_all(client)
publish_all_ini_files(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": elif topic == f"devices/{CLIENT_ID}/control":
if ":" in payload: if ":" in payload:
@@ -381,11 +397,11 @@ def on_message(client, userdata, msg):
try: try:
subprocess.run(["sudo", "systemctl", action.lower(), service.lower()], check=True) subprocess.run(["sudo", "systemctl", action.lower(), service.lower()], check=True)
client.publish(f"devices/{CLIENT_ID}/logs", f"{action.upper()}: {service}") 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) publish_all(client)
except Exception as e: except Exception as e:
client.publish(f"devices/{CLIENT_ID}/logs", f"❌ ERROR: {str(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/"): elif topic.startswith(f"devices/{CLIENT_ID}/config_set/"):
slug = topic.split("/")[-1] slug = topic.split("/")[-1]
@@ -402,6 +418,8 @@ def auto_publish_task(client):
time.sleep(cfg['settings'].get('update_interval', 30)) time.sleep(cfg['settings'].get('update_interval', 30))
def start_service(): 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 = 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.will_set(TOPIC_STAT, payload=f"OFFLINE - {CLIENT_ID.upper()}", qos=1, retain=False)
client.username_pw_set(cfg['mqtt']['user'], cfg['mqtt']['password']) client.username_pw_set(cfg['mqtt']['user'], cfg['mqtt']['password'])
@@ -409,19 +427,24 @@ def start_service():
client.on_disconnect = on_disconnect client.on_disconnect = on_disconnect
client.on_message = on_message client.on_message = on_message
while True: # 1. Start the telemetry "engine" ONLY ONCE
try:
logger.info("Tentativo di connessione al broker MQTT...")
client.connect(cfg['mqtt']['broker'], cfg['mqtt']['port'], 60)
client.loop_start()
threading.Thread(target=auto_publish_task, args=(client,), daemon=True).start() 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: while True:
time.sleep(1) time.sleep(1)
except Exception as e: 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) time.sleep(10)
if __name__ == "__main__": if __name__ == "__main__":
+18 -6
View File
@@ -47,6 +47,10 @@ def init_db():
c.execute("ALTER TABLE radio_logs ADD COLUMN protocol TEXT DEFAULT 'DMR'") c.execute("ALTER TABLE radio_logs ADD COLUMN protocol TEXT DEFAULT 'DMR'")
except: pass except: pass
try:
c.execute("ALTER TABLE radio_logs ADD COLUMN source_ext TEXT DEFAULT ''")
except: pass
c.execute('''CREATE TABLE IF NOT EXISTS users c.execute('''CREATE TABLE IF NOT EXISTS users
(id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, password_hash TEXT, (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, password_hash TEXT,
role TEXT, allowed_nodes TEXT)''') role TEXT, allowed_nodes TEXT)''')
@@ -122,8 +126,8 @@ def save_cache(data):
def save_to_sqlite(client_id, data, protocol="DMR"): def save_to_sqlite(client_id, data, protocol="DMR"):
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
c = conn.cursor() c = conn.cursor()
c.execute("INSERT INTO radio_logs (timestamp, client_id, protocol, source_id, target, slot, duration, ber) VALUES (datetime('now', 'localtime'), ?, ?, ?, ?, ?, ?, ?)", c.execute("INSERT INTO radio_logs (timestamp, client_id, protocol, source_id, target, slot, duration, ber, source_ext) VALUES (datetime('now', 'localtime'), ?, ?, ?, ?, ?, ?, ?, ?)",
(client_id, protocol, str(data.get('source_id', '---')), str(data.get('destination_id', '---')), data.get('slot', 0), round(data.get('duration', 0), 1), round(data.get('ber', 0), 2))) (client_id, protocol, str(data.get('source_id', '---')), str(data.get('destination_id', '---')), data.get('slot', 0), round(data.get('duration', 0), 1), round(data.get('ber', 0), 2), str(data.get('source_ext', ''))))
conn.commit() conn.commit()
conn.close() conn.close()
socketio.emit('dati_aggiornati') socketio.emit('dati_aggiornati')
@@ -345,13 +349,13 @@ def on_message(client, userdata, msg):
else: else:
target = current_target target = current_target
active_calls[cid][k] = {'src': src, 'dst': target} active_calls[cid][k] = {'src': src, 'dst': target, 'ext': str(p.get('source_ext', ''))}
client_telemetry[cid].update({"ts1":"","ts2":"","alt": f"{ico} {name}: {src}{target}"}) client_telemetry[cid].update({"ts1":"","ts2":"","alt": f"{ico} {name}: {src}{target}"})
socketio.emit('dati_aggiornati') # <--- WEBSOCKET socketio.emit('dati_aggiornati') # <--- WEBSOCKET
elif act in ['end', 'lost']: elif act in ['end', 'lost']:
info = active_calls[cid].get(k, {'src': '---', 'dst': '---'}) info = active_calls[cid].get(k, {'src': '---', 'dst': '---', 'ext': ''})
p.update({'source_id': info['src'], 'destination_id': info['dst']}) p.update({'source_id': info['src'], 'destination_id': info['dst'], 'source_ext': info['ext']})
save_to_sqlite(cid, p, protocol=name) save_to_sqlite(cid, p, protocol=name)
client_telemetry[cid]["alt"] = f"{'' if act=='end' else '⚠️'} {name}: {info['src']}" client_telemetry[cid]["alt"] = f"{'' if act=='end' else '⚠️'} {name}: {info['src']}"
save_cache(client_telemetry) save_cache(client_telemetry)
@@ -381,7 +385,7 @@ def get_clients():
def get_logs(): def get_logs():
conn = sqlite3.connect(DB_PATH, timeout=10) conn = sqlite3.connect(DB_PATH, timeout=10)
c = conn.cursor() c = conn.cursor()
c.execute("SELECT timestamp, client_id, protocol, source_id, target, slot, duration, ber FROM radio_logs ORDER BY id DESC LIMIT 60") c.execute("SELECT timestamp, client_id, protocol, source_id, target, slot, duration, ber, source_ext FROM radio_logs ORDER BY id DESC LIMIT 60")
logs = c.fetchall() logs = c.fetchall()
conn.close() conn.close()
return jsonify(logs) return jsonify(logs)
@@ -459,6 +463,14 @@ def update_nodes():
mqtt_backend.publish("devices/control/request", "update") mqtt_backend.publish("devices/control/request", "update")
return jsonify({"success": True}) 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']) @app.route('/api/users', methods=['GET'])
def get_users(): def get_users():
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403 if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 88 KiB

+8 -10
View File
@@ -4,14 +4,12 @@
This guide describes the steps to install the Central This guide describes the steps to install the Central
Dashboard and the Remote Agents on MMDVM nodes. Dashboard and the Remote Agents on MMDVM nodes.
------------------------------------------------------------ ------------------------------------------------------------
1. PRE-REQUISITES 1. PRE-REQUISITES
------------------------------------------------------------ ------------------------------------------------------------
Ensure Python 3 is installed on all systems. Ensure Python 3 is installed on all systems.
The necessary dependencies are listed in the The necessary dependencies are listed in the
'requirements.txt' file. 'requirements.txt' file.
Install dependencies: Install dependencies:
pip install -r requirements.txt pip install -r requirements.txt
@@ -19,7 +17,6 @@ Install dependencies:
2. SERVER SETUP (CENTRAL HUB) 2. SERVER SETUP (CENTRAL HUB)
------------------------------------------------------------ ------------------------------------------------------------
The server handles the web interface and user permissions. The server handles the web interface and user permissions.
Steps: Steps:
1. Configure 'config.json' using 'config.example.json' as a template. 1. Configure 'config.json' using 'config.example.json' as a template.
2. Enter MQTT credentials and VAPID keys. 2. Enter MQTT credentials and VAPID keys.
@@ -32,6 +29,10 @@ Steps:
------------------------------------------------------------ ------------------------------------------------------------
Required to enable browser and mobile notifications. 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. 1. Go to https://vapidkeys.com/ and generate the keys.
2. Copy 'Public Key' and 'Private Key' into 'config.json'. 2. Copy 'Public Key' and 'Private Key' into 'config.json'.
3. Set 'vapid_claim_email' (e.g., "mailto:your@email.com"). 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) 4. AGENT SETUP (REMOTE NODES)
------------------------------------------------------------ ------------------------------------------------------------
To be installed on each Raspberry Pi / MMDVM Node. To be installed on each Raspberry Pi / MMDVM Node.
1. Copy 'system_monitor.py' and 'node_config.json' to 1. Copy 'system_monitor.py' and 'node_config.json' to
'/opt/node_agent/'. '/opt/node_agent/'.
2. Edit 'node_config.json' with a unique 'client_id'. 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) 5. RUNNING AS A SERVICE (SYSTEMD)
------------------------------------------------------------ ------------------------------------------------------------
For auto-start and process monitoring. For auto-start and process monitoring.
Configuration: Configuration:
1. Copy .service files to '/etc/systemd/system/': 1. Copy .service files to '/etc/systemd/system/':
- Server: sudo cp fleet-console.service /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 Questa guida descrive i passaggi per installare la Dashboard
Centrale e gli Agenti Remoti sui nodi MMDVM. Centrale e gli Agenti Remoti sui nodi MMDVM.
------------------------------------------------------------ ------------------------------------------------------------
1. REQUISITI PRELIMINARI 1. REQUISITI PRELIMINARI
------------------------------------------------------------ ------------------------------------------------------------
Assicurarsi di avere Python 3 installato su tutti i sistemi. Assicurarsi di avere Python 3 installato su tutti i sistemi.
Le dipendenze necessarie sono elencate nel file Le dipendenze necessarie sono elencate nel file
'requirements.txt'. 'requirements.txt'.
Installazione dipendenze: Installazione dipendenze:
pip install -r requirements.txt pip install -r requirements.txt
@@ -84,7 +81,6 @@ Installazione dipendenze:
2. SETUP DEL SERVER (HUB CENTRALE) 2. SETUP DEL SERVER (HUB CENTRALE)
------------------------------------------------------------ ------------------------------------------------------------
Il server gestisce l'interfaccia web e i permessi. Il server gestisce l'interfaccia web e i permessi.
Passaggi: Passaggi:
1. Configura 'config.json' partendo da 'config.example.json'. 1. Configura 'config.json' partendo da 'config.example.json'.
2. Inserisci le credenziali MQTT e le chiavi VAPID. 2. Inserisci le credenziali MQTT e le chiavi VAPID.
@@ -97,6 +93,10 @@ Passaggi:
------------------------------------------------------------ ------------------------------------------------------------
Necessarie per abilitare le notifiche su browser e mobile. 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. 1. Vai su https://vapidkeys.com/ e genera le chiavi.
2. Copia 'Public Key' e 'Private Key' nel 'config.json'. 2. Copia 'Public Key' e 'Private Key' nel 'config.json'.
3. Imposta 'vapid_claim_email' (es. "mailto:tua@email.com"). 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) 4. SETUP DELL'AGENTE (NODI REMOTI)
------------------------------------------------------------ ------------------------------------------------------------
Da installare su ogni Raspberry Pi / Nodo MMDVM. Da installare su ogni Raspberry Pi / Nodo MMDVM.
1. Copia 'system_monitor.py' e 'node_config.json' in 1. Copia 'system_monitor.py' e 'node_config.json' in
'/opt/node_agent/'. '/opt/node_agent/'.
2. Modifica 'node_config.json' con il 'client_id' univoco. 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) 5. ESECUZIONE COME SERVIZIO (SYSTEMD)
------------------------------------------------------------ ------------------------------------------------------------
Per l'avvio automatico e il monitoraggio del processo. Per l'avvio automatico e il monitoraggio del processo.
Configurazione: Configurazione:
1. Copia i file .service in '/etc/systemd/system/': 1. Copia i file .service in '/etc/systemd/system/':
- Server: sudo cp fleet-console.service /etc/systemd/system/ - Server: sudo cp fleet-console.service /etc/systemd/system/
+114 -138
View File
@@ -13,146 +13,110 @@
<meta name="apple-mobile-web-app-title" content="Fleet C2"> <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"> <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 { :root {
--primary: #3b82f6; --success: #10b981; --danger: #ef4444; --accent: #8b5cf6; --primary: #2f81f7; --success: #2ea043; --danger: #da3633; --accent: #a371f7;
--bg-gradient: linear-gradient(135deg, #e0e7ff 0%, #ede9fe 100%); --bg-gradient: #0d1117; /* Sfondo nero/grigio profondissimo */
--card-bg: rgba(255, 255, 255, 0.6); --card-bg: #161b22; /* Sfondo grigio antracite per i pannelli */
--border-color: rgba(255, 255, 255, 0.8); --border-color: #30363d;/* Bordi netti e sottili */
--text-main: #1e293b; --text-muted: #64748b; --text-main: #c9d1d9; /* Bianco attenuato anti-affaticamento */
--glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07); --text-muted: #8b949e; /* Grigio tecnico per label */
--topbar-bg: rgba(255, 255, 255, 0.8); --topbar-bg: #161b22;
} }
/* Modalità scura "pura" se il toggle viene premuto */
body.dark-mode { body.dark-mode {
--bg-gradient: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #020617 100%); --bg-gradient: #010409; --card-bg: #0d1117; --border-color: #21262d;
--card-bg: rgba(30, 41, 59, 0.5); --text-main: #f0f6fc; --topbar-bg: #0d1117;
--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);
} }
* { box-sizing: border-box; } * { 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-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; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; max-width: 1400px; margin: 30px auto; padding: 0 20px; }
.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: 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-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid var(--border-color); padding-bottom: 10px; }
.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; } .client-name { font-size: 1.1rem; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
.card:hover { transform: translateY(-4px); box-shadow: 0 12px 40px 0 rgba(0,0,0,0.15); } .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.online { border-top-color: var(--success); }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; } /* Telemetria stile terminale */
.client-name { font-size: 1.25rem; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; } .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; }
.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); }
.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); } /* Display Stato */
body.dark-mode .health-bar { background: rgba(0,0,0,0.2); } .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; } /* TimeSlots Override */
body.dark-mode .status-display { background: rgba(0,0,0,0.2); color: #10b981; } .ts-container { display: flex; flex-direction: column; gap: 6px; margin-bottom: 15px; }
.status-offline { color: var(--danger) !important; opacity: 0.8; } .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 */ /* Terminal Log interno ai pannelli */
.ts-container { display: flex; flex-direction: column; gap: 8px; margin-bottom: 15px; } .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); }
.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 */ /* Bottoni flat */
.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); } .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 */ /* Tabella LastHeard */
.actions { display: none; gap: 10px; flex-wrap: wrap; margin-top: auto; } .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; }
.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; } table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.85rem; font-family: 'JetBrains Mono', monospace; }
.btn-cmd:hover { transform: translateY(-2px); filter: brightness(1.1); box-shadow: 0 6px 12px rgba(0,0,0,0.15); } thead { background: var(--topbar-bg); position: sticky; top: 0; z-index: 1; border-bottom: 1px solid var(--border-color); }
.btn-cmd:active { transform: translateY(0); } 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; } /* Animazioni Uniformate per i transiti */
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.9rem; } @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 */ /* La vecchia classe blink la teniamo solo per gli allarmi rossi dei demoni KO */
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); } @keyframes flat-blink { 0% { border-color: var(--border-color); } 50% { border-color: var(--danger); } 100% { border-color: var(--border-color); } }
body.dark-mode thead { background: rgba(15, 23, 42, 0.95); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.3); } .blink { animation: flat-blink 1.5s infinite; color: var(--danger) !important; }
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); }
@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); } } /* Finestre Modali (Popup) */
.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; } .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; }
@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); } } .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); }
.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; }
/* Modals (Dark Glass) */ /* Elementi Input e Form */
.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; } .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; }
.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; } .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 */ /* Ottimizzazione Mobile */
.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) --- */
@media (max-width: 768px) { @media (max-width: 768px) {
#top-bar-container { top: 10px; padding: 0 10px; } #top-bar-container { top: 10px; padding: 0 10px; }
#top-bar { #top-bar { flex-direction: column; padding: 15px; gap: 12px; border-radius: 8px; }
flex-direction: column; .title-brand { font-size: 1.1rem; text-align: center; width: 100%; }
border-radius: 24px; #top-bar > div { width: 100%; flex-wrap: wrap; justify-content: center; gap: 8px !important; }
padding: 15px; #auth-container { width: 100%; justify-content: center; flex-wrap: wrap; padding-top: 12px; border-top: 1px solid var(--border-color); }
gap: 12px; #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> </style>
</head> </head>
<body> <body>
@@ -162,7 +126,6 @@
<div class="title-brand">FLEET CONTROL CONSOLE</div> <div class="title-brand">FLEET CONTROL CONSOLE</div>
<div style="display: flex; align-items: center; gap: 12px;"> <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="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> <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 id="auth-container" style="display:flex; align-items:center; gap:8px;"></div>
</div> </div>
@@ -460,12 +423,6 @@
let currentResetHatId = null; let currentResetHatId = null;
let confirmActionCallback = 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 --- // --- NEW GLOBAL GLASSMORPHISM POPUPS ---
function customAlert(title, desc, isError = false) { function customAlert(title, desc, isError = false) {
const color = isError ? 'var(--danger)' : 'var(--success)'; const color = isError ? 'var(--danger)' : 'var(--success)';
@@ -585,11 +542,6 @@
const grid = document.getElementById('client-grid'); const grid = document.getElementById('client-grid');
const authContainer = document.getElementById('auth-container'); 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 role = sessionStorage.getItem('user_role');
const allowed = sessionStorage.getItem('allowed_nodes') || ""; const allowed = sessionStorage.getItem('allowed_nodes') || "";
@@ -716,18 +668,31 @@
if (altDiv) { if (altDiv) {
altDiv.style.display = "block"; altDiv.innerText = telemetryObj.alt; altDiv.style.display = "block"; altDiv.innerText = telemetryObj.alt;
let altText = telemetryObj.alt.toUpperCase(); 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("🟠"); isTx = altText.includes("🟢") || altText.includes("🟣") || altText.includes("🔵") || altText.includes("🟠");
altDiv.style.setProperty('color', activeModeColor, 'important');
altDiv.style.setProperty('border-left', `4px solid ${activeModeColor}`, 'important'); // Passiamo il colore al CSS per l'animazione
if (isTx) { altDiv.classList.add('blink'); } else { altDiv.classList.remove('blink'); } 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 { } else {
if (altDiv) altDiv.style.display = "none"; if (altDiv) altDiv.style.display = "none";
if (tsContainer) tsContainer.style.display = "flex"; if (tsContainer) tsContainer.style.display = "flex";
let netObj = data.networks && data.networks[c.id.toLowerCase()] ? data.networks[c.id.toLowerCase()] : {ts1: "", ts2: ""}; 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) { if (ts1Div && ts2Div) {
[ts1Div, ts2Div].forEach((div, idx) => { [ts1Div, ts2Div].forEach((div, idx) => {
@@ -737,15 +702,15 @@
const fullLabel = netName ? `${baseLabel} [${netName}]` : baseLabel; const fullLabel = netName ? `${baseLabel} [${netName}]` : baseLabel;
div.innerText = `${fullLabel}: ${val}`; div.innerText = `${fullLabel}: ${val}`;
div.style.setProperty('--pulse-color', activeModeColor);
if (val.includes("🎙️")) { if (val.includes("🎙️")) {
isTx = true; isTx = true;
div.classList.add('tx-active'); div.classList.add('tx-active-unified');
} else { } else {
div.classList.remove('tx-active'); div.classList.remove('tx-active-unified');
div.style.setProperty('color', 'var(--text-main)', 'important'); div.style.setProperty('color', 'var(--text-muted)', 'important');
div.style.setProperty('border-left-color', 'var(--primary)', 'important'); div.style.setProperty('border-left', '4px solid var(--border-color)', 'important');
div.style.setProperty('background', 'rgba(59, 130, 246, 0.1)', 'important');
} }
}); });
} }
@@ -830,7 +795,19 @@
const res = await fetch('/api/logs'); const logs = await res.json(); const res = await fetch('/api/logs'); const logs = await res.json();
const tbody = document.getElementById('log-body'); let tableHTML = ""; let networkLogs = {}; const tbody = document.getElementById('log-body'); let tableHTML = ""; let networkLogs = {};
logs.forEach(row => { logs.forEach(row => {
const time = row[0] ? row[0].split(' ')[1] : "--:--"; const clientId = row[1]; const protocol = row[2] || "DMR"; const source = row[3] || "---"; const target = row[4] || "---"; const rawSlot = row[5]; const time = row[0] ? row[0].split(' ')[1] : "--:--";
const clientId = row[1];
const protocol = row[2] || "DMR";
let source = row[3] || "---";
const target = row[4] || "---";
const rawSlot = row[5];
const source_ext = row[8]; // <--- NUOVO CAMPO DAL BACKEND
// Formattiamo il source_ext se presente e non vuoto
if (source_ext && source_ext.trim() !== "") {
source = `${source} <span style="font-size:0.8em; color:#94a3b8; font-family:'JetBrains Mono', monospace;">/${source_ext}</span>`;
}
const slotDisplay = protocol === "DMR" ? `TS${rawSlot}` : "--"; const slotDisplay = protocol === "DMR" ? `TS${rawSlot}` : "--";
let protoColor = "#3b82f6"; if (protocol === "NXDN") protoColor = "#10b981"; else if (protocol === "YSF") protoColor = "#8b5cf6"; else if (protocol === "D-STAR") protoColor = "#06b6d4"; else if (protocol === "P25") protoColor = "#f59e0b"; let protoColor = "#3b82f6"; if (protocol === "NXDN") protoColor = "#10b981"; else if (protocol === "YSF") protoColor = "#8b5cf6"; else if (protocol === "D-STAR") protoColor = "#06b6d4"; else if (protocol === "P25") protoColor = "#f59e0b";
@@ -845,7 +822,6 @@
clients.forEach(c => { const localDiv = document.getElementById(`sys-log-${c.id}`); if (localDiv && networkLogs[c.id]) { localDiv.innerHTML = networkLogs[c.id]; } }); clients.forEach(c => { const localDiv = document.getElementById(`sys-log-${c.id}`); if (localDiv && networkLogs[c.id]) { localDiv.innerHTML = networkLogs[c.id]; } });
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
// --- USER MANAGEMENT (ADD & EDIT) --- // --- USER MANAGEMENT (ADD & EDIT) ---
async function openAdmin() { document.getElementById('admin-modal').style.display = 'flex'; loadUsers(); loadSettings(); cancelEdit(); } async function openAdmin() { document.getElementById('admin-modal').style.display = 'flex'; loadUsers(); loadSettings(); cancelEdit(); }
function closeAdmin() { document.getElementById('admin-modal').style.display = 'none'; cancelEdit(); } function closeAdmin() { document.getElementById('admin-modal').style.display = 'none'; cancelEdit(); }