Add Push Notification

This commit is contained in:
2026-04-22 01:43:09 +02:00
parent 541e6f1ce3
commit 324b066f51
5 changed files with 216 additions and 9 deletions
+6 -4
View File
@@ -19,6 +19,7 @@ The ecosystem consists of three main parts:
### ✨ Features ### ✨ 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. * **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. * **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. * **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). * **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 ### 🚀 Installation & Setup
#### 1. Server Setup (Central Hub) #### 1. Server Setup (Central Hub)
* Install dependencies: `pip install flask flask-socketio paho-mqtt psutil werkzeug` * Install dependencies: `pip install flask flask-socketio paho-mqtt psutil werkzeug pywebpush`
* Configure `config.json` (use `config.example.json` as template) with your MQTT credentials. * Configure `config.json` (use `config.example.json` as template) with your MQTT and WebPush VAPID credentials.
* Define your repeaters in `clients.json`. * Define your repeaters in `clients.json`.
* Run: `python3 app.py` * Run: `python3 app.py`
@@ -60,6 +61,7 @@ L'ecosistema si compone di tre parti principali:
### ✨ Funzionalità ### ✨ 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. * **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. * **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. * **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). * **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 ### 🚀 Installazione e Configurazione
#### 1. Setup del Server (Hub Centrale) #### 1. Setup del Server (Hub Centrale)
* Installa le dipendenze: `pip install flask flask-socketio paho-mqtt psutil werkzeug` * 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. * 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`. * Definisci i tuoi ripetitori nel file `clients.json`.
* Avvia: `python3 app.py` * Avvia: `python3 app.py`
+83 -2
View File
@@ -8,6 +8,7 @@ import urllib.request
import threading import threading
import time import time
import logging import logging
from pywebpush import webpush, WebPushException
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from flask_socketio import SocketIO, emit from flask_socketio import SocketIO, emit
@@ -50,6 +51,9 @@ def init_db():
(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)''')
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 c.execute('''CREATE TABLE IF NOT EXISTS audit_logs
(id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME, username TEXT, (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME, username TEXT,
client_id TEXT, command TEXT)''') client_id TEXT, command TEXT)''')
@@ -117,6 +121,7 @@ app.secret_key = 'ari_fvg_secret_ultra_secure'
client_states = {} client_states = {}
device_configs = {} device_configs = {}
client_telemetry = {} client_telemetry = {}
last_notified_errors = {}
device_health = {} device_health = {}
last_seen_reflector = {} last_seen_reflector = {}
network_mapping = {} network_mapping = {}
@@ -171,15 +176,28 @@ def on_message(client, userdata, msg):
except Exception as e: except Exception as e:
logger.error(f"Errore parsing config JSON: {e}") logger.error(f"Errore parsing config JSON: {e}")
# --- GESTIONE STATI SERVIZIO E NODO ---
elif parts[0] == 'servizi': elif parts[0] == 'servizi':
client_states[cid] = payload client_states[cid] = payload
socketio.emit('dati_aggiornati') # <--- WEBSOCKET 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', '']: if payload.upper() not in ['OFF', 'OFFLINE', '']:
tel = client_telemetry.get(cid, {}) tel = client_telemetry.get(cid, {})
if isinstance(tel, dict) and '🔄' in str(tel.get('ts1', '')): if isinstance(tel, dict) and '🔄' in str(tel.get('ts1', '')):
client_telemetry[cid] = {"ts1": "In attesa...", "ts2": "In attesa...", "alt": ""} client_telemetry[cid] = {"ts1": "In attesa...", "ts2": "In attesa...", "alt": ""}
save_cache(client_telemetry) save_cache(client_telemetry)
# --- GESTIONE SALUTE DISPOSITIVI ---
elif parts[0] == 'devices' and len(parts) >= 3 and parts[2] == 'services': elif parts[0] == 'devices' and len(parts) >= 3 and parts[2] == 'services':
try: try:
data = json.loads(payload) data = json.loads(payload)
@@ -193,8 +211,27 @@ def on_message(client, userdata, msg):
"profiles": data.get("profiles", {"A": "PROFILO A", "B": "PROFILO B"}) "profiles": data.get("profiles", {"A": "PROFILO A", "B": "PROFILO B"})
} }
socketio.emit('dati_aggiornati') # <--- WEBSOCKET 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')): 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: try:
cid = parts[1].lower() cid = parts[1].lower()
@@ -221,6 +258,7 @@ def on_message(client, userdata, msg):
except Exception as e: except Exception as e:
logger.error(f"Errore parsing DMRGateway per {cid}: {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']: elif parts[0] in ['dmr-gateway', 'nxdn-gateway', 'ysf-gateway', 'p25-gateway', 'dstar-gateway']:
data = json.loads(payload) data = json.loads(payload)
proto = "DMR" 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) if m: save_to_sqlite(cid, {'source_id': "🌐 " + m, 'destination_id': 'NET'}, protocol=proto)
# --- GESTIONE MMDVM E TRAFFICO ---
elif parts[0] == 'mmdvm': elif parts[0] == 'mmdvm':
data = json.loads(payload) data = json.loads(payload)
if cid not in active_calls: active_calls[cid] = {} 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']}" client_telemetry[cid]["alt"] = f"{'' if act=='end' else '⚠️'} {name}: {info['src']}"
save_cache(client_telemetry) save_cache(client_telemetry)
if k in active_calls[cid]: del active_calls[cid][k] 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 --- # --- INIZIALIZZAZIONE CLIENT MQTT ---
mqtt_backend = mqtt_client.Client(mqtt_client.CallbackAPIVersion.VERSION2, "flask_backend") mqtt_backend = mqtt_client.Client(mqtt_client.CallbackAPIVersion.VERSION2, "flask_backend")
@@ -625,6 +665,47 @@ def serve_sw():
def serve_icon(): def serve_icon():
return send_from_directory('.', 'icon-512.png') 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__': if __name__ == '__main__':
threading.Thread(target=auto_update_ids, daemon=True).start() threading.Thread(target=auto_update_ids, daemon=True).start()
socketio.run(app, host='0.0.0.0', port=9000, allow_unsafe_werkzeug=True) socketio.run(app, host='0.0.0.0', port=9000, allow_unsafe_werkzeug=True)
+47 -2
View File
@@ -1,9 +1,11 @@
const CACHE_NAME = 'fleet-c2-v1'; const CACHE_NAME = 'fleet-c2-v2'; // Incrementiamo la versione
const urlsToCache = [ const urlsToCache = [
'/', '/',
'/manifest.json' '/manifest.json',
'/icon-512.png'
]; ];
// --- 1. GESTIONE CACHE (Il tuo codice originale) ---
self.addEventListener('install', event => { self.addEventListener('install', event => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME) caches.open(CACHE_NAME)
@@ -16,3 +18,46 @@ self.addEventListener('fetch', event => {
fetch(event.request).catch(() => caches.match(event.request)) 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('/')
);
}
});
+69 -1
View File
@@ -163,6 +163,7 @@
<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="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 id="auth-container" style="display:flex; align-items:center; gap:8px;"></div>
</div> </div>
</div> </div>
@@ -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 --- // --- MOTORE WEBSOCKET REAL-TIME ---
const socket = io(); const socket = io();
+11
View File
@@ -0,0 +1,11 @@
import sqlite3
import json
from app import broadcast_push_notification, init_db
# Proviamo a inviare una notifica a tutti gli iscritti nel DB
print("🚀 Starting notification test...")
broadcast_push_notification(
"⚠️ ALLERTA FLOTTA",
"Test Push Notification: System Online!"
)
print("✅ Command sent.")