Add Push Notification
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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('/')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -163,6 +163,7 @@
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<button class="theme-switch" id="lang-btn" onclick="toggleLang()" data-i18n-title="ttLang">🇮🇹 ITA</button>
|
||||
<button class="theme-switch" id="theme-btn" onclick="toggleTheme()" data-i18n-title="ttTheme">🌙 DARK</button>
|
||||
<button class="theme-switch" id="push-btn" onclick="subscribeToPush()">🔔 PUSH</button>
|
||||
<div id="auth-container" style="display:flex; align-items:center; gap:8px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1076,7 +1077,74 @@
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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();
|
||||
|
||||
@@ -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.")
|
||||
Reference in New Issue
Block a user