diff --git a/README.md b/README.md index ee05ca7..94647ee 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ The ecosystem consists of three main parts: ### โœจ Features * **Zero-Latency Real-Time UI:** Powered by WebSockets (Socket.IO), the dashboard updates instantly upon radio traffic or telemetry changes, completely eliminating heavy HTTP polling overhead. +* **Web Push Notifications:** Get instant alerts directly on your desktop or mobile device when a node goes offline, comes back online, or a critical service fails (even when the app is closed or in the background). * **Centralized Telemetry:** Real-time CPU, RAM, Temperature, and Disk usage for all nodes. * **Service Management:** Start, Stop, or Restart system daemons (MMDVMHost, DMRGateway, etc.) remotely. * **Smart Auto-Healing:** The agent automatically detects crashed services and attempts to revive them before raising critical alerts (includes Telegram notifications). @@ -31,8 +32,8 @@ The ecosystem consists of three main parts: ### ๐Ÿš€ Installation & Setup #### 1. Server Setup (Central Hub) -* Install dependencies: `pip install flask flask-socketio paho-mqtt psutil werkzeug` -* Configure `config.json` (use `config.example.json` as template) with your MQTT credentials. +* Install dependencies: `pip install flask flask-socketio paho-mqtt psutil werkzeug pywebpush` +* Configure `config.json` (use `config.example.json` as template) with your MQTT and WebPush VAPID credentials. * Define your repeaters in `clients.json`. * Run: `python3 app.py` @@ -60,6 +61,7 @@ L'ecosistema si compone di tre parti principali: ### โœจ Funzionalitร  * **Interfaccia Real-Time a Latenza Zero:** Grazie all'integrazione di WebSockets (Socket.IO), la dashboard scatta all'istante al passaggio di traffico radio o ai cambi di telemetria, eliminando totalmente il carico del polling HTTP continuo. +* **Notifiche Push Web:** Ricevi avvisi immediati su desktop o smartphone quando un nodo va offline, torna operativo o un servizio critico si blocca (anche quando l'app รจ chiusa o in background). * **Telemetria Centralizzata:** Stato in tempo reale di CPU, RAM, Temperatura e Disco di tutti i nodi. * **Gestione Servizi:** Avvio, arresto o riavvio dei demoni di sistema (MMDVMHost, DMRGateway, ecc.) da remoto. * **Auto-Healing Intelligente:** L'agente rileva automaticamente i servizi andati in blocco e tenta di rianimarli prima di inviare allarmi critici (include notifiche Telegram). @@ -72,8 +74,8 @@ L'ecosistema si compone di tre parti principali: ### ๐Ÿš€ Installazione e Configurazione #### 1. Setup del Server (Hub Centrale) -* Installa le dipendenze: `pip install flask flask-socketio paho-mqtt psutil werkzeug` -* Configura `config.json` (usa `config.example.json` come base) con le credenziali MQTT. +* Installa le dipendenze: `pip install flask flask-socketio paho-mqtt psutil werkzeug pywebpush` +* Configura `config.json` (usa `config.example.json` come base) con le credenziali MQTT e le chiavi VAPID per le notifiche Push. * Definisci i tuoi ripetitori nel file `clients.json`. * Avvia: `python3 app.py` diff --git a/app.py b/app.py index 2d6c11d..bc54f2c 100644 --- a/app.py +++ b/app.py @@ -8,6 +8,7 @@ import urllib.request import threading import time import logging +from pywebpush import webpush, WebPushException from logging.handlers import RotatingFileHandler from flask_socketio import SocketIO, emit @@ -50,6 +51,9 @@ def init_db(): (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, password_hash TEXT, role TEXT, allowed_nodes TEXT)''') + c.execute('''CREATE TABLE IF NOT EXISTS push_subscriptions + (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, subscription TEXT UNIQUE)''') + c.execute('''CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME, username TEXT, client_id TEXT, command TEXT)''') @@ -117,6 +121,7 @@ app.secret_key = 'ari_fvg_secret_ultra_secure' client_states = {} device_configs = {} client_telemetry = {} +last_notified_errors = {} device_health = {} last_seen_reflector = {} network_mapping = {} @@ -171,15 +176,28 @@ def on_message(client, userdata, msg): except Exception as e: logger.error(f"Errore parsing config JSON: {e}") + # --- GESTIONE STATI SERVIZIO E NODO --- elif parts[0] == 'servizi': client_states[cid] = payload socketio.emit('dati_aggiornati') # <--- WEBSOCKET + + # --- GRILLETTO PUSH: STATO NODO --- + if payload.upper() == 'OFFLINE': + if last_notified_errors.get(f"{cid}_NODE") != 'OFFLINE': + broadcast_push_notification(f"๐Ÿ’€ NODO OFFLINE: {cid.upper()}", "Disconnesso dal broker.") + last_notified_errors[f"{cid}_NODE"] = 'OFFLINE' + elif payload.upper() == 'ONLINE': + if last_notified_errors.get(f"{cid}_NODE") == 'OFFLINE': + broadcast_push_notification(f"๐ŸŒค๏ธ NODO ONLINE: {cid.upper()}", "Tornato operativo.") + del last_notified_errors[f"{cid}_NODE"] + if payload.upper() not in ['OFF', 'OFFLINE', '']: tel = client_telemetry.get(cid, {}) if isinstance(tel, dict) and '๐Ÿ”„' in str(tel.get('ts1', '')): client_telemetry[cid] = {"ts1": "In attesa...", "ts2": "In attesa...", "alt": ""} save_cache(client_telemetry) + # --- GESTIONE SALUTE DISPOSITIVI --- elif parts[0] == 'devices' and len(parts) >= 3 and parts[2] == 'services': try: data = json.loads(payload) @@ -193,8 +211,27 @@ def on_message(client, userdata, msg): "profiles": data.get("profiles", {"A": "PROFILO A", "B": "PROFILO B"}) } socketio.emit('dati_aggiornati') # <--- WEBSOCKET - except Exception as e: logger.error(f"Errore parsing health: {e}") + # --- GRILLETTO PUSH: SERVIZI IN ERRORE --- + processes = data.get("processes", {}) + for svc_name, svc_status in processes.items(): + status_key = f"{cid}_{svc_name}" + s_lower = svc_status.lower() + if s_lower in ["error", "stopped", "failed"]: + if last_notified_errors.get(status_key) != s_lower: + msg_err = f"Servizio {svc_name} KO ({svc_status})" + if s_lower == "error": msg_err += " - Auto-healing fallito! โš ๏ธ" + broadcast_push_notification(f"๐Ÿšจ ALLARME: {cid.upper()}", msg_err) + last_notified_errors[status_key] = s_lower + elif s_lower == "online" and status_key in last_notified_errors: + broadcast_push_notification(f"โœ… RIPRISTINO: {cid.upper()}", f"Servizio {svc_name} tornato ONLINE.") + del last_notified_errors[status_key] + # ----------------------------------------- + + except Exception as e: + logger.error(f"Errore parsing health: {e}") + + # --- GESTIONE DMR GATEWAY --- elif len(parts) >= 4 and parts[0] == 'data' and parts[2].lower() == 'dmrgateway' and (parts[3].upper().startswith('NETWORK') or parts[3].upper().startswith('DMR NETWORK')): try: cid = parts[1].lower() @@ -221,6 +258,7 @@ def on_message(client, userdata, msg): except Exception as e: logger.error(f"Errore parsing DMRGateway per {cid}: {e}") + # --- GESTIONE ALTRI GATEWAY --- elif parts[0] in ['dmr-gateway', 'nxdn-gateway', 'ysf-gateway', 'p25-gateway', 'dstar-gateway']: data = json.loads(payload) proto = "DMR" @@ -242,6 +280,7 @@ def on_message(client, userdata, msg): if m: save_to_sqlite(cid, {'source_id': "๐ŸŒ " + m, 'destination_id': 'NET'}, protocol=proto) + # --- GESTIONE MMDVM E TRAFFICO --- elif parts[0] == 'mmdvm': data = json.loads(payload) if cid not in active_calls: active_calls[cid] = {} @@ -304,7 +343,8 @@ def on_message(client, userdata, msg): client_telemetry[cid]["alt"] = f"{'โœ…' if act=='end' else 'โš ๏ธ'} {name}: {info['src']}" save_cache(client_telemetry) if k in active_calls[cid]: del active_calls[cid][k] - except Exception as e: logger.error(f"ERRORE MQTT MSG: {e}") + except Exception as e: + logger.error(f"ERRORE MQTT MSG: {e}") # --- INIZIALIZZAZIONE CLIENT MQTT --- mqtt_backend = mqtt_client.Client(mqtt_client.CallbackAPIVersion.VERSION2, "flask_backend") @@ -625,6 +665,47 @@ def serve_sw(): def serve_icon(): return send_from_directory('.', 'icon-512.png') +def broadcast_push_notification(title, body): + wp_config = config.get('webpush') + if not wp_config: return + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("SELECT id, subscription FROM push_subscriptions") + subs = c.fetchall() + + for sub_id, sub_json in subs: + try: + webpush( + subscription_info=json.loads(sub_json), + data=json.dumps({"title": title, "body": body}), + vapid_private_key=wp_config['vapid_private_key'], + vapid_claims={"sub": wp_config['vapid_claim_email']} + ) + except WebPushException as ex: + if ex.response and ex.response.status_code == 410: + c.execute("DELETE FROM push_subscriptions WHERE id = ?", (sub_id,)) + conn.commit() + except Exception as e: + logger.error(f"Errore generico Push: {e}") + conn.close() + +@app.route('/api/vapid_public_key') +def get_vapid_key(): + return jsonify({"public_key": config.get('webpush', {}).get('vapid_public_key', '')}) + +@app.route('/api/subscribe', methods=['POST']) +def subscribe_push(): + if not session.get('logged_in'): return jsonify({"error": "Unauthorized"}), 403 + sub_data = request.json + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("INSERT OR IGNORE INTO push_subscriptions (username, subscription) VALUES (?, ?)", + (session.get('user'), json.dumps(sub_data))) + conn.commit() + conn.close() + return jsonify({"success": True}) + if __name__ == '__main__': threading.Thread(target=auto_update_ids, daemon=True).start() socketio.run(app, host='0.0.0.0', port=9000, allow_unsafe_werkzeug=True) diff --git a/sw.js b/sw.js index aeaeeeb..79d751a 100644 --- a/sw.js +++ b/sw.js @@ -1,9 +1,11 @@ -const CACHE_NAME = 'fleet-c2-v1'; +const CACHE_NAME = 'fleet-c2-v2'; // Incrementiamo la versione const urlsToCache = [ '/', - '/manifest.json' + '/manifest.json', + '/icon-512.png' ]; +// --- 1. GESTIONE CACHE (Il tuo codice originale) --- self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_NAME) @@ -16,3 +18,46 @@ self.addEventListener('fetch', event => { fetch(event.request).catch(() => caches.match(event.request)) ); }); + +// --- 2. GESTIONE NOTIFICHE PUSH (Il nuovo codice) --- +self.addEventListener('push', function(event) { + console.log('[Service Worker] Notifica Push ricevuta.'); + + let data = { title: 'Fleet Alert', body: 'Nuovo messaggio dal sistema.' }; + + if (event.data) { + try { + data = event.data.json(); + } catch (e) { + data.body = event.data.text(); + } + } + + const options = { + body: data.body, + icon: '/icon-512.png', + badge: '/icon-512.png', + vibrate: [200, 100, 200], + data: { + dateOfArrival: Date.now(), + primaryKey: '1' + }, + actions: [ + { action: 'explore', title: 'Apri Dashboard' }, + { action: 'close', title: 'Chiudi' } + ] + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); +}); + +self.addEventListener('notificationclick', function(event) { + event.notification.close(); + if (event.action === 'explore') { + event.waitUntil( + clients.openWindow('/') + ); + } +}); diff --git a/templates/index.html b/templates/index.html index 2db854f..5de210a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -163,6 +163,7 @@
+
@@ -1076,7 +1077,74 @@ }); } -initUI(); +// --- FUNZIONE PER ISCRIVERSI ALLE PUSH --- + async function subscribeToPush() { + const lang = document.documentElement.lang || 'it'; + const msg = { + it: { ok: "Notifiche attivate!", err: "Errore o permesso negato", title: "PUSH" }, + en: { ok: "Notifications enabled!", err: "Error or permission denied", title: "PUSH" } + }; + + if (!('serviceWorker' in navigator)) return; + + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + customAlert("Error", msg[lang].err, true); + return; + } + + try { + const reg = await navigator.serviceWorker.ready; + const res = await fetch('/api/vapid_public_key'); + const { public_key } = await res.json(); + + const subscription = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlB64ToUint8Array(public_key) + }); + + await fetch('/api/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(subscription) + }); + + customAlert("Success", msg[lang].ok); + document.getElementById('push-btn').style.color = 'var(--success)'; + } catch (e) { + console.error(e); + } + } + + // --- FUNZIONE PER CONTROLLARE IL COLORE DEL BOTTONE --- + async function checkPushStatus() { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) return; + + try { + const reg = await navigator.serviceWorker.ready; + const subscription = await reg.pushManager.getSubscription(); + + const btn = document.getElementById('push-btn'); + if (subscription && btn) { + btn.style.color = 'var(--success)'; + } + } catch(e) { + console.error("Errore nel controllo stato Push:", e); + } + } + + // Helper per convertire la chiave VAPID + function urlB64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i); + return outputArray; + } + + initUI(); + checkPushStatus(); // Ora funzionerร  perfettamente! // --- MOTORE WEBSOCKET REAL-TIME --- const socket = io(); diff --git a/test-push.py b/test-push.py new file mode 100644 index 0000000..f44a7c5 --- /dev/null +++ b/test-push.py @@ -0,0 +1,11 @@ +import sqlite3 +import json +from app import broadcast_push_notification, init_db + +# Proviamo a inviare una notifica a tutti gli iscritti nel DB +print("๐Ÿš€ Starting notification test...") +broadcast_push_notification( + "โš ๏ธ ALLERTA FLOTTA", + "Test Push Notification: System Online!" +) +print("โœ… Command sent.")