Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1780a4a737 | |||
| 728233998b | |||
| 080a6af776 | |||
| 2a8815a6bd | |||
| 71cbc78cb7 | |||
| 6c6073b966 | |||
| b587669f0c | |||
| 69d7e885bd | |||
| 66d22411a4 | |||
| 324b066f51 | |||
| 541e6f1ce3 | |||
| 4b58bebe2a | |||
| 8959c8f1cf | |||
| 7da6471ff9 |
@@ -6,6 +6,8 @@ __pycache__/
|
||||
*.db
|
||||
monitor.db
|
||||
monitor.db-journal
|
||||
monitor.db-shm
|
||||
monitor.db-wal
|
||||
|
||||
# Ignora i file di configurazione reali (pubblicherai solo i .example.json)
|
||||
config.json
|
||||
|
||||
@@ -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,11 +32,20 @@ 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 using the provided file: `pip install -r requirements.txt`
|
||||
* 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`
|
||||
|
||||
#### 🔑 Generating VAPID Keys (Push Notifications)
|
||||
To enable Web Push Notifications, you must generate a unique VAPID key pair for your server.
|
||||
1. Go to a free online generator like [vapidkeys.com](https://vapidkeys.com/).
|
||||
2. Generate the keys.
|
||||
3. Open your `config.json` and paste them in the `webpush` section:
|
||||
* `vapid_public_key`: Your newly generated Public Key
|
||||
* `vapid_private_key`: Your newly generated Private Key
|
||||
* `vapid_claim_email`: A contact email formatted as `"mailto:your@email.com"`
|
||||
|
||||
#### 2. Agent Setup (Remote Nodes)
|
||||
The `system_monitor.py` must be installed on every node you wish to monitor.
|
||||
* *(Optional)* Install `RPi.GPIO` (`pip install RPi.GPIO` or `apt install python3-rpi.gpio`) if you plan to use the physical Hardware Reset feature.
|
||||
@@ -43,6 +53,17 @@ The `system_monitor.py` must be installed on every node you wish to monitor.
|
||||
* Edit `node_config.json` to set the `client_id` (must match the ID in `clients.json`) and MQTT credentials.
|
||||
* (Recommended) Set up a systemd service to run the agent automatically at boot.
|
||||
|
||||
#### ⚙️ Running as a System Service (systemd)
|
||||
To keep the system running continuously and start automatically at boot, use the provided `.service` files:
|
||||
|
||||
1. Copy the appropriate file to the systemd directory:
|
||||
* For Server: `sudo cp systemd/fleet-console.service /etc/systemd/system/`
|
||||
* For Nodes: `sudo cp systemd/fleet-agent.service /etc/systemd/system/`
|
||||
2. Reload the systemd daemon: `sudo systemctl daemon-reload`
|
||||
3. Enable it to start on boot: `sudo systemctl enable fleet-console` (or `fleet-agent`)
|
||||
4. Start the service: `sudo systemctl start fleet-console`
|
||||
5. Check the status: `sudo systemctl status fleet-console`
|
||||
|
||||
---
|
||||
|
||||
<a name="italiano"></a>
|
||||
@@ -60,6 +81,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,11 +94,20 @@ 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 usando il file fornito: `pip install -r requirements.txt`
|
||||
* 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`
|
||||
|
||||
#### 🔑 Generare le chiavi VAPID (Notifiche Push)
|
||||
Per abilitare le Notifiche Push, devi generare una coppia di chiavi VAPID univoca per il tuo server.
|
||||
1. Vai su un generatore online gratuito come [vapidkeys.com](https://vapidkeys.com/).
|
||||
2. Genera la coppia di chiavi.
|
||||
3. Apri il tuo file `config.json` e incollale nella sezione `webpush`:
|
||||
* `vapid_public_key`: La tua Chiave Pubblica appena generata
|
||||
* `vapid_private_key`: La tua Chiave Privata appena generata
|
||||
* `vapid_claim_email`: Un indirizzo di contatto formattato come `"mailto:tua@email.com"`
|
||||
|
||||
#### 2. Setup dell'Agente (Nodi Remoti)
|
||||
Il file `system_monitor.py` va installato su ogni nodo che vuoi monitorare.
|
||||
* *(Opzionale)* Installa `RPi.GPIO` (`pip install RPi.GPIO` o `apt install python3-rpi.gpio`) se intendi utilizzare la funzione di Reset Hardware fisico della scheda.
|
||||
@@ -84,5 +115,16 @@ Il file `system_monitor.py` va installato su ogni nodo che vuoi monitorare.
|
||||
* Modifica `node_config.json` impostando il `client_id` (deve corrispondere all'ID in `clients.json`) e le credenziali MQTT.
|
||||
* (Consigliato) Crea un servizio systemd per avviare l'agente automaticamente al boot.
|
||||
|
||||
#### ⚙️ Esecuzione come Servizio di Sistema (systemd)
|
||||
Per mantenere il sistema sempre attivo e avviarlo in automatico all'accensione, usa i file `.service` forniti:
|
||||
|
||||
1. Copia il file appropriato nella cartella di systemd:
|
||||
* Per il Server: `sudo cp systemd/fleet-console.service /etc/systemd/system/`
|
||||
* Per i Nodi: `sudo cp systemd/fleet-agent.service /etc/systemd/system/`
|
||||
2. Ricarica i demoni di sistema: `sudo systemctl daemon-reload`
|
||||
3. Abilita l'avvio automatico: `sudo systemctl enable fleet-console` (oppure `fleet-agent`)
|
||||
4. Avvia il servizio: `sudo systemctl start fleet-console`
|
||||
5. Controlla lo stato: `sudo systemctl status fleet-console`
|
||||
|
||||
---
|
||||
*Created by IV3JDV @ ARIFVG - 2026*
|
||||
|
||||
@@ -182,6 +182,27 @@ def check_auto_healing(client, status):
|
||||
msg = f"🛠 Auto-healing: {proc_name} offline. Riavvio {attempts+1}/3..."
|
||||
client.publish(f"devices/{CLIENT_ID}/logs", msg)
|
||||
send_telegram_message(msg)
|
||||
|
||||
# --- INIZIO MODIFICA: RESET HARDWARE SPECIFICO PER MMDVMHOST ---
|
||||
if proc_name.lower() == "mmdvmhost" and GPIO_AVAILABLE:
|
||||
logger.info("Esecuzione RESET HAT automatico pre-riavvio MMDVMHost...")
|
||||
try:
|
||||
RESET_PIN = 21 # Assicurati che il PIN sia quello corretto per i tuoi nodi
|
||||
GPIO.setwarnings(False)
|
||||
GPIO.setmode(GPIO.BCM)
|
||||
GPIO.setup(RESET_PIN, GPIO.OUT)
|
||||
# Impulso LOW per resettare
|
||||
GPIO.output(RESET_PIN, GPIO.LOW)
|
||||
time.sleep(0.5)
|
||||
GPIO.output(RESET_PIN, GPIO.HIGH)
|
||||
GPIO.cleanup(RESET_PIN)
|
||||
# Diamo tempo al microcontrollore di riavviarsi
|
||||
time.sleep(1.5)
|
||||
client.publish(f"devices/{CLIENT_ID}/logs", "🔌 Impulso GPIO (Reset MMDVM) inviato!")
|
||||
except Exception as e:
|
||||
logger.error(f"Errore GPIO in auto-healing: {e}")
|
||||
# --- FINE MODIFICA ---
|
||||
|
||||
subprocess.run(["sudo", "systemctl", "restart", proc_name])
|
||||
elif attempts == 3:
|
||||
msg = f"🚨 CRITICO: {proc_name} fallito!"
|
||||
|
||||
@@ -8,10 +8,11 @@ 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
|
||||
|
||||
# --- CONFIGURAZIONE LOGGING ---
|
||||
# --- LOGGING CONFIGURATION ---
|
||||
logging.basicConfig(
|
||||
handlers=[
|
||||
RotatingFileHandler('/opt/web-control/fleet_console.log', maxBytes=10000000, backupCount=3),
|
||||
@@ -22,10 +23,10 @@ logging.basicConfig(
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
logger = logging.getLogger("FleetHub")
|
||||
# Silenzia lo spam delle richieste HTTP (GET /api/states 200 OK)
|
||||
# Silence HTTP request spam (GET /api/states 200 OK)
|
||||
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
||||
|
||||
# --- PERCORSI ---
|
||||
# --- PATHS ---
|
||||
DB_PATH = '/opt/web-control/monitor.db'
|
||||
CACHE_FILE = '/opt/web-control/telemetry_cache.json'
|
||||
CONFIG_PATH = '/opt/web-control/config.json'
|
||||
@@ -37,7 +38,7 @@ def init_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute('PRAGMA journal_mode=WAL;') # <-- MAGIA: Abilita letture/scritture simultanee!
|
||||
c.execute('PRAGMA journal_mode=WAL;') # <-- MAGIC: Enable simultaneous read/write!
|
||||
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS radio_logs
|
||||
(id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME, client_id TEXT,
|
||||
@@ -50,23 +51,39 @@ 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)''')
|
||||
|
||||
c.execute("SELECT COUNT(*) FROM users")
|
||||
if c.fetchone()[0] == 0:
|
||||
h = generate_password_hash('admin123')
|
||||
c.execute("INSERT INTO users (username, password_hash, role, allowed_nodes) VALUES (?,?,?,?)",
|
||||
('admin', h, 'admin', 'all'))
|
||||
logger.info(">>> UTENTE DI DEFAULT CREATO - User: admin | Pass: admin123 <<<")
|
||||
# Default value if missing config file
|
||||
def_user = "admin"
|
||||
def_pass = "admin123"
|
||||
|
||||
# Try read config.json
|
||||
try:
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
cfg = json.load(f)
|
||||
def_user = cfg.get("web_admin", {}).get("default_user", "admin")
|
||||
def_pass = cfg.get("web_admin", {}).get("default_pass", "admin123")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
h = generate_password_hash(def_pass)
|
||||
c.execute("INSERT INTO users (username, password_hash, role, allowed_nodes) VALUES (?,?,?,?)",
|
||||
(def_user, h, 'admin', 'all'))
|
||||
logger.info(f">>> DEFAULT USER CREATED - User: {def_user} | Pass: {def_pass} <<<")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
init_db()
|
||||
|
||||
# --- CARICAMENTO DATABASE ID ---
|
||||
# --- ID DATABASE LOADING ---
|
||||
user_db = {}
|
||||
nxdn_db = {}
|
||||
|
||||
@@ -117,6 +134,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 = {}
|
||||
@@ -129,10 +147,10 @@ if os.path.exists(CACHE_FILE):
|
||||
active_calls = {}
|
||||
with open(CONFIG_PATH) as f: config = json.load(f)
|
||||
|
||||
# --- CALLBACKS MQTT ---
|
||||
# --- MQTT CALLBACKS ---
|
||||
def on_connect(client, userdata, flags, reason_code, properties=None):
|
||||
if reason_code == 0:
|
||||
logger.info("✅ Connesso al Broker MQTT con successo! Sottoscrizione ai topic in corso...")
|
||||
logger.info("✅ Successfully connected to MQTT Broker! Subscribing to topics...")
|
||||
client.subscribe([
|
||||
("servizi/+/stat", 0),
|
||||
("dmr-gateway/+/json", 0),
|
||||
@@ -146,10 +164,10 @@ def on_connect(client, userdata, flags, reason_code, properties=None):
|
||||
("data/#", 0)
|
||||
])
|
||||
else:
|
||||
logger.error(f"❌ Errore di connessione MQTT. Codice motivo: {reason_code}")
|
||||
logger.error(f"❌ MQTT Connection Error. Reason code: {reason_code}")
|
||||
|
||||
def on_disconnect(client, userdata, disconnect_flags, reason_code, properties=None):
|
||||
logger.warning(f"⚠️ Disconnessione MQTT rilevata! Codice motivo: {reason_code}. Tentativo di riconnessione automatico in corso...")
|
||||
logger.warning(f"⚠️ MQTT Disconnection detected! Reason code: {reason_code}. Attempting automatic reconnection...")
|
||||
|
||||
def on_message(client, userdata, msg):
|
||||
try:
|
||||
@@ -159,7 +177,7 @@ def on_message(client, userdata, msg):
|
||||
if len(parts) < 2: return
|
||||
cid = parts[1].lower()
|
||||
|
||||
# --- CATTURA CONFIGURAZIONI COMPLETE ---
|
||||
# --- CAPTURE FULL CONFIGURATIONS ---
|
||||
if parts[0] == 'data' and len(parts) >= 4 and parts[3] == 'full_config':
|
||||
cid_conf = parts[1].lower()
|
||||
svc_name = parts[2].lower()
|
||||
@@ -167,19 +185,32 @@ def on_message(client, userdata, msg):
|
||||
device_configs[cid_conf] = {}
|
||||
try:
|
||||
device_configs[cid_conf][svc_name] = json.loads(payload)
|
||||
logger.debug(f"Configurazione salvata per {cid_conf} -> {svc_name}")
|
||||
logger.debug(f"Configuration saved for {cid_conf} -> {svc_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Errore parsing config JSON: {e}")
|
||||
logger.error(f"Error parsing config JSON: {e}")
|
||||
|
||||
# --- NODE AND SERVICE STATE MANAGEMENT ---
|
||||
elif parts[0] == 'servizi':
|
||||
client_states[cid] = payload
|
||||
socketio.emit('dati_aggiornati') # <--- WEBSOCKET
|
||||
|
||||
# --- PUSH TRIGGER: NODE STATE ---
|
||||
if payload.upper() == 'OFFLINE':
|
||||
if last_notified_errors.get(f"{cid}_NODE") != 'OFFLINE':
|
||||
broadcast_push_notification(f"💀 NODE OFFLINE: {cid.upper()}", "Connection lost with 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"🌤️ NODE ONLINE: {cid.upper()}", "Node is back online.")
|
||||
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": ""}
|
||||
client_telemetry[cid] = {"ts1": "Waiting...", "ts2": "Waiting...", "alt": ""}
|
||||
save_cache(client_telemetry)
|
||||
|
||||
# --- DEVICE HEALTH MANAGEMENT ---
|
||||
elif parts[0] == 'devices' and len(parts) >= 3 and parts[2] == 'services':
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
@@ -190,11 +221,30 @@ def on_message(client, userdata, msg):
|
||||
"disk": round(data.get("disk_usage_percent", 0), 1),
|
||||
"processes": data.get("processes", {}),
|
||||
"files": data.get("files", data.get("config_files", [])),
|
||||
"profiles": data.get("profiles", {"A": "PROFILO A", "B": "PROFILO B"})
|
||||
"profiles": data.get("profiles", {"A": "PROFILE A", "B": "PROFILE B"})
|
||||
}
|
||||
socketio.emit('dati_aggiornati') # <--- WEBSOCKET
|
||||
except Exception as e: logger.error(f"Errore parsing health: {e}")
|
||||
|
||||
# --- PUSH TRIGGER: SERVICE ERRORS ---
|
||||
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"Service {svc_name} KO ({svc_status})"
|
||||
if s_lower == "error": msg_err += " - Auto-healing failed! ⚠️"
|
||||
broadcast_push_notification(f"🚨 ALARM: {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"✅ RESTORED: {cid.upper()}", f"Service {svc_name} back ONLINE.")
|
||||
del last_notified_errors[status_key]
|
||||
# -----------------------------------------
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing health data: {e}")
|
||||
|
||||
# --- DMR GATEWAY MANAGEMENT ---
|
||||
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()
|
||||
@@ -219,8 +269,9 @@ def on_message(client, userdata, msg):
|
||||
socketio.emit('dati_aggiornati') # <--- WEBSOCKET
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Errore parsing DMRGateway per {cid}: {e}")
|
||||
logger.error(f"Error parsing DMRGateway for {cid}: {e}")
|
||||
|
||||
# --- OTHER GATEWAYS MANAGEMENT ---
|
||||
elif parts[0] in ['dmr-gateway', 'nxdn-gateway', 'ysf-gateway', 'p25-gateway', 'dstar-gateway']:
|
||||
data = json.loads(payload)
|
||||
proto = "DMR"
|
||||
@@ -242,11 +293,12 @@ def on_message(client, userdata, msg):
|
||||
|
||||
if m: save_to_sqlite(cid, {'source_id': "🌐 " + m, 'destination_id': 'NET'}, protocol=proto)
|
||||
|
||||
# --- MMDVM AND TRAFFIC MANAGEMENT ---
|
||||
elif parts[0] == 'mmdvm':
|
||||
data = json.loads(payload)
|
||||
if cid not in active_calls: active_calls[cid] = {}
|
||||
if cid not in client_telemetry or not isinstance(client_telemetry.get(cid), dict):
|
||||
client_telemetry[cid] = {"ts1": "In attesa...", "ts2": "In attesa...", "alt": "", "idle": True}
|
||||
client_telemetry[cid] = {"ts1": "Waiting...", "ts2": "Waiting...", "alt": "", "idle": True}
|
||||
|
||||
if 'MMDVM' in data and data['MMDVM'].get('mode') == 'idle':
|
||||
client_telemetry[cid]["idle"] = True
|
||||
@@ -304,9 +356,10 @@ 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"MQTT MSG ERROR: {e}")
|
||||
|
||||
# --- INIZIALIZZAZIONE CLIENT MQTT ---
|
||||
# --- MQTT CLIENT INITIALIZATION ---
|
||||
mqtt_backend = mqtt_client.Client(mqtt_client.CallbackAPIVersion.VERSION2, "flask_backend")
|
||||
mqtt_backend.username_pw_set(config['mqtt']['user'], config['mqtt']['password'])
|
||||
mqtt_backend.on_connect = on_connect
|
||||
@@ -344,7 +397,7 @@ def get_states():
|
||||
|
||||
@app.route('/api/service_control', methods=['POST'])
|
||||
def service_control():
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 403
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
|
||||
d = request.json
|
||||
cid = d.get('clientId').lower()
|
||||
action = d.get('action')
|
||||
@@ -378,7 +431,7 @@ def login():
|
||||
|
||||
@app.route('/api/command', methods=['POST'])
|
||||
def cmd():
|
||||
if not session.get('logged_in'): return jsonify({"success": False, "error": "Non autenticato"}), 403
|
||||
if not session.get('logged_in'): return jsonify({"success": False, "error": "Not authenticated"}), 403
|
||||
d = request.json
|
||||
cid = d['clientId'].lower()
|
||||
cmd_type = d['type']
|
||||
@@ -387,7 +440,7 @@ def cmd():
|
||||
allowed = session.get('allowed_nodes', '')
|
||||
is_allowed = (role == 'admin' or allowed == 'all' or cid in [x.strip() for x in allowed.split(',')])
|
||||
if cmd_type == 'REBOOT' and role != 'admin':
|
||||
return jsonify({"success": False, "error": "Solo gli Admin possono riavviare."}), 403
|
||||
return jsonify({"success": False, "error": "Only Admins can reboot."}), 403
|
||||
if is_allowed:
|
||||
mqtt_backend.publish(f"servizi/{cid}/cmnd", cmd_type)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
@@ -396,20 +449,19 @@ def cmd():
|
||||
(username, cid, cmd_type))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
client_telemetry[cid] = {"ts1": "🔄 Inviato...", "ts2": "🔄 Inviato...", "alt": ""}
|
||||
client_telemetry[cid] = {"ts1": "🔄 Sent...", "ts2": "🔄 Sent...", "alt": ""}
|
||||
return jsonify({"success": True})
|
||||
return jsonify({"success": False, "error": "Non hai i permessi per questo nodo."}), 403
|
||||
return jsonify({"success": False, "error": "You do not have permission for this node."}), 403
|
||||
|
||||
# --- API PER IL PULSANTE DI AGGIORNAMENTO ---
|
||||
@app.route('/api/update_nodes', methods=['POST'])
|
||||
def update_nodes():
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 403
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
|
||||
mqtt_backend.publish("devices/control/request", "update")
|
||||
return jsonify({"success": True})
|
||||
|
||||
@app.route('/api/users', methods=['GET'])
|
||||
def get_users():
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 403
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
@@ -420,14 +472,14 @@ def get_users():
|
||||
|
||||
@app.route('/api/users', methods=['POST'])
|
||||
def add_user():
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 403
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
|
||||
d = request.json
|
||||
username = d.get('username')
|
||||
password = d.get('password')
|
||||
role = d.get('role', 'operator')
|
||||
allowed = d.get('allowed_nodes', '')
|
||||
if not username or not password:
|
||||
return jsonify({"success": False, "error": "Dati mancanti"})
|
||||
return jsonify({"success": False, "error": "Missing data"})
|
||||
h = generate_password_hash(password)
|
||||
try:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
@@ -438,18 +490,18 @@ def add_user():
|
||||
conn.close()
|
||||
return jsonify({"success": True})
|
||||
except sqlite3.IntegrityError:
|
||||
return jsonify({"success": False, "error": "Username già esistente"})
|
||||
return jsonify({"success": False, "error": "Username already exists"})
|
||||
|
||||
@app.route('/api/users/<int:user_id>', methods=['DELETE'])
|
||||
def delete_user(user_id):
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 403
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT username FROM users WHERE id = ?", (user_id,))
|
||||
u = c.fetchone()
|
||||
if u and u[0] == session.get('user'):
|
||||
conn.close()
|
||||
return jsonify({"success": False, "error": "Non puoi cancellare te stesso!"})
|
||||
return jsonify({"success": False, "error": "You cannot delete yourself!"})
|
||||
c.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -458,7 +510,7 @@ def delete_user(user_id):
|
||||
@app.route('/api/users/<int:user_id>', methods=['PUT'])
|
||||
def update_user(user_id):
|
||||
if session.get('role') != 'admin':
|
||||
return jsonify({"error": "Non autorizzato"}), 403
|
||||
return jsonify({"error": "Unauthorized"}), 403
|
||||
|
||||
data = request.json
|
||||
role = data.get('role', 'operator')
|
||||
@@ -486,14 +538,14 @@ def update_user(user_id):
|
||||
@app.route('/api/change_password', methods=['POST'])
|
||||
def change_password():
|
||||
if not session.get('logged_in'):
|
||||
return jsonify({"success": False, "error": "Non autenticato"}), 403
|
||||
return jsonify({"success": False, "error": "Not authenticated"}), 403
|
||||
d = request.json
|
||||
new_pass = d.get('new_password')
|
||||
user_to_change = d.get('username')
|
||||
if session.get('role') != 'admin' and session.get('user') != user_to_change:
|
||||
return jsonify({"success": False, "error": "Non autorizzato"}), 403
|
||||
return jsonify({"success": False, "error": "Unauthorized"}), 403
|
||||
if not new_pass:
|
||||
return jsonify({"success": False, "error": "La password non può essere vuota"}), 400
|
||||
return jsonify({"success": False, "error": "Password cannot be empty"}), 400
|
||||
h = generate_password_hash(new_pass)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
@@ -505,7 +557,7 @@ def change_password():
|
||||
@app.route('/api/global_command', methods=['POST'])
|
||||
def global_cmd():
|
||||
if session.get('role') != 'admin':
|
||||
return jsonify({"success": False, "error": "Azione riservata all'Admin!"}), 403
|
||||
return jsonify({"success": False, "error": "Admin action only!"}), 403
|
||||
d = request.json
|
||||
cmd_type = d.get('type')
|
||||
clients_list = []
|
||||
@@ -535,14 +587,14 @@ def auto_update_ids():
|
||||
})
|
||||
now = time.strftime("%H:%M")
|
||||
if now == target_time:
|
||||
logger.info(f">>> [AUTO-UPDATE] Orario raggiunto ({now}). Download in corso...")
|
||||
logger.info(f">>> [AUTO-UPDATE] Scheduled time reached ({now}). Downloading...")
|
||||
urllib.request.urlretrieve(urls["dmr"], DMR_IDS_PATH)
|
||||
urllib.request.urlretrieve(urls["nxdn"], NXDN_IDS_PATH)
|
||||
load_ids()
|
||||
logger.info(f">>> [AUTO-UPDATE] Completato con successo.")
|
||||
logger.info(f">>> [AUTO-UPDATE] Completed successfully.")
|
||||
time.sleep(65)
|
||||
except Exception as e:
|
||||
logger.error(f">>> [AUTO-UPDATE] Errore: {e}")
|
||||
logger.error(f">>> [AUTO-UPDATE] Error: {e}")
|
||||
time.sleep(30)
|
||||
|
||||
@app.route('/api/ui_config', methods=['GET'])
|
||||
@@ -551,9 +603,9 @@ def get_ui_config():
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
cfg = json.load(f)
|
||||
ui_cfg = cfg.get("ui", {
|
||||
"profileA_Name": "PROFILO A",
|
||||
"profileA_Name": "PROFILE A",
|
||||
"profileA_Color": "#3498db",
|
||||
"profileB_Name": "PROFILO B",
|
||||
"profileB_Name": "PROFILE B",
|
||||
"profileB_Color": "#9b59b6"
|
||||
})
|
||||
return jsonify(ui_cfg)
|
||||
@@ -562,7 +614,7 @@ def get_ui_config():
|
||||
|
||||
@app.route('/api/config', methods=['GET'])
|
||||
def get_config_api():
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 403
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
cfg = json.load(f)
|
||||
return jsonify({
|
||||
@@ -573,7 +625,7 @@ def get_config_api():
|
||||
|
||||
@app.route('/api/config', methods=['POST'])
|
||||
def save_config_api():
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 403
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
|
||||
new_data = request.json
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
cfg = json.load(f)
|
||||
@@ -587,18 +639,18 @@ def save_config_api():
|
||||
|
||||
@app.route('/api/config_file/<cid>/<service>', methods=['GET'])
|
||||
def get_config_file(cid, service):
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 403
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
|
||||
cid = cid.lower()
|
||||
service = service.lower()
|
||||
config_data = device_configs.get(cid, {}).get(service)
|
||||
|
||||
if not config_data:
|
||||
return jsonify({"error": "Configurazione non ancora ricevuta. Attendi o invia un comando UPDATE."}), 404
|
||||
return jsonify({"error": "Configuration not received yet. Wait or send an UPDATE command."}), 404
|
||||
return jsonify({"success": True, "data": config_data})
|
||||
|
||||
@app.route('/api/config_file', methods=['POST'])
|
||||
def save_config_file():
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 403
|
||||
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
|
||||
d = request.json
|
||||
cid = d.get('clientId').lower()
|
||||
service = d.get('service').lower()
|
||||
@@ -625,6 +677,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"Generic Push Error: {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)
|
||||
socketio.run(app, host='0.0.0.0', port=9000, allow_unsafe_werkzeug=True)
|
||||
|
||||
+18
-9
@@ -1,15 +1,24 @@
|
||||
{
|
||||
"_comment": {"Default admin username and password"
|
||||
},
|
||||
"web_admin": {
|
||||
"user": "admin",
|
||||
"pass": "admin123"
|
||||
},
|
||||
"mqtt": {
|
||||
"broker": "127.0.0.1",
|
||||
"broker": "your_mqtt_broker_address",
|
||||
"port": 1883,
|
||||
"user": "mmdvm",
|
||||
"password": "password"
|
||||
"user": "your_username",
|
||||
"password": "your_password"
|
||||
},
|
||||
"web_admin": {
|
||||
"default_user": "admin",
|
||||
"default_pass": "admin123"
|
||||
},
|
||||
"webpush": {
|
||||
"vapid_public_key": "INSERT_GENERATED_PUBLIC_KEY_HERE",
|
||||
"vapid_private_key": "INSERT_GENERATED_PRIVATE_KEY_HERE",
|
||||
"vapid_claim_email": "mailto:your@email.com"
|
||||
},
|
||||
"ui": {
|
||||
"profileA_Name": "PROFILE A",
|
||||
"profileA_Color": "#3b82f6",
|
||||
"profileB_Name": "PROFILE B",
|
||||
"profileB_Color": "#eab308"
|
||||
},
|
||||
"update_schedule": "03:00",
|
||||
"id_urls": {
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
============================================================
|
||||
INSTALLATION GUIDE - FLEET CONTROL CONSOLE
|
||||
============================================================
|
||||
|
||||
This guide describes the steps to install the Central
|
||||
Dashboard and the Remote Agents on MMDVM nodes.
|
||||
|
||||
------------------------------------------------------------
|
||||
1. PRE-REQUISITES
|
||||
------------------------------------------------------------
|
||||
Ensure Python 3 is installed on all systems.
|
||||
The necessary dependencies are listed in the
|
||||
'requirements.txt' file.
|
||||
|
||||
Install dependencies:
|
||||
pip install -r requirements.txt
|
||||
|
||||
------------------------------------------------------------
|
||||
2. SERVER SETUP (CENTRAL HUB)
|
||||
------------------------------------------------------------
|
||||
The server handles the web interface and user permissions.
|
||||
|
||||
Steps:
|
||||
1. Configure 'config.json' using 'config.example.json' as a template.
|
||||
2. Enter MQTT credentials and VAPID keys.
|
||||
3. Define repeaters in the 'clients.json' file.
|
||||
4. Start the server:
|
||||
python3 app.py
|
||||
|
||||
------------------------------------------------------------
|
||||
3. GENERATING VAPID KEYS (PUSH NOTIFICATIONS)
|
||||
------------------------------------------------------------
|
||||
Required to enable browser and mobile notifications.
|
||||
|
||||
1. Go to https://vapidkeys.com/ and generate the keys.
|
||||
2. Copy 'Public Key' and 'Private Key' into 'config.json'.
|
||||
3. Set 'vapid_claim_email' (e.g., "mailto:your@email.com").
|
||||
|
||||
------------------------------------------------------------
|
||||
4. AGENT SETUP (REMOTE NODES)
|
||||
------------------------------------------------------------
|
||||
To be installed on each Raspberry Pi / MMDVM Node.
|
||||
|
||||
1. Copy 'system_monitor.py' and 'node_config.json' to
|
||||
'/opt/node_agent/'.
|
||||
2. Edit 'node_config.json' with a unique 'client_id'.
|
||||
3. (Optional) Install 'RPi.GPIO' for hardware reset.
|
||||
|
||||
------------------------------------------------------------
|
||||
5. RUNNING AS A SERVICE (SYSTEMD)
|
||||
------------------------------------------------------------
|
||||
For auto-start and process monitoring.
|
||||
|
||||
Configuration:
|
||||
1. Copy .service files to '/etc/systemd/system/':
|
||||
- Server: sudo cp fleet-console.service /etc/systemd/system/
|
||||
- Nodes: sudo cp fleet-agent.service /etc/systemd/system/
|
||||
2. Reload systemd:
|
||||
sudo systemctl daemon-reload
|
||||
3. Enable start on boot:
|
||||
sudo systemctl enable fleet-console (or fleet-agent)
|
||||
4. Start the service:
|
||||
sudo systemctl start fleet-console
|
||||
|
||||
|
||||
============================================================
|
||||
GUIDA ALL'INSTALLAZIONE - FLEET CONTROL CONSOLE
|
||||
============================================================
|
||||
|
||||
Questa guida descrive i passaggi per installare la Dashboard
|
||||
Centrale e gli Agenti Remoti sui nodi MMDVM.
|
||||
|
||||
------------------------------------------------------------
|
||||
1. REQUISITI PRELIMINARI
|
||||
------------------------------------------------------------
|
||||
Assicurarsi di avere Python 3 installato su tutti i sistemi.
|
||||
Le dipendenze necessarie sono elencate nel file
|
||||
'requirements.txt'.
|
||||
|
||||
Installazione dipendenze:
|
||||
pip install -r requirements.txt
|
||||
|
||||
------------------------------------------------------------
|
||||
2. SETUP DEL SERVER (HUB CENTRALE)
|
||||
------------------------------------------------------------
|
||||
Il server gestisce l'interfaccia web e i permessi.
|
||||
|
||||
Passaggi:
|
||||
1. Configura 'config.json' partendo da 'config.example.json'.
|
||||
2. Inserisci le credenziali MQTT e le chiavi VAPID.
|
||||
3. Definisci i ripetitori nel file 'clients.json'.
|
||||
4. Avvia il server:
|
||||
python3 app.py
|
||||
|
||||
------------------------------------------------------------
|
||||
3. GENERAZIONE CHIAVI VAPID (NOTIFICHE PUSH)
|
||||
------------------------------------------------------------
|
||||
Necessarie per abilitare le notifiche su browser e mobile.
|
||||
|
||||
1. Vai su https://vapidkeys.com/ e genera le chiavi.
|
||||
2. Copia 'Public Key' e 'Private Key' nel 'config.json'.
|
||||
3. Imposta 'vapid_claim_email' (es. "mailto:tua@email.com").
|
||||
|
||||
------------------------------------------------------------
|
||||
4. SETUP DELL'AGENTE (NODI REMOTI)
|
||||
------------------------------------------------------------
|
||||
Da installare su ogni Raspberry Pi / Nodo MMDVM.
|
||||
|
||||
1. Copia 'system_monitor.py' e 'node_config.json' in
|
||||
'/opt/node_agent/'.
|
||||
2. Modifica 'node_config.json' con il 'client_id' univoco.
|
||||
3. (Opzionale) Installa 'RPi.GPIO' per il reset hardware.
|
||||
|
||||
------------------------------------------------------------
|
||||
5. ESECUZIONE COME SERVIZIO (SYSTEMD)
|
||||
------------------------------------------------------------
|
||||
Per l'avvio automatico e il monitoraggio del processo.
|
||||
|
||||
Configurazione:
|
||||
1. Copia i file .service in '/etc/systemd/system/':
|
||||
- Server: sudo cp fleet-console.service /etc/systemd/system/
|
||||
- Nodi: sudo cp fleet-agent.service /etc/systemd/system/
|
||||
2. Ricarica systemd:
|
||||
sudo systemctl daemon-reload
|
||||
3. Abilita l'avvio al boot:
|
||||
sudo systemctl enable fleet-console (oppure fleet-agent)
|
||||
4. Avvia il servizio:
|
||||
sudo systemctl start fleet-console
|
||||
|
||||
------------------------------------------------------------
|
||||
Created by IV3JDV @ ARIFVG - 2026
|
||||
============================================================
|
||||
@@ -0,0 +1,6 @@
|
||||
Flask
|
||||
Flask-SocketIO
|
||||
paho-mqtt
|
||||
psutil
|
||||
Werkzeug
|
||||
pywebpush
|
||||
@@ -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('/')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Fleet Control - Remote Node Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Agent must be run as root for services restart (MMDVMHost etc.) and use GPIO
|
||||
User=root
|
||||
WorkingDirectory=/opt/node_agent
|
||||
ExecStart=/usr/bin/python3 /opt/node_agent/system_monitor.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Fleet Control Console - Central Hub
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/web-control
|
||||
ExecStart=/usr/bin/python3 /opt/web-control/app.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=fleet-console
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+133
-128
@@ -12,147 +12,101 @@
|
||||
<link rel="apple-touch-icon" href="/icon-512.png">
|
||||
<meta name="apple-mobile-web-app-title" content="Fleet C2">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
<style>
|
||||
/* --- TEMA NOC (Network Operations Center) --- */
|
||||
:root {
|
||||
--primary: #3b82f6; --success: #10b981; --danger: #ef4444; --accent: #8b5cf6;
|
||||
--bg-gradient: linear-gradient(135deg, #e0e7ff 0%, #ede9fe 100%);
|
||||
--card-bg: rgba(255, 255, 255, 0.6);
|
||||
--border-color: rgba(255, 255, 255, 0.8);
|
||||
--text-main: #1e293b; --text-muted: #64748b;
|
||||
--glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07);
|
||||
--topbar-bg: rgba(255, 255, 255, 0.8);
|
||||
--primary: #2f81f7; --success: #2ea043; --danger: #da3633; --accent: #a371f7;
|
||||
--bg-gradient: #0d1117; /* Sfondo nero/grigio profondissimo */
|
||||
--card-bg: #161b22; /* Sfondo grigio antracite per i pannelli */
|
||||
--border-color: #30363d;/* Bordi netti e sottili */
|
||||
--text-main: #c9d1d9; /* Bianco attenuato anti-affaticamento */
|
||||
--text-muted: #8b949e; /* Grigio tecnico per label */
|
||||
--topbar-bg: #161b22;
|
||||
}
|
||||
|
||||
/* Modalità scura "pura" se il toggle viene premuto */
|
||||
body.dark-mode {
|
||||
--bg-gradient: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #020617 100%);
|
||||
--card-bg: rgba(30, 41, 59, 0.5);
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--text-main: #f8fafc; --text-muted: #94a3b8;
|
||||
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.4);
|
||||
--topbar-bg: rgba(15, 23, 42, 0.8);
|
||||
--bg-gradient: #010409; --card-bg: #0d1117; --border-color: #21262d;
|
||||
--text-main: #f0f6fc; --topbar-bg: #0d1117;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: 'Inter', sans-serif; background: var(--bg-gradient); background-attachment: fixed; margin: 0; padding: 0; color: var(--text-main); transition: color 0.3s; min-height: 100vh; }
|
||||
body { font-family: 'Inter', sans-serif; background: var(--bg-gradient); margin: 0; padding: 0; color: var(--text-main); transition: background-color 0.3s; min-height: 100vh; }
|
||||
|
||||
/* Floating Top Bar */
|
||||
/* Barra superiore squadrata e tecnica */
|
||||
#top-bar-container { position: sticky; top: 15px; z-index: 100; padding: 0 20px; display: flex; justify-content: center; }
|
||||
#top-bar { background: var(--topbar-bg); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); padding: 12px 30px; display: flex; justify-content: space-between; align-items: center; border-radius: 50px; box-shadow: var(--glass-shadow); border: 1px solid var(--border-color); width: 100%; max-width: 1400px; }
|
||||
#top-bar { background: var(--topbar-bg); padding: 12px 30px; display: flex; justify-content: space-between; align-items: center; border-radius: 6px; border: 1px solid var(--border-color); width: 100%; max-width: 1400px; }
|
||||
.title-brand { font-size: 1.2rem; font-weight: 800; letter-spacing: 2px; color: var(--text-main); font-family: 'JetBrains Mono', monospace; }
|
||||
|
||||
.title-brand { font-size: 1.3rem; font-weight: 800; letter-spacing: 1px; background: linear-gradient(to right, var(--primary), var(--accent)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.theme-switch { background: transparent; border: 1px solid var(--border-color); color: var(--text-muted); padding: 6px 14px; border-radius: 4px; cursor: pointer; font-weight: 600; font-size: 0.85rem; font-family: 'JetBrains Mono', monospace; }
|
||||
.theme-switch:hover { color: var(--text-main); border-color: var(--text-main); }
|
||||
|
||||
.theme-switch { background: rgba(128, 128, 128, 0.1); border: 1px solid var(--border-color); color: var(--text-main); padding: 8px 18px; border-radius: 20px; cursor: pointer; font-weight: 600; transition: all 0.2s ease; font-size: 0.85rem; }
|
||||
.theme-switch:hover { background: rgba(128, 128, 128, 0.2); transform: translateY(-1px); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; max-width: 1400px; margin: 30px auto; padding: 0 20px; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 30px; max-width: 1400px; margin: 40px auto; padding: 0 20px; }
|
||||
/* Pannelli Ripetitori - Zero ombre, bordi netti */
|
||||
.card { background: var(--card-bg) !important; border-radius: 4px; padding: 20px; border: 1px solid var(--border-color); border-top: 4px solid var(--border-color) !important; display: flex; flex-direction: column; box-shadow: none !important; filter: none !important; opacity: 1 !important; }
|
||||
.card.online { border-top-color: var(--success) !important; }
|
||||
|
||||
/* Glass Cards */
|
||||
.card { background: var(--card-bg); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border-radius: 20px; padding: 25px; box-shadow: var(--glass-shadow); border: 1px solid var(--border-color); border-top: 5px solid #64748b; transition: transform 0.3s ease, box-shadow 0.3s ease; display: flex; flex-direction: column; }
|
||||
.card:hover { transform: translateY(-4px); box-shadow: 0 12px 40px 0 rgba(0,0,0,0.15); }
|
||||
.card.online { border-top-color: var(--success); }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid var(--border-color); padding-bottom: 10px; }
|
||||
.client-name { font-size: 1.1rem; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
|
||||
.badge-id { font-size: 0.75rem; font-family: 'JetBrains Mono', monospace; background: #010409; padding: 4px 8px; border-radius: 3px; border: 1px solid var(--border-color); color: var(--text-muted); }
|
||||
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
|
||||
.client-name { font-size: 1.25rem; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
|
||||
.badge-id { font-size: 0.7rem; font-weight: 800; background: rgba(128, 128, 128, 0.15); padding: 4px 10px; border-radius: 12px; letter-spacing: 0.5px; border: 1px solid var(--border-color); }
|
||||
/* Telemetria stile terminale */
|
||||
.health-bar { display: flex; justify-content: space-between; font-size: 0.75rem; font-weight: 600; background: #010409; padding: 8px 12px; border-radius: 3px; margin-bottom: 15px; border: 1px solid var(--border-color); font-family: 'JetBrains Mono', monospace; }
|
||||
|
||||
.health-bar { display: flex; justify-content: space-between; font-size: 0.75rem; font-weight: 600; background: rgba(0,0,0,0.05); padding: 8px 12px; border-radius: 12px; margin-bottom: 15px; border: 1px solid rgba(128,128,128,0.1); }
|
||||
body.dark-mode .health-bar { background: rgba(0,0,0,0.2); }
|
||||
/* Display Stato */
|
||||
.status-display { text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; font-weight: 700; padding: 8px; border-radius: 3px; background: #010409; border: 1px solid var(--border-color); margin-bottom: 15px; color: var(--text-muted); }
|
||||
.card.online .status-display { color: var(--success); border-color: rgba(46, 160, 67, 0.4); }
|
||||
.status-offline { color: var(--danger) !important; border-color: rgba(218, 54, 51, 0.4) !important; }
|
||||
|
||||
.status-display { text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; font-weight: 700; padding: 10px; border-radius: 12px; background: rgba(0,0,0,0.05); border: 1px solid var(--border-color); margin-bottom: 15px; transition: all 0.3s; }
|
||||
body.dark-mode .status-display { background: rgba(0,0,0,0.2); color: #10b981; }
|
||||
.status-offline { color: var(--danger) !important; opacity: 0.8; }
|
||||
/* TimeSlots Override */
|
||||
.ts-container { display: flex; flex-direction: column; gap: 6px; margin-bottom: 15px; }
|
||||
.dmr-info { font-size: 0.85rem; font-weight: 600; font-family: 'JetBrains Mono', monospace; padding: 8px 12px; border-radius: 3px; background: #010409 !important; border: 1px solid var(--border-color); border-left: 4px solid var(--border-color) !important; color: var(--text-muted) !important; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* TimeSlots */
|
||||
.ts-container { display: flex; flex-direction: column; gap: 8px; margin-bottom: 15px; }
|
||||
.dmr-info { font-size: 0.85rem; font-weight: 600; padding: 10px 15px; border-radius: 12px; background: rgba(59, 130, 246, 0.1); border-left: 4px solid var(--primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: all 0.2s; }
|
||||
/* Terminal Log interno ai pannelli */
|
||||
.terminal-log { background: #010409; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; border-radius: 3px; padding: 10px; height: 130px; overflow-y: auto; border: 1px solid var(--border-color); line-height: 1.4; margin-bottom: 15px; box-shadow: inset 0 2px 10px rgba(0,0,0,0.5); }
|
||||
|
||||
/* Terminal Log */
|
||||
.terminal-log { background: #0f172a; color: #10b981; font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; border-radius: 12px; padding: 12px; height: 130px; overflow-y: auto; border: 1px solid rgba(255,255,255,0.1); line-height: 1.5; margin-bottom: 15px; box-shadow: inset 0 2px 10px rgba(0,0,0,0.5); }
|
||||
/* Bottoni flat */
|
||||
.actions { display: none; gap: 8px; flex-wrap: wrap; margin-top: auto; }
|
||||
.btn-cmd { flex: 1; padding: 8px 10px; border: 1px solid var(--border-color); background: #010409 !important; border-radius: 3px; font-weight: 600; font-size: 0.75rem; cursor: pointer; color: var(--text-main) !important; font-family: 'JetBrains Mono', monospace; transition: border-color 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 5px; box-shadow: none !important; }
|
||||
.btn-cmd:hover { border-color: var(--text-main); }
|
||||
|
||||
/* Buttons */
|
||||
.actions { display: none; gap: 10px; flex-wrap: wrap; margin-top: auto; }
|
||||
.btn-cmd { flex: 1; padding: 10px 12px; border: none; border-radius: 12px; font-weight: 700; font-size: 0.8rem; cursor: pointer; color: white; transition: all 0.2s ease; box-shadow: 0 4px 6px rgba(0,0,0,0.1); display: flex; align-items: center; justify-content: center; gap: 5px; }
|
||||
.btn-cmd:hover { transform: translateY(-2px); filter: brightness(1.1); box-shadow: 0 6px 12px rgba(0,0,0,0.15); }
|
||||
.btn-cmd:active { transform: translateY(0); }
|
||||
/* Tabella LastHeard */
|
||||
.table-container { background: var(--card-bg); border-radius: 6px; border: 1px solid var(--border-color); overflow: hidden; max-height: 400px; overflow-y: auto; margin: 0 20px 40px 20px; box-shadow: none; }
|
||||
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.85rem; font-family: 'JetBrains Mono', monospace; }
|
||||
thead { background: var(--topbar-bg); position: sticky; top: 0; z-index: 1; border-bottom: 1px solid var(--border-color); }
|
||||
th { padding: 12px 15px; font-weight: 600; text-transform: uppercase; font-size: 0.75rem; color: var(--text-muted); }
|
||||
td { padding: 10px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-main); }
|
||||
|
||||
.table-container { background: var(--card-bg); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border-radius: 20px; border: 1px solid var(--border-color); overflow: hidden; max-height: 400px; overflow-y: auto; box-shadow: var(--glass-shadow); margin: 0 20px 40px 20px; }
|
||||
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.9rem; }
|
||||
/* Animazioni Flat per i transiti */
|
||||
@keyframes flat-blink { 0% { border-color: var(--border-color); } 50% { border-color: var(--danger); } 100% { border-color: var(--border-color); } }
|
||||
.blink { animation: flat-blink 1.5s infinite; color: var(--danger) !important; }
|
||||
|
||||
/* Table Header Frosted Glass */
|
||||
thead { background: rgba(255, 255, 255, 0.9); position: sticky; top: 0; z-index: 1; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05); }
|
||||
body.dark-mode thead { background: rgba(15, 23, 42, 0.95); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.3); }
|
||||
th { padding: 15px; font-weight: 700; text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.5px; color: var(--text-muted); }
|
||||
td { padding: 12px 15px; border-bottom: 1px solid var(--border-color); }
|
||||
@keyframes flat-tx { 0% { border-left-color: var(--border-color); background: #010409; } 50% { border-left-color: var(--primary); background: rgba(47, 129, 247, 0.1); } 100% { border-left-color: var(--border-color); background: #010409; } }
|
||||
.tx-active { animation: flat-tx 1.5s infinite !important; color: var(--text-main) !important; border-color: var(--border-color) !important; }
|
||||
|
||||
@keyframes pulse-glow { 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } 70% { box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); } 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); } }
|
||||
.blink { animation: pulse-glow 1.5s infinite; color: var(--danger) !important; font-weight: 800 !important; background: rgba(239, 68, 68, 0.1) !important; border-left-color: var(--danger) !important; }
|
||||
@keyframes tx-glow { 0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.5); } 70% { box-shadow: 0 0 0 10px rgba(59, 130, 246, 0); } 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); } }
|
||||
.tx-active { animation: tx-glow 1.5s infinite; font-weight: 800 !important; color: var(--text-main) !important; background: rgba(59, 130, 246, 0.25) !important; border-left-color: var(--primary) !important; }
|
||||
/* Finestre Modali (Popup) */
|
||||
.modal-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(1, 4, 9, 0.85); z-index:1000; align-items:center; justify-content:center; }
|
||||
.modal-content { background:var(--card-bg); border:1px solid var(--border-color); padding:25px; border-radius:6px; max-height: 90vh; overflow-y: auto; box-shadow: 0 10px 30px rgba(0,0,0,0.8); }
|
||||
|
||||
/* Modals (Dark Glass) */
|
||||
.modal-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); backdrop-filter:blur(5px); -webkit-backdrop-filter:blur(5px); z-index:1000; align-items:center; justify-content:center; }
|
||||
.modal-content { background:var(--card-bg); backdrop-filter:blur(20px); border:1px solid var(--border-color); padding:30px; border-radius:24px; box-shadow:0 25px 50px -12px rgba(0,0,0,0.5); max-height: 90vh; overflow-y: auto; }
|
||||
/* Elementi Input e Form */
|
||||
.auth-btn { background: #010409; color: var(--text-main); border: 1px solid var(--border-color); padding: 6px 12px; border-radius: 4px; font-weight: 600; cursor: pointer; font-size: 0.8rem; font-family: 'JetBrains Mono', monospace; transition: border-color 0.2s; }
|
||||
.auth-btn:hover { border-color: var(--text-main); }
|
||||
input, select { background: #010409; border: 1px solid var(--border-color); color: var(--text-main); padding: 8px 12px; border-radius: 4px; font-family: 'JetBrains Mono', monospace; outline: none; }
|
||||
input:focus { border-color: var(--primary); }
|
||||
input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
option { background: var(--bg-main); color: var(--text-main); }
|
||||
|
||||
/* Auth Buttons Style */
|
||||
.auth-btn { background: var(--text-main); color: var(--bg-gradient); border: none; padding: 8px 16px; border-radius: 20px; font-weight: 700; cursor: pointer; font-size: 0.85rem; transition: 0.2s; }
|
||||
.auth-btn:hover { opacity: 0.8; }
|
||||
input, select { background: rgba(128,128,128,0.1); border: 1px solid var(--border-color); color: var(--text-main); padding: 10px 15px; border-radius: 12px; font-family: inherit; outline: none; transition: 0.2s; }
|
||||
input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); }
|
||||
input:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
/* Dropdown Fix in Dark Mode */
|
||||
option { background: #ffffff; color: #1e293b; }
|
||||
/* --- OTTIMIZZAZIONE MOBILE (PWA / SMARTPHONE) --- */
|
||||
/* Ottimizzazione Mobile */
|
||||
@media (max-width: 768px) {
|
||||
#top-bar-container { top: 10px; padding: 0 10px; }
|
||||
#top-bar {
|
||||
flex-direction: column;
|
||||
border-radius: 24px;
|
||||
padding: 15px;
|
||||
gap: 12px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
#top-bar { flex-direction: column; padding: 15px; gap: 12px; border-radius: 8px; }
|
||||
.title-brand { font-size: 1.1rem; text-align: center; width: 100%; }
|
||||
#top-bar > div { width: 100%; flex-wrap: wrap; justify-content: center; gap: 8px !important; }
|
||||
#auth-container { width: 100%; justify-content: center; flex-wrap: wrap; padding-top: 12px; border-top: 1px solid var(--border-color); }
|
||||
#auth-container > span { width: 100%; text-align: center; margin-bottom: 8px; font-family: 'JetBrains Mono', monospace; }
|
||||
.auth-btn, .theme-switch { flex: 1 1 auto; text-align: center; }
|
||||
}
|
||||
body.dark-mode option { background: #0f172a; color: #f8fafc; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -162,7 +116,7 @@
|
||||
<div class="title-brand">FLEET CONTROL CONSOLE</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<button class="theme-switch" id="lang-btn" onclick="toggleLang()" data-i18n-title="ttLang">🇮🇹 ITA</button>
|
||||
<button class="theme-switch" id="theme-btn" onclick="toggleTheme()" data-i18n-title="ttTheme">🌙 DARK</button>
|
||||
<button class="theme-switch" id="push-btn" onclick="subscribeToPush()">🔔 PUSH</button>
|
||||
<div id="auth-container" style="display:flex; align-items:center; gap:8px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -459,12 +413,6 @@
|
||||
let currentResetHatId = null;
|
||||
let confirmActionCallback = null;
|
||||
|
||||
function toggleTheme() {
|
||||
const isDark = document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
document.getElementById('theme-btn').innerText = isDark ? t('themeLight') : t('themeDark');
|
||||
}
|
||||
|
||||
// --- NEW GLOBAL GLASSMORPHISM POPUPS ---
|
||||
function customAlert(title, desc, isError = false) {
|
||||
const color = isError ? 'var(--danger)' : 'var(--success)';
|
||||
@@ -584,11 +532,6 @@
|
||||
const grid = document.getElementById('client-grid');
|
||||
const authContainer = document.getElementById('auth-container');
|
||||
|
||||
if (localStorage.getItem('theme') === 'dark') {
|
||||
document.body.classList.add('dark-mode');
|
||||
document.getElementById('theme-btn').innerText = t('themeLight');
|
||||
} else { document.getElementById('theme-btn').innerText = t('themeDark'); }
|
||||
|
||||
const role = sessionStorage.getItem('user_role');
|
||||
const allowed = sessionStorage.getItem('allowed_nodes') || "";
|
||||
|
||||
@@ -1076,7 +1019,69 @@
|
||||
});
|
||||
}
|
||||
|
||||
initUI();
|
||||
// --- FUNZIONE PER ISCRIVERSI ALLE PUSH ---
|
||||
async function subscribeToPush() {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
customAlert("Error", "Push notifications permission denied.", 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", "Push notifications enabled successfully!");
|
||||
document.getElementById('push-btn').style.color = 'var(--success)';
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
customAlert("Error", "Failed to enable notifications.", true);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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