14 Commits

Author SHA1 Message Date
iv3jdv 1780a4a737 Change dashboard theme 2026-04-22 19:34:14 +02:00
iv3jdv 728233998b Change dashboard theme 2026-04-22 19:32:52 +02:00
iv3jdv 080a6af776 update 2026-04-22 14:34:26 +02:00
iv3jdv 2a8815a6bd update 2026-04-22 14:29:37 +02:00
iv3jdv 71cbc78cb7 update 2026-04-22 11:18:30 +02:00
iv3jdv 6c6073b966 Fix: default user config 2026-04-22 11:04:59 +02:00
iv3jdv b587669f0c update README 2026-04-22 11:04:19 +02:00
iv3jdv 69d7e885bd makeup 2026-04-22 02:58:07 +02:00
iv3jdv 66d22411a4 update Push Notification 2026-04-22 02:08:51 +02:00
iv3jdv 324b066f51 Add Push Notification 2026-04-22 01:43:09 +02:00
iv3jdv 541e6f1ce3 Fix: allow_unsafe_werkzeug=true 2026-04-21 22:49:16 +02:00
iv3jdv 4b58bebe2a Fix: allow_unsafe_werkzeug=true 2026-04-21 22:48:10 +02:00
iv3jdv 8959c8f1cf Merge pull request #1 from picchiosat/websocket-upgrade
Aggiunti WebSockets al frontend
2026-04-21 22:35:29 +02:00
iv3jdv 7da6471ff9 Enanced auto-healing 2026-04-21 14:32:56 +02:00
12 changed files with 594 additions and 195 deletions
+2
View File
@@ -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
+46 -4
View File
@@ -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*
+21
View File
@@ -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!"
+145 -52
View File
@@ -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
View File
@@ -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
View File
@@ -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
============================================================
+6
View File
@@ -0,0 +1,6 @@
Flask
Flask-SocketIO
paho-mqtt
psutil
Werkzeug
pywebpush
+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 = [
'/',
'/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('/')
);
}
});
+15
View File
@@ -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
+18
View File
@@ -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
View File
@@ -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();
+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.")