enhanced security and optimization

This commit is contained in:
2026-10-14 20:55:20 +02:00
parent b31fac8eaa
commit d9d7990ad9
6 changed files with 3130 additions and 409 deletions
+789 -307
View File
File diff suppressed because it is too large Load Diff
+907
View File
@@ -0,0 +1,907 @@
from flask import Flask, render_template, request, session, jsonify, send_from_directory
from paho.mqtt import client as mqtt_client
from werkzeug.security import generate_password_hash, check_password_hash
import json
import os
import sqlite3
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
# --- DYNAMIC BASE PATH ---
# Calcola automaticamente la directory esatta in cui si trova questo file (app.py)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# --- PATHS ---
DB_PATH = os.path.join(BASE_DIR, 'monitor.db')
CACHE_FILE = os.path.join(BASE_DIR, 'telemetry_cache.json')
CONFIG_PATH = os.path.join(BASE_DIR, 'config.json')
DMR_IDS_PATH = os.path.join(BASE_DIR, 'dmrid.dat')
NXDN_IDS_PATH = os.path.join(BASE_DIR, 'nxdn.csv')
CLIENTS_PATH = os.path.join(BASE_DIR, 'clients.json')
LOG_FILE = os.path.join(BASE_DIR, 'fleet_console.log')
# --- LOGGING CONFIGURATION ---
logging.basicConfig(
handlers=[
RotatingFileHandler(LOG_FILE, maxBytes=10000000, backupCount=3),
logging.StreamHandler()
],
level=logging.INFO,
format='[%(asctime)s] %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger("FleetHub")
# Silence HTTP request spam (GET /api/states 200 OK)
logging.getLogger('werkzeug').setLevel(logging.ERROR)
def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
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,
source_id TEXT, target TEXT, slot INTEGER, duration REAL, ber REAL, loss REAL)''')
try:
c.execute("ALTER TABLE radio_logs ADD COLUMN protocol TEXT DEFAULT 'DMR'")
except: pass
try:
c.execute("ALTER TABLE radio_logs ADD COLUMN source_ext TEXT DEFAULT ''")
except: pass
c.execute('''CREATE TABLE IF NOT EXISTS users
(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:
# 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()
# --- ID DATABASE LOADING ---
user_db = {}
nxdn_db = {}
def load_ids():
global user_db, nxdn_db
user_db.clear()
nxdn_db.clear()
if os.path.exists(DMR_IDS_PATH):
with open(DMR_IDS_PATH, 'r', encoding='utf-8', errors='ignore') as f:
for l in f:
l = l.replace('"', '') # Rimuove eventuali virgolette
if ',' in l: p = l.strip().split(',')
elif '\t' in l: p = l.strip().split('\t')
elif ';' in l: p = l.strip().split(';')
else: p = l.strip().split() # Cerca gli spazi normali
if len(p) >= 2 and p[0].strip().isdigit():
user_db[p[0].strip()] = p[1].strip()
if os.path.exists(NXDN_IDS_PATH):
with open(NXDN_IDS_PATH, 'r', encoding='utf-8', errors='ignore') as f:
for l in f:
sep = '\t' if '\t' in l else (',' if ',' in l else ';')
p = l.strip().split(sep)
if len(p) >= 2 and p[0].strip().isdigit():
nxdn_db[p[0].strip()] = p[1].strip()
load_ids()
def get_call(id, proto="DMR"):
sid = str(id)
if proto == "NXDN": return nxdn_db.get(sid, sid)
return user_db.get(sid, sid)
def save_cache(data):
with open(CACHE_FILE, 'w') as f: json.dump(data, f)
socketio.emit('dati_aggiornati')
def save_to_sqlite(client_id, data, protocol="DMR"):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("INSERT INTO radio_logs (timestamp, client_id, protocol, source_id, target, slot, duration, ber, source_ext) VALUES (datetime('now', 'localtime'), ?, ?, ?, ?, ?, ?, ?, ?)",
(client_id, protocol, str(data.get('src_id', '---')), str(data.get('dst_id', '---')), data.get('slot', 0), round(data.get('duration', 0), 1), round(data.get('ber', 0), 2), str(data.get('src_ext', ''))))
conn.commit()
conn.close()
socketio.emit('dati_aggiornati')
app = Flask(__name__)
socketio = SocketIO(app, cors_allowed_origins="*")
app.secret_key = 'ari_fvg_secret_ultra_secure'
client_states = {}
device_configs = {}
client_telemetry = {}
last_notified_errors = {}
device_health = {}
last_seen_reflector = {}
network_mapping = {}
node_info = {}
node_general = {}
if os.path.exists(CACHE_FILE):
try:
with open(CACHE_FILE, 'r') as f: client_telemetry = json.load(f)
except: client_telemetry = {}
active_calls = {}
with open(CONFIG_PATH) as f: config = json.load(f)
# --- MQTT CALLBACKS ---
mqtt_connected_status = False
def on_connect(client, userdata, flags, reason_code, properties=None):
global mqtt_connected_status
if reason_code == 0:
mqtt_connected_status = True
logger.info("✅ Successfully connected to MQTT Broker! Subscribing to topics...")
# Invia lo stato Online ai client web
socketio.emit('mqtt_status', {'connected': True})
# --- LETTURA DINAMICA DEI TOPIC ---
default_topics = [
"servizi/+/stat", "dmr-gateway/+/json", "devices/+/services",
"nxdn-gateway/+/json", "ysf-gateway/+/json", "p25-gateway/+/json",
"dstar-gateway/+/json", "mmdvm/+/json", "devices/#", "data/#"
]
# Cerca la lista "topics" nel config.json, se non la trova usa quella di default
topics_list = config.get('mqtt', {}).get('topics', default_topics)
# Converte la lista di stringhe nel formato richiesto da paho-mqtt: [(topic, qos), (topic, qos)...]
subscribe_list = [(topic, 0) for topic in topics_list]
client.subscribe(subscribe_list)
logger.info(f"Subscribed to {len(subscribe_list)} MQTT topics.")
else:
mqtt_connected_status = False
socketio.emit('mqtt_status', {'connected': False})
logger.error(f"❌ MQTT Connection Error. Reason code: {reason_code}")
def on_disconnect(client, userdata, disconnect_flags, reason_code, properties=None):
global mqtt_connected_status
mqtt_connected_status = False
# Invia lo stato Offline ai client web
socketio.emit('mqtt_status', {'connected': False})
logger.warning(f"⚠️ MQTT Disconnection detected! Reason code: {reason_code}. Attempting automatic reconnection...")
# Quando un nuovo utente apre la pagina web, inviagli subito lo stato attuale
@socketio.on('connect')
def handle_connect():
emit('mqtt_status', {'connected': mqtt_connected_status})
def on_message(client, userdata, msg):
try:
topic = msg.topic
payload = msg.payload.decode().strip()
parts = topic.split('/')
if len(parts) < 2: return
cid = parts[1].lower()
# --- MAGIA DELLA NORMALIZZAZIONE ---
# Prendiamo i blocchi del topic, li facciamo minuscoli e togliamo i trattini
# Così "DMRGateway", "dmr-gateway" e "dmrgateway" diventano tutti identici per noi
p0 = parts[0].lower().replace('-', '')
p2 = parts[2].lower().replace('-', '') if len(parts) > 2 else ""
# --- CAPTURE FULL CONFIGURATIONS ---
if p0 == 'data' and len(parts) >= 4 and parts[3].lower() == 'full_config':
cid_conf = parts[1].lower()
svc_name = parts[2].lower()
if cid_conf not in device_configs:
device_configs[cid_conf] = {}
try:
device_configs[cid_conf][svc_name] = json.loads(payload)
logger.debug(f"Configuration saved for {cid_conf} -> {svc_name}")
except Exception as e:
logger.error(f"Error parsing config JSON: {e}")
# --- NODE AND SERVICE STATE MANAGEMENT ---
elif p0 == '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": "Waiting...", "ts2": "Waiting...", "alt": ""}
save_cache(client_telemetry)
# --- DEVICE HEALTH MANAGEMENT ---
elif p0 == 'devices' and len(parts) >= 3 and p2 == 'services':
try:
data = json.loads(payload)
device_health[cid] = {
"cpu": round(data.get("cpu_usage_percent", 0), 1),
"temp": round(data.get("cpu_temp", 0), 1),
"ram": round(data.get("memory_usage_percent", 0), 1),
"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": "PROFILE A", "B": "PROFILE B"})
}
socketio.emit('dati_aggiornati') # <--- WEBSOCKET
# --- 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 p0 == 'data' and p2 == 'dmrgateway' and (parts[3].upper().startswith('NETWORK') or parts[3].upper().startswith('DMR NETWORK')):
try:
cid = parts[1].lower()
data = json.loads(payload)
if cid not in network_mapping:
network_mapping[cid] = {"ts1": "", "ts2": ""}
if str(data.get("Enabled")) == "1":
net_name = data.get("Name", "Net").upper()
is_ts1 = False
is_ts2 = False
keys_to_check = ["PassAllTG", "PassAllPC", "TGRewrite", "PCRewrite", "TypeRewrite", "SrcRewrite"]
for k in keys_to_check:
val = str(data.get(k, "")).strip()
if val.startswith("1"): is_ts1 = True
if val.startswith("2"): is_ts2 = True
if is_ts1: network_mapping[cid]["ts1"] = net_name
if is_ts2: network_mapping[cid]["ts2"] = net_name
socketio.emit('dati_aggiornati') # <--- WEBSOCKET
except Exception as e:
logger.error(f"Error parsing DMRGateway for {cid}: {e}")
# --- MMDVMHOST INFO MANAGEMENT (FREQUENZE & LOCATION) ---
elif len(parts) >= 4 and p0 == 'data' and p2 == 'mmdvm' and parts[3].lower() == 'info':
try:
cid = parts[1].lower()
data = json.loads(payload)
# Estrazione dati
tx = data.get("TXFrequency", "0")
rx = data.get("RXFrequency", "0")
lat = data.get("Latitude", "0.0")
lon = data.get("Longitude", "0.0")
loc = data.get("Location", "Sconosciuta")
def format_freq(f):
if str(f).isdigit() and int(f) > 0:
return f"{int(f)/1000000:.3f} MHz"
return str(f)
node_info[cid] = {
"tx": format_freq(tx),
"rx": format_freq(rx),
"lat": lat,
"lon": lon,
"loc": loc
}
socketio.emit('dati_aggiornati')
except Exception as e:
logger.error(f"Error parsing MMDVMHost info for {cid}: {e}")
# --- MMDVMHOST GENERAL MANAGEMENT (CALLSIGN & ID & DUPLEX) ---
elif len(parts) >= 4 and p0 == 'data' and p2 == 'mmdvm' and parts[3].lower() == 'general':
try:
cid = parts[1].lower()
data = json.loads(payload)
callsign = data.get("Callsign", "")
radio_id = data.get("Id", "")
duplex = data.get("Duplex", "1") # 1 = Repeater, 0 = Simplex
if callsign:
node_general[cid] = {"callsign": callsign, "radio_id": radio_id, "duplex": str(duplex)}
socketio.emit('dati_aggiornati')
except Exception as e:
logger.error(f"Error parsing MMDVMHost general for {cid}: {e}")
# --- OTHER GATEWAYS MANAGEMENT ---
elif p0 in ['dmrgateway', 'nxdngateway', 'ysfgateway', 'p25gateway', 'dstargateway']:
data = json.loads(payload)
proto = "DMR"
if "nxdn" in p0: proto = "NXDN"
elif "ysf" in p0: proto = "YSF"
elif "p25" in p0: proto = "P25"
elif "dstar" in p0: proto = "D-STAR"
m = ""
if 'status' in data:
m = data['status'].get('message', '')
elif 'link' in data:
l = data['link']
dest = str(l.get('reflector') or l.get('talkgroup') or '---').strip()
action = l.get('action')
if action == 'linking': last_seen_reflector[f"{cid}_{proto}"] = dest
elif action == 'unlinking': last_seen_reflector[f"{cid}_{proto}"] = "---"
m = f"{'Link' if action=='linking' else 'Unlinked'} {dest}"
if m: save_to_sqlite(cid, {'src_id': "🌐 " + m, 'dst_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": "Waiting...", "ts2": "Waiting...", "alt": "", "idle": True}
if 'MMDVM' in data and data['MMDVM'].get('mode') == 'idle':
client_telemetry[cid]["idle"] = True
save_cache(client_telemetry)
return
client_telemetry[cid]["idle"] = False
if 'DMR' in data:
d = data['DMR']
act = d.get('action')
sk = f"ts{d.get('slot', 1)}"
if act in ['start', 'late_entry']:
src = get_call(d.get('src_id'))
dst = str(d.get('dst_id'))
active_calls[cid][sk] = {'src': src, 'dst': dst}
client_telemetry[cid]["alt"] = ""
client_telemetry[cid][sk] = f"🎙️ {src} ➔ TG {dst}"
socketio.emit('dati_aggiornati') # <--- WEBSOCKET
elif act in ['end', 'lost']:
info = active_calls[cid].get(sk, {'src': '---', 'dst': '---'})
d['src_id'], d['dst_id'] = info['src'], info['dst']
save_to_sqlite(cid, d, protocol="DMR")
client_telemetry[cid][sk] = f"{'✅' if act=='end' else '⚠️'} {info['src']}"
save_cache(client_telemetry)
if sk in active_calls[cid]: del active_calls[cid][sk]
else:
for k, ico, name in [('NXDN','🟢','NXDN'),('YSF','🟣','YSF'),('P25','🟠','P25'),('D-Star','🔵','D-STAR')]:
if k in data:
p = data[k]
act = p.get('action')
if act == 'start':
if k == 'NXDN': src = get_call(p.get('src_id', '---'), 'NXDN')
elif k == 'P25': src = get_call(p.get('src_id', '---'), 'DMR')
else: src = str(p.get('Callsign', p.get('src_callsign', p.get('src_info', p.get('src_id', '---')))))
t_list = [p.get('reflector'), p.get('dst_callsign'), p.get('dst_id')]
current_target = next((str(x).strip() for x in t_list if x and str(x).strip() not in ['', '---', '0', 'CQCQCQ']), None)
if not current_target or current_target == cid.upper():
target = last_seen_reflector.get(f"{cid}_{name}", "---")
else:
target = current_target
active_calls[cid][k] = {'src': src, 'dst': target, 'ext': str(p.get('src_ext', ''))}
client_telemetry[cid].update({"ts1":"","ts2":"","alt": f"{ico} {name}: {src} ➔ {target}"})
socketio.emit('dati_aggiornati') # <--- WEBSOCKET
elif act in ['end', 'lost']:
info = active_calls[cid].get(k, {'src': '---', 'dst': '---', 'ext': ''})
p.update({'src_id': info['src'], 'dst_id': info['dst'], 'src_ext': info['ext']})
save_to_sqlite(cid, p, protocol=name)
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"MQTT MSG ERROR: {e}")
# --- MQTT CLIENT INITIALIZATION ---
# Recuperiamo l'ID dal config. Se per caso manca, usiamo un default di emergenza.
mqtt_id = config['mqtt'].get('client_id', 'flask_backend_generic')
mqtt_backend = mqtt_client.Client(mqtt_client.CallbackAPIVersion.VERSION2, mqtt_id)
mqtt_backend.username_pw_set(config['mqtt']['user'], config['mqtt']['password'])
mqtt_backend.on_connect = on_connect
mqtt_backend.on_disconnect = on_disconnect
mqtt_backend.on_message = on_message
try:
# Usiamo connect_async! Non blocca il server se l'IP è irraggiungibile
mqtt_backend.connect_async(config['mqtt']['broker'], config['mqtt']['port'])
mqtt_backend.loop_start()
logger.info(f"Avvio connessione MQTT asincrona verso {config['mqtt']['broker']}...")
except Exception as e:
logger.error(f"Errore critico nell'inizializzazione MQTT: {e}")
@app.route('/')
def index(): return render_template('index.html')
@app.route('/api/clients')
def get_clients():
if os.path.exists(CLIENTS_PATH):
with open(CLIENTS_PATH, 'r') as f: return jsonify(json.load(f))
return jsonify([])
@app.route('/api/logs')
def get_logs():
conn = sqlite3.connect(DB_PATH, timeout=10)
c = conn.cursor()
c.execute("SELECT timestamp, client_id, protocol, source_id, target, slot, duration, ber, source_ext FROM radio_logs ORDER BY id DESC LIMIT 60")
logs = c.fetchall()
conn.close()
return jsonify(logs)
@app.route('/api/states', methods=['GET'])
def get_states():
return jsonify({
"states": client_states,
"telemetry": client_telemetry,
"health": device_health,
"networks": network_mapping,
"info": node_info,
"general": node_general
})
@app.route('/api/stats')
def get_stats():
# Leggiamo il parametro 'node' (di default 'all')
node = request.args.get('node', 'all').lower()
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# Prepariamo il filtro aggiuntivo se è stato selezionato un nodo
node_filter = ""
params = []
if node != 'all':
node_filter = " AND LOWER(client_id) = ?"
params.append(node)
# 1. Top 5 TalkGroups di OGGI
c.execute(f"""SELECT target, COUNT(*) as cnt FROM radio_logs
WHERE target NOT IN ('---', 'NET', '')
AND date(timestamp) = date('now', 'localtime'){node_filter}
GROUP BY target ORDER BY cnt DESC LIMIT 5""", params)
top_tgs = [{"target": row[0], "count": row[1]} for row in c.fetchall()]
# 2. Top 5 Callsign di OGGI
c.execute(f"""SELECT source_id, COUNT(*) as cnt FROM radio_logs
WHERE source_id NOT LIKE '🌐%'
AND date(timestamp) = date('now', 'localtime'){node_filter}
GROUP BY source_id ORDER BY cnt DESC LIMIT 5""", params)
top_calls = [{"call": row[0], "count": row[1]} for row in c.fetchall()]
# 3. Tempo medio dei transiti di OGGI
c.execute(f"""SELECT AVG(duration) FROM radio_logs
WHERE duration > 0.5
AND date(timestamp) = date('now', 'localtime'){node_filter}""", params)
avg_dur = c.fetchone()[0]
avg_dur = round(avg_dur, 1) if avg_dur else 0
# 4. Totale transiti di OGGI
c.execute(f"SELECT COUNT(*) FROM radio_logs WHERE date(timestamp) = date('now', 'localtime'){node_filter}", params)
today_tx = c.fetchone()[0]
conn.close()
return jsonify({
"top_tgs": top_tgs,
"top_calls": top_calls,
"avg_duration": avg_dur,
"today_tx": today_tx
})
@app.route('/api/service_control', methods=['POST'])
def service_control():
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
d = request.json
cid = d.get('clientId').lower()
action = d.get('action')
service = d.get('service')
mqtt_backend.publish(f"devices/{cid}/control", f"{action}:{service}")
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("INSERT INTO audit_logs (timestamp, username, client_id, command) VALUES (datetime('now','localtime'), ?, ?, ?)",
(session.get('user'), cid, f"SVC_{action.upper()}_{service}"))
conn.commit()
conn.close()
return jsonify({"success": True})
@app.route('/api/login', methods=['POST'])
def login():
d = request.json
username, password = d.get('user'), d.get('pass')
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("SELECT * FROM users WHERE username = ?", (username,))
user = c.fetchone()
conn.close()
if user and check_password_hash(user['password_hash'], password):
session['logged_in'] = True
session['user'] = user['username']
session['role'] = user['role']
session['allowed_nodes'] = user['allowed_nodes']
return jsonify({"success": True, "role": user['role'], "allowed_nodes": user['allowed_nodes']})
return jsonify({"success": False}), 401
@app.route('/api/command', methods=['POST'])
def cmd():
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']
username = session.get('user')
role = session.get('role')
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": "Only Admins can reboot."}), 403
if is_allowed:
mqtt_backend.publish(f"servizi/{cid}/cmnd", cmd_type)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("INSERT INTO audit_logs (timestamp, username, client_id, command) VALUES (datetime('now','localtime'), ?, ?, ?)",
(username, cid, cmd_type))
conn.commit()
conn.close()
client_telemetry[cid] = {"ts1": "🔄 Sent...", "ts2": "🔄 Sent...", "alt": ""}
return jsonify({"success": True})
return jsonify({"success": False, "error": "You do not have permission for this node."}), 403
@app.route('/api/update_nodes', methods=['POST'])
def update_nodes():
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
mqtt_backend.publish("devices/control/request", "update")
return jsonify({"success": True})
# Mandiamo il comando "update" direttamente nel topic privato di ciascun nodo
for client in clients_list:
cid = client['id'].lower()
mqtt_backend.publish(f"devices/{cid}/control", "update", qos=1)
logger.info("📢 Inviato comando REQ CONFIG diretto a tutti i nodi della flotta.")
return jsonify({"success": True})
@app.route('/api/users', methods=['GET'])
def get_users():
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("SELECT id, username, role, allowed_nodes FROM users")
users = [dict(row) for row in c.fetchall()]
conn.close()
return jsonify(users)
@app.route('/api/users', methods=['POST'])
def add_user():
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": "Missing data"})
h = generate_password_hash(password)
try:
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("INSERT INTO users (username, password_hash, role, allowed_nodes) VALUES (?,?,?,?)",
(username, h, role, allowed))
conn.commit()
conn.close()
return jsonify({"success": True})
except sqlite3.IntegrityError:
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": "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": "You cannot delete yourself!"})
c.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
conn.close()
return jsonify({"success": True})
@app.route('/api/users/<int:user_id>', methods=['PUT'])
def update_user(user_id):
if session.get('role') != 'admin':
return jsonify({"error": "Unauthorized"}), 403
data = request.json
role = data.get('role', 'operator')
allowed = data.get('allowed_nodes', 'all')
password = data.get('password')
try:
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
if password and password.strip() != "":
h = generate_password_hash(password)
c.execute("UPDATE users SET password_hash=?, role=?, allowed_nodes=? WHERE id=?",
(h, role, allowed, user_id))
else:
c.execute("UPDATE users SET role=?, allowed_nodes=? WHERE id=?",
(role, allowed, user_id))
conn.commit()
conn.close()
return jsonify({"success": True})
except Exception as e:
return jsonify({"success": False, "error": str(e)})
@app.route('/api/change_password', methods=['POST'])
def change_password():
if not session.get('logged_in'):
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": "Unauthorized"}), 403
if not new_pass:
return jsonify({"success": False, "error": "Password cannot be empty"}), 400
h = generate_password_hash(new_pass)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("UPDATE users SET password_hash = ? WHERE username = ?", (h, user_to_change))
conn.commit()
conn.close()
return jsonify({"success": True})
@app.route('/api/global_command', methods=['POST'])
def global_cmd():
if session.get('role') != 'admin':
return jsonify({"success": False, "error": "Admin action only!"}), 403
d = request.json
cmd_type = d.get('type')
clients_list = []
if os.path.exists(CLIENTS_PATH):
with open(CLIENTS_PATH, 'r') as f:
clients_list = json.load(f)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
for client in clients_list:
cid = client['id'].lower()
mqtt_backend.publish(f"servizi/{cid}/cmnd", cmd_type)
c.execute("INSERT INTO audit_logs (timestamp, username, client_id, command) VALUES (datetime('now','localtime'), ?, ?, ?)",
(session.get('user'), cid, f"GLOBAL_OVERRIDE_{cmd_type}"))
conn.commit()
conn.close()
return jsonify({"success": True})
def auto_update_ids():
"""Versione corretta: controlla all'avvio e poi ogni notte."""
def download_logic():
try:
# Usiamo esattamente i nomi e le URL definiti nel tuo config/codice
with open(CONFIG_PATH, 'r') as f:
current_cfg = json.load(f)
urls = current_cfg.get("id_urls", {
"dmr": "https://radioid.net/static/users.csv",
"nxdn": "https://radioid.net/static/nxdn.csv"
})
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}
logger.info("📡 Inizio download database ID (DMR e NXDN)...")
# Download DMR -> dmrid.dat
req_dmr = urllib.request.Request(urls["dmr"], headers=headers)
with urllib.request.urlopen(req_dmr) as response, open(DMR_IDS_PATH, 'wb') as out_file:
out_file.write(response.read())
# Download NXDN -> nxdn.csv
req_nxdn = urllib.request.Request(urls["nxdn"], headers=headers)
with urllib.request.urlopen(req_nxdn) as response, open(NXDN_IDS_PATH, 'wb') as out_file:
out_file.write(response.read())
load_ids()
logger.info("✅ Aggiornamento completato con successo.")
except Exception as e:
logger.error(f"❌ Errore durante il download: {e}")
# --- CONTROLLO INIZIALE ALL'AVVIO ---
if not os.path.exists(DMR_IDS_PATH) or not os.path.exists(NXDN_IDS_PATH):
logger.info("🔍 File ID mancanti. Avvio download immediato...")
download_logic()
# --- CICLO NOTTURNO ---
while True:
try:
now = time.strftime("%H:%M")
with open(CONFIG_PATH, 'r') as f:
target_time = json.load(f).get("update_schedule", "03:00")
if now == target_time:
logger.info(f"⏰ Orario programmato ({target_time}) raggiunto. Aggiorno...")
download_logic()
time.sleep(65)
except Exception as e:
logger.error(f"⚠️ Errore nel thread update: {e}")
time.sleep(30)
@app.route('/api/ui_config', methods=['GET'])
def get_ui_config():
try:
with open(CONFIG_PATH, 'r') as f:
cfg = json.load(f)
ui_cfg = cfg.get("ui", {
"profileA_Name": "PROFILE A",
"profileA_Color": "#3498db",
"profileB_Name": "PROFILE B",
"profileB_Color": "#9b59b6"
})
return jsonify(ui_cfg)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/config', methods=['GET'])
def get_config_api():
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
with open(CONFIG_PATH, 'r') as f:
cfg = json.load(f)
return jsonify({
"update_schedule": cfg.get("update_schedule", "03:00"),
"url_dmr": cfg.get("id_urls", {}).get("dmr", ""),
"url_nxdn": cfg.get("id_urls", {}).get("nxdn", "")
})
@app.route('/api/config', methods=['POST'])
def save_config_api():
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)
cfg["update_schedule"] = new_data.get("update_schedule", "03:00")
if "id_urls" not in cfg: cfg["id_urls"] = {}
cfg["id_urls"]["dmr"] = new_data.get("url_dmr", "")
cfg["id_urls"]["nxdn"] = new_data.get("url_nxdn", "")
with open(CONFIG_PATH, 'w') as f:
json.dump(cfg, f, indent=4)
return jsonify({"success": True})
@app.route('/api/config_file/<cid>/<service>', methods=['GET'])
def get_config_file(cid, service):
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": "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": "Unauthorized"}), 403
d = request.json
cid = d.get('clientId').lower()
service = d.get('service').lower()
new_config = d.get('config_data')
topic_set = f"devices/{cid}/config_set/{service}"
mqtt_backend.publish(topic_set, json.dumps(new_config))
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("INSERT INTO audit_logs (timestamp, username, client_id, command) VALUES (datetime('now','localtime'), ?, ?, ?)",
(session.get('user'), cid, f"EDIT_CONFIG_{service.upper()}"))
conn.commit()
conn.close()
return jsonify({"success": True})
@app.route('/manifest.json')
def serve_manifest():
return send_from_directory('.', 'manifest.json')
@app.route('/sw.js')
def serve_sw():
return send_from_directory('.', 'sw.js')
@app.route('/icon-512.png')
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})
threading.Thread(target=auto_update_ids, daemon=True).start()
if __name__ == '__main__':
socketio.run(app, host='0.0.0.0', port=9000)
+101 -43
View File
@@ -1,63 +1,121 @@
const CACHE_NAME = 'fleet-c2-v2'; // Incrementiamo la versione const CACHE_NAME = 'fleet-c2-v3'; // incrementare a ogni modifica degli asset precaricati
const urlsToCache = [ const PRECACHE_URLS = [
'/', '/',
'/manifest.json', '/manifest.json',
'/icon-512.png' '/icon-512.png'
]; ];
// --- 1. GESTIONE CACHE (Il tuo codice originale) --- // --- 1. INSTALL: precache "best effort" -------------------------------------
// Se un file manca (es. icona non ancora caricata) l'installazione NON fallisce:
// un SW che non si installa significa niente notifiche push.
self.addEventListener('install', event => { self.addEventListener('install', event => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME) caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache)) .then(cache => Promise.allSettled(PRECACHE_URLS.map(url => cache.add(url))))
.then(() => self.skipWaiting())
); );
}); });
// --- 2. ACTIVATE: elimina le cache delle versioni precedenti -----------------
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys()
.then(keys => Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))))
.then(() => self.clients.claim())
);
});
// --- 3. FETCH: network-first, cache come ripiego offline ---------------------
// Gestiamo solo le GET della nostra origine. API e WebSocket (socket.io) passano
// sempre direttamente dalla rete: i dati della flotta non vanno mai serviti "vecchi".
self.addEventListener('fetch', event => { self.addEventListener('fetch', event => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith('/api/') ||
url.pathname.startsWith('/socket.io/') ||
url.pathname === '/sw.js') return;
event.respondWith( event.respondWith(
fetch(event.request).catch(() => caches.match(event.request)) fetch(req)
.then(res => {
if (res && res.ok && res.type === 'basic') {
const copy = res.clone();
caches.open(CACHE_NAME).then(cache => cache.put(req, copy)).catch(() => {});
}
return res;
})
.catch(async () => {
const cached = await caches.match(req);
if (cached) return cached;
if (req.mode === 'navigate') {
const shell = await caches.match('/');
if (shell) return shell;
}
return new Response('Offline', {
status: 503,
statusText: 'Offline',
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
});
})
); );
}); });
// --- 2. GESTIONE NOTIFICHE PUSH (Il nuovo codice) --- // --- 4. NOTIFICHE PUSH -------------------------------------------------------
self.addEventListener('push', function(event) { self.addEventListener('push', event => {
console.log('[Service Worker] Notifica Push ricevuta.'); let data = { title: 'Fleet Alert', body: 'Nuovo messaggio dal sistema.' };
let data = { title: 'Fleet Alert', body: 'Nuovo messaggio dal sistema.' }; if (event.data) {
try {
data = Object.assign(data, event.data.json());
} catch (e) {
data.body = event.data.text();
}
}
if (event.data) { const options = {
try { body: data.body,
data = event.data.json(); icon: '/icon-512.png',
} catch (e) { badge: '/icon-512.png',
data.body = event.data.text(); vibrate: [200, 100, 200],
// Stesso tag = la nuova notifica dello stesso nodo sostituisce la precedente
// (invece di accumulare 10 notifiche) e rifà vibrare/suonare.
tag: data.tag || 'fleet-alert',
renotify: true,
data: {
dateOfArrival: Date.now(),
url: data.url || '/'
},
actions: [
{ action: 'explore', title: 'Apri Dashboard' },
{ action: 'close', title: 'Chiudi' }
]
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
// --- 5. CLICK SULLA NOTIFICA -------------------------------------------------
// Click sul corpo O su "Apri Dashboard": porta in primo piano la finestra già aperta,
// altrimenti ne apre una nuova. "Chiudi" chiude soltanto.
self.addEventListener('notificationclick', event => {
event.notification.close();
if (event.action === 'close') return;
const target = new URL((event.notification.data && event.notification.data.url) || '/', self.location.origin).href;
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => {
for (const client of list) {
if (new URL(client.url).origin === self.location.origin && 'focus' in client) {
return client.focus();
} }
} }
return clients.openWindow(target);
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('/')
);
}
}); });
+63
View File
@@ -0,0 +1,63 @@
const CACHE_NAME = 'fleet-c2-v2'; // Incrementiamo la versione
const urlsToCache = [
'/',
'/manifest.json',
'/icon-512.png'
];
// --- 1. GESTIONE CACHE (Il tuo codice originale) ---
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', event => {
event.respondWith(
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('/')
);
}
});
+139 -59
View File
@@ -53,6 +53,10 @@
.freq-rx { color: #4ade80; } /* Verde per la RX */ .freq-rx { color: #4ade80; } /* Verde per la RX */
/* Metadati Nodo (Location, Coordinate) */ /* Metadati Nodo (Location, Coordinate) */
.seen-bar { display: flex; flex-wrap: wrap; justify-content: center; gap: 4px 14px; font-size: 0.7rem; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); margin: -6px 0 12px 0; }
.seen-item b { font-weight: 700; }
.seen-item.ok b { color: var(--success); }
.seen-item.warn, .seen-item.warn b { color: #d29922; font-weight: 700; }
.node-meta { font-size: 0.75rem; color: #8b949e; text-align: center; margin-bottom: 15px; line-height: 1.4; } .node-meta { font-size: 0.75rem; color: #8b949e; text-align: center; margin-bottom: 15px; line-height: 1.4; }
.node-meta-item { display: block; } .node-meta-item { display: block; }
@@ -364,8 +368,6 @@
</div> </div>
</div> </div>
</div>
<div id="reset-hat-modal" class="modal-overlay"> <div id="reset-hat-modal" class="modal-overlay">
<div class="modal-content" style="width:90%; max-width:400px; text-align:center; border: 1px solid var(--danger);"> <div class="modal-content" style="width:90%; max-width:400px; text-align:center; border: 1px solid var(--danger);">
<h2 style="margin-top:0; color:var(--danger);">🔌 <span data-i18n="btnHat">RESET HAT</span> 🔌</h2> <h2 style="margin-top:0; color:var(--danger);">🔌 <span data-i18n="btnHat">RESET HAT</span> 🔌</h2>
@@ -471,9 +473,64 @@
else if (newPassInput) newPassInput.placeholder = t('phPass'); else if (newPassInput) newPassInput.placeholder = t('phPass');
} }
// --- HELPER DI SICUREZZA ---
// esc(): escape HTML per tutto ciò che arriva da MQTT / database / utenti prima di finire in innerHTML
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
// q(): letterale JS sicuro da usare dentro onclick="...": onclick="fn(${q(valore)})"
function q(s) { return esc(JSON.stringify(String(s ?? ''))); }
const HANG_TIME_MS = 180000; // tempo massimo in cui la card resta "accesa" dopo l'ultimo TX
// --- DIAGNOSTICA: "ultimo messaggio ricevuto" per nodo (MMDVM / gateway / agent) ---
function fmtAge(sec) {
sec = Math.max(0, Math.round(sec));
if (sec < 60) return sec + 's';
if (sec < 3600) return Math.floor(sec / 60) + 'm';
if (sec < 86400) return Math.floor(sec / 3600) + 'h';
return Math.floor(sec / 86400) + 'd';
}
function seenItem(label, age, tip, warn) {
const cls = warn ? 'warn' : (age !== undefined && age < 300 ? 'ok' : 'idle');
const txt = age === undefined ? '—' : fmtAge(age);
return `<span class="seen-item ${cls}" title="${esc(tip)}">${warn ? '⚠ ' : ''}${label} <b>${txt}</b></span>`;
}
function renderSeen(c, data, isOnline) {
const div = document.getElementById(`seen-${c.id}`);
if (!div) return;
const it = currentLang === 'it';
const ages = (data.last_seen && data.last_seen[c.id.toLowerCase()]) || {};
const upSec = data.uptime || 0;
let html = '';
// MMDVM: pubblica eventi (TX, cambio modo), quindi su un nodo tranquillo l'età può crescere senza problemi.
// Si segnala solo il caso "mai ricevuto nulla" con nodo online e server attivo da un po'.
const warnM = ages.mmdvm === undefined && isOnline && upSec > 600;
const tipM = it ? `Ultimo evento dal topic mmdvm/${c.id}/json (TX, cambio modo...). Su un nodo tranquillo può passare molto tempo.`
: `Last event from topic mmdvm/${c.id}/json (TX, mode change...). It can be a long time on a quiet node.`;
const tipMW = it ? `Nessun evento MMDVM ricevuto da quando il server è attivo (${fmtAge(upSec)}): controlla che MMDVMHost sia connesso al broker.`
: `No MMDVM event received since the server started (${fmtAge(upSec)}): check that MMDVMHost is connected to the broker.`;
html += seenItem('MMDVM', ages.mmdvm, warnM ? tipMW : tipM, warnM);
// Gateway: solo se il nodo ne ha mai pubblicati
if (ages.gateway !== undefined) {
html += seenItem('GW', ages.gateway, it ? 'Ultimo evento dai gateway (DMR/NXDN/YSF/P25/D-Star)' : 'Last event from the gateways (DMR/NXDN/YSF/P25/D-Star)', false);
}
// Agent: heartbeat periodico, se tace a lungo con nodo "online" qualcosa non va
const warnA = isOnline && (ages.health === undefined ? upSec > 300 : ages.health > 180);
const tipA = warnA
? (it ? 'L\'agent del nodo non invia più lo stato (devices/<id>/services): connessione bloccata?' : 'The node agent stopped sending its status (devices/<id>/services): stuck connection?')
: (it ? 'Ultimo stato ricevuto dall\'agent del nodo (devices/<id>/services)' : 'Last status received from the node agent (devices/<id>/services)');
html += seenItem('AGENT', ages.health, tipA, warnA);
div.innerHTML = html;
div.style.display = 'flex';
}
// --- GLOBAL VARIABLES --- // --- GLOBAL VARIABLES ---
let clients = []; let clients = [];
let isAuthenticated = sessionStorage.getItem('is_admin') === 'true'; let me = { logged_in: false, user: '', role: '', allowed_nodes: '' }; // stato reale della sessione (da /api/me)
let isAuthenticated = false;
let usersCache = {};
let globalHealthData = {}; let globalHealthData = {};
let editingUserId = null; let editingUserId = null;
let currentResetHatId = null; let currentResetHatId = null;
@@ -518,7 +575,7 @@
try { try {
const res = await fetch('/api/command', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId, type }) }); const res = await fetch('/api/command', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId, type }) });
const data = await res.json(); const data = await res.json();
if(!data.success) { customAlert(t('titleError'), data.error, true); } if(!data.success) { customAlert(t('titleError'), esc(data.error), true); }
refreshStates(); refreshStates();
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
}); });
@@ -529,7 +586,7 @@
let profileName = mode === 'A' ? "PROFILE A" : "PROFILE B"; let profileName = mode === 'A' ? "PROFILE A" : "PROFILE B";
if (data && data.profiles && data.profiles[mode]) profileName = data.profiles[mode]; if (data && data.profiles && data.profiles[mode]) profileName = data.profiles[mode];
const title = `🔄 ${t('titleSwitch')}`; const title = `🔄 ${t('titleSwitch')}`;
const msg = `${t('confSwitch')} <b>${profileName}</b> -> <b>${id.toUpperCase()}</b>?`; const msg = `${t('confSwitch')} <b>${esc(profileName)}</b> -> <b>${id.toUpperCase()}</b>?`;
const color = mode === 'A' ? "var(--accent)" : "#eab308"; const color = mode === 'A' ? "var(--accent)" : "#eab308";
sendCommand(id, mode, title, msg, color); sendCommand(id, mode, title, msg, color);
} }
@@ -552,7 +609,7 @@
try { try {
const res = await fetch('/api/command', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: clientId, type: 'RESET_HAT' }) }); const res = await fetch('/api/command', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: clientId, type: 'RESET_HAT' }) });
const data = await res.json(); const data = await res.json();
if(!data.success) { customAlert(t('titleError'), data.error, true); } if(!data.success) { customAlert(t('titleError'), esc(data.error), true); }
refreshStates(); refreshStates();
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
@@ -572,7 +629,7 @@
try { try {
const res = await fetch('/api/command', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: clientId, type: comando }) }); const res = await fetch('/api/command', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: clientId, type: comando }) });
const data = await res.json(); const data = await res.json();
if(!data.success) { customAlert(t('titleError'), data.error, true); } if(!data.success) { customAlert(t('titleError'), esc(data.error), true); }
refreshStates(); refreshStates();
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
}); });
@@ -582,20 +639,25 @@
async function initUI() { async function initUI() {
applyTranslations(); applyTranslations();
try { try {
try { const meRes = await fetch('/api/me'); if (meRes.ok) me = await meRes.json(); } catch (e) { console.error(e); }
isAuthenticated = !!me.logged_in;
const response = await fetch('/api/clients'); const response = await fetch('/api/clients');
clients = await response.json(); clients = await response.json();
// Gli id nodo finiscono in id HTML e onclick: accettiamo solo caratteri sicuri
clients = clients.filter(c => /^[A-Za-z0-9_\-]+$/.test(String(c.id)));
const statSelect = document.getElementById('stat-node-filter'); const statSelect = document.getElementById('stat-node-filter');
if (statSelect) { if (statSelect) {
let opts = `<option value="all">🌐 ${t('statAllNodes')}</option>`; let opts = `<option value="all">🌐 ${t('statAllNodes')}</option>`;
clients.forEach(c => { opts += `<option value="${c.id}">${c.name} (${c.id.toUpperCase()})</option>`; }); clients.forEach(c => { opts += `<option value="${c.id}">${esc(c.name)} (${c.id.toUpperCase()})</option>`; });
statSelect.innerHTML = opts; statSelect.innerHTML = opts;
} }
const grid = document.getElementById('client-grid'); const grid = document.getElementById('client-grid');
const authContainer = document.getElementById('auth-container'); const authContainer = document.getElementById('auth-container');
const role = sessionStorage.getItem('user_role'); const role = me.role || '';
const allowed = sessionStorage.getItem('allowed_nodes') || ""; const allowedList = String(me.allowed_nodes || '').split(',').map(x => x.trim().toLowerCase()).filter(Boolean);
if (isAuthenticated) { if (isAuthenticated) {
let adminBtn = role === 'admin' ? ` let adminBtn = role === 'admin' ? `
@@ -604,7 +666,7 @@
<button onclick="openAdmin()" class="auth-btn" style="background:var(--accent); color:white;" data-i18n-title="ttAdmin" title="${t('ttAdmin')}">${t('btnAdmin')}</button>` : ''; <button onclick="openAdmin()" class="auth-btn" style="background:var(--accent); color:white;" data-i18n-title="ttAdmin" title="${t('ttAdmin')}">${t('btnAdmin')}</button>` : '';
authContainer.innerHTML = `${adminBtn} authContainer.innerHTML = `${adminBtn}
<span style="font-weight:800; font-size:0.9rem; margin: 0 10px;">👤 ${sessionStorage.getItem('user_name').toUpperCase()}</span> <span style="font-weight:800; font-size:0.9rem; margin: 0 10px;">👤 ${esc(String(me.user).toUpperCase())}</span>
<button onclick="changeMyPassword()" class="auth-btn" style="background:#64748b; color:white;" data-i18n-title="ttPass" title="${t('ttPass')}">${t('btnPass')}</button> <button onclick="changeMyPassword()" class="auth-btn" style="background:#64748b; color:white;" data-i18n-title="ttPass" title="${t('ttPass')}">${t('btnPass')}</button>
<button onclick="logout()" class="auth-btn" style="background:var(--text-main); color:var(--card-bg);" data-i18n-title="ttLogout" title="${t('ttLogout')}">${t('btnLogout')}</button>`; <button onclick="logout()" class="auth-btn" style="background:var(--text-main); color:var(--card-bg);" data-i18n-title="ttLogout" title="${t('ttLogout')}">${t('btnLogout')}</button>`;
} else { } else {
@@ -612,13 +674,13 @@
} }
grid.innerHTML = clients.map(c => { grid.innerHTML = clients.map(c => {
let canControl = (role === 'admin' || allowed === 'all' || allowed.includes(c.id)); let canControl = isAuthenticated && (role === 'admin' || allowedList.includes('all') || allowedList.includes(c.id.toLowerCase()));
let showReboot = (role === 'admin'); let showReboot = (role === 'admin');
return ` return `
<div class="card" id="card-${c.id}"> <div class="card" id="card-${c.id}">
<div class="card-header"> <div class="card-header">
<span class="client-name" title="${c.name}">${c.name}</span> <span class="client-name" title="${esc(c.name)}">${esc(c.name)}</span>
<div style="display: flex; align-items: center;"> <div style="display: flex; align-items: center;">
<span class="badge-id">ID: ${c.id.toUpperCase()}</span> <span class="badge-id">ID: ${c.id.toUpperCase()}</span>
<div id="type-badge-${c.id}" class="type-badge" style="display: none;"></div> <div id="type-badge-${c.id}" class="type-badge" style="display: none;"></div>
@@ -638,6 +700,7 @@
<span>🧠 <span id="ram-${c.id}">--</span>%</span> <span>🧠 <span id="ram-${c.id}">--</span>%</span>
<span>💾 <span id="disk-${c.id}">--</span>%</span> <span>💾 <span id="disk-${c.id}">--</span>%</span>
</div> </div>
<div class="seen-bar" id="seen-${c.id}" style="display: none;"></div>
<div class="status-display" id="status-${c.id}">Offline</div> <div class="status-display" id="status-${c.id}">Offline</div>
@@ -655,7 +718,7 @@
${canControl ? ` ${canControl ? `
<div id="prof-btns-${c.id}" style="width: 100%; display: flex; gap: 10px; display: none;"></div> <div id="prof-btns-${c.id}" style="width: 100%; gap: 10px; display: none;"></div>
<div style="width: 100%; display: flex; gap: 10px; margin-bottom: 8px;"> <div style="width: 100%; display: flex; gap: 10px; margin-bottom: 8px;">
<button class="btn-cmd" style="background: var(--success);" title="${t('ttTgOn')}" onclick="sendTgCommand('${c.id}', 'TG:ON')">🔔 Telegram ON</button> <button class="btn-cmd" style="background: var(--success);" title="${t('ttTgOn')}" onclick="sendTgCommand('${c.id}', 'TG:ON')">🔔 Telegram ON</button>
<button class="btn-cmd" style="background: var(--text-muted);" title="${t('ttTgOff')}" onclick="sendTgCommand('${c.id}', 'TG:OFF')">🔇 Telegram OFF</button> <button class="btn-cmd" style="background: var(--text-muted);" title="${t('ttTgOff')}" onclick="sendTgCommand('${c.id}', 'TG:OFF')">🔇 Telegram OFF</button>
@@ -672,7 +735,6 @@
<button class="btn-cmd" style="background: #2563eb; padding: 8px 4px;" title="Scarica l'ultima versione da Gitea" onclick="confirmUpdateAgent('${c.id}')">⬇️ UPDATE OTA</button> <button class="btn-cmd" style="background: #2563eb; padding: 8px 4px;" title="Scarica l'ultima versione da Gitea" onclick="confirmUpdateAgent('${c.id}')">⬇️ UPDATE OTA</button>
</div> </div>
` : ''} ` : ''}
</div>
</div>`; </div>`;
}).join(''); }).join('');
@@ -690,11 +752,11 @@
if (!user || !pass) return; if (!user || !pass) return;
const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user, pass }) }); const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user, pass }) });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) { location.reload(); }
sessionStorage.setItem('is_admin', 'true'); sessionStorage.setItem('user_name', user); sessionStorage.setItem('user_role', data.role); sessionStorage.setItem('allowed_nodes', data.allowed_nodes); location.reload(); else if (res.status === 429) { customAlert(t('titleError'), esc(data.error || t('msgLoginFail')), true); }
} else { customAlert(t('titleError'), t('msgLoginFail'), true); } else { customAlert(t('titleError'), t('msgLoginFail'), true); }
} }
function logout() { sessionStorage.clear(); location.reload(); } async function logout() { try { await fetch('/api/logout', { method: 'POST' }); } catch (e) { console.error(e); } sessionStorage.clear(); location.reload(); }
async function refreshStates() { async function refreshStates() {
try { try {
@@ -707,6 +769,7 @@
let stateValue = data.states[c.id.toLowerCase()] || data.states[c.id] || "OFFLINE"; let stateValue = data.states[c.id.toLowerCase()] || data.states[c.id] || "OFFLINE";
stateValue = String(stateValue).trim().toUpperCase(); statusDiv.innerText = stateValue; stateValue = String(stateValue).trim().toUpperCase(); statusDiv.innerText = stateValue;
const isOnline = !stateValue.includes("OFF") && stateValue !== ""; const isOnline = !stateValue.includes("OFF") && stateValue !== "";
renderSeen(c, data, isOnline);
// 2. POI AGGIORNIAMO NOME E ID DINAMICI // 2. POI AGGIORNIAMO NOME E ID DINAMICI
let genObj = data.general && data.general[c.id.toLowerCase()]; let genObj = data.general && data.general[c.id.toLowerCase()];
@@ -783,7 +846,7 @@
// Se è simplex, saltiamo del tutto l'aggiornamento grafico del TS1 // Se è simplex, saltiamo del tutto l'aggiornamento grafico del TS1
if (isSimplex && idx === 0) return; if (isSimplex && idx === 0) return;
const val = idx === 0 ? telemetryObj.ts1 : telemetryObj.ts2; const netName = idx === 0 ? netObj.ts1 : netObj.ts2; const val = String((idx === 0 ? telemetryObj.ts1 : telemetryObj.ts2) ?? ''); const netName = idx === 0 ? netObj.ts1 : netObj.ts2;
const fullLabel = netName ? `TS${idx + 1} [${netName}]` : `TS${idx + 1}`; const fullLabel = netName ? `TS${idx + 1} [${netName}]` : `TS${idx + 1}`;
div.innerText = `${fullLabel}: ${val}`; div.innerText = `${fullLabel}: ${val}`;
div.style.setProperty('--pulse-color', activeModeColor); div.style.setProperty('--pulse-color', activeModeColor);
@@ -841,7 +904,7 @@
let idx = 0; let idx = 0;
for (const [pKey, pLabel] of Object.entries(healthObj.profiles)) { for (const [pKey, pLabel] of Object.entries(healthObj.profiles)) {
let col = colors[idx % colors.length]; let col = colors[idx % colors.length];
html += `<button class="btn-cmd" style="background: ${col}; flex: 1;" title="${t('ttSwitchTo')}${pLabel}" onclick="confirmSwitch('${c.id}', '${pKey}')">${pLabel}</button>`; html += `<button class="btn-cmd" style="background: ${col}; flex: 1;" title="${esc(t('ttSwitchTo') + pLabel)}" onclick="confirmSwitch('${c.id}', ${q(pKey)})">${esc(pLabel)}</button>`;
idx++; idx++;
} }
profBtnsDiv.innerHTML = html; profBtnsDiv.innerHTML = html;
@@ -874,8 +937,8 @@
if (isTx) cardDiv.dataset.lastTx = Date.now(); // Memorizza quando è avvenuto l'ultimo TX if (isTx) cardDiv.dataset.lastTx = Date.now(); // Memorizza quando è avvenuto l'ultimo TX
let timeSinceTx = Date.now() - (parseInt(cardDiv.dataset.lastTx) || 0); let timeSinceTx = Date.now() - (parseInt(cardDiv.dataset.lastTx) || 0);
// Accendi se in TX, OPPURE se in Hang Time (ma spegni a forza dopo 30 secondi di inattività) // Accendi se in TX, OPPURE se in Hang Time (ma spegni a forza dopo HANG_TIME_MS di inattività)
let showActiveColor = isTx || (!isIdle && timeSinceTx < 180000); let showActiveColor = isTx || (!isIdle && timeSinceTx < HANG_TIME_MS);
cardDiv.style.setProperty('border-top-color', showActiveColor ? activeModeColor : "var(--border-color)", 'important'); cardDiv.style.setProperty('border-top-color', showActiveColor ? activeModeColor : "var(--border-color)", 'important');
// --- FINE LOGICA --- // --- FINE LOGICA ---
@@ -904,16 +967,17 @@
const time = row[0] ? row[0].split(' ')[1] : "--:--"; const time = row[0] ? row[0].split(' ')[1] : "--:--";
const clientId = row[1]; const protocol = row[2] || "DMR"; let source = row[3] || "---"; const target = row[4] || "---"; const clientId = row[1]; const protocol = row[2] || "DMR"; let source = row[3] || "---"; const target = row[4] || "---";
const rawSlot = row[5]; const source_ext = row[8]; const rawSlot = row[5]; const source_ext = row[8];
if (source_ext && source_ext.trim() !== "") { source = `${source} <span style="font-size:0.8em; color:#94a3b8; font-family:'JetBrains Mono', monospace;">/${source_ext}</span>`; } let sourceHtml = esc(source);
if (source_ext && source_ext.trim() !== "") { sourceHtml += ` <span style="font-size:0.8em; color:#94a3b8; font-family:'JetBrains Mono', monospace;">/${esc(source_ext)}</span>`; }
const slotDisplay = protocol === "DMR" ? `TS${rawSlot}` : "--"; const slotDisplay = protocol === "DMR" ? `TS${rawSlot}` : "--";
let protoColor = "#3b82f6"; if (protocol === "NXDN") protoColor = "#10b981"; else if (protocol === "YSF") protoColor = "#8b5cf6"; else if (protocol === "D-STAR") protoColor = "#06b6d4"; else if (protocol === "P25") protoColor = "#f59e0b"; let protoColor = "#3b82f6"; if (protocol === "NXDN") protoColor = "#10b981"; else if (protocol === "YSF") protoColor = "#8b5cf6"; else if (protocol === "D-STAR") protoColor = "#06b6d4"; else if (protocol === "P25") protoColor = "#f59e0b";
if (source.includes("🌐")) { if (source.includes("🌐")) {
if (!networkLogs[clientId]) networkLogs[clientId] = ""; if (!networkLogs[clientId]) networkLogs[clientId] = "";
networkLogs[clientId] += `<div style="border-bottom:1px solid rgba(255,255,255,0.1); padding:4px 0;"><span style="color:#64748b;">[${time}]</span> <span style="color:${protoColor}; font-weight:bold;">[${protocol}]</span> <span style="color:#cbd5e1;">${source.replace("🌐", "").trim()}</span></div>`; networkLogs[clientId] += `<div style="border-bottom:1px solid rgba(255,255,255,0.1); padding:4px 0;"><span style="color:#64748b;">[${esc(time)}]</span> <span style="color:${protoColor}; font-weight:bold;">[${esc(protocol)}]</span> <span style="color:#cbd5e1;">${esc(source.replace("🌐", "").trim())}</span></div>`;
} else { } else {
tableHTML += `<tr><td>${time}</td><td><b style="color:var(--text-main);">${clientId.toUpperCase()}</b></td><td><span style="background:${protoColor}; color:white; padding:3px 8px; border-radius:8px; font-size:0.7rem; font-weight:800;">${protocol}</span></td><td style="font-family:'JetBrains Mono', monospace; font-weight:bold; text-align:center;">${slotDisplay}</td><td style="color:var(--accent); font-weight:800;">${source}</td><td style="font-family:'JetBrains Mono', monospace;">${target.trim()}</td><td>${row[6]}s</td><td>${row[7]}%</td></tr>`; tableHTML += `<tr><td>${esc(time)}</td><td><b style="color:var(--text-main);">${esc(String(clientId).toUpperCase())}</b></td><td><span style="background:${protoColor}; color:white; padding:3px 8px; border-radius:8px; font-size:0.7rem; font-weight:800;">${esc(protocol)}</span></td><td style="font-family:'JetBrains Mono', monospace; font-weight:bold; text-align:center;">${esc(slotDisplay)}</td><td style="color:var(--accent); font-weight:800;">${sourceHtml}</td><td style="font-family:'JetBrains Mono', monospace;">${esc(target.trim())}</td><td>${esc(row[6])}s</td><td>${esc(row[7])}%</td></tr>`;
} }
}); });
tbody.innerHTML = tableHTML; tbody.innerHTML = tableHTML;
@@ -925,8 +989,8 @@
try { try {
const node = document.getElementById('stat-node-filter').value || 'all'; const node = document.getElementById('stat-node-filter').value || 'all';
const res = await fetch(`/api/stats?node=${node}`); const data = await res.json(); const res = await fetch(`/api/stats?node=${node}`); const data = await res.json();
document.getElementById('stat-tgs').innerHTML = data.top_tgs.map((t, i) => `<div><b style="color:var(--text-main);">${i+1}. ${t.target}</b> <span style="float:right; background:rgba(128,128,128,0.1); padding:2px 6px; border-radius:4px;">${t.count} tx</span></div>`).join('') || t('statNoData'); document.getElementById('stat-tgs').innerHTML = data.top_tgs.map((t, i) => `<div><b style="color:var(--text-main);">${i+1}. ${esc(t.target)}</b> <span style="float:right; background:rgba(128,128,128,0.1); padding:2px 6px; border-radius:4px;">${t.count} tx</span></div>`).join('') || t('statNoData');
document.getElementById('stat-calls').innerHTML = data.top_calls.map((c, i) => `<div><b style="color:var(--text-main);">${i+1}. ${c.call}</b> <span style="float:right; background:rgba(128,128,128,0.1); padding:2px 6px; border-radius:4px;">${c.count} tx</span></div>`).join('') || t('statNoData'); document.getElementById('stat-calls').innerHTML = data.top_calls.map((c, i) => `<div><b style="color:var(--text-main);">${i+1}. ${esc(c.call)}</b> <span style="float:right; background:rgba(128,128,128,0.1); padding:2px 6px; border-radius:4px;">${c.count} tx</span></div>`).join('') || t('statNoData');
document.getElementById('stat-avg').innerText = data.avg_duration; document.getElementById('stat-today').innerText = data.today_tx; document.getElementById('stat-avg').innerText = data.avg_duration; document.getElementById('stat-today').innerText = data.today_tx;
} catch (e) { console.error("Errore statistiche:", e); } } catch (e) { console.error("Errore statistiche:", e); }
} }
@@ -936,18 +1000,20 @@
function closeAdmin() { document.getElementById('admin-modal').style.display = 'none'; cancelEdit(); } function closeAdmin() { document.getElementById('admin-modal').style.display = 'none'; cancelEdit(); }
async function loadUsers() { async function loadUsers() {
const res = await fetch('/api/users'); const users = await res.json(); const res = await fetch('/api/users'); if (!res.ok) return; const users = await res.json(); usersCache = {}; users.forEach(u => { usersCache[u.id] = u; });
document.getElementById('users-table-body').innerHTML = users.map(u => ` document.getElementById('users-table-body').innerHTML = users.map(u => `
<tr> <tr>
<td>${u.id}</td><td style="font-weight:bold;">${u.username}</td><td><span style="background:rgba(128,128,128,0.2); padding:2px 8px; border-radius:6px; font-size:0.8rem;">${u.role.toUpperCase()}</span></td><td>${u.allowed_nodes}</td> <td>${u.id}</td><td style="font-weight:bold;">${esc(u.username)}</td><td><span style="background:rgba(128,128,128,0.2); padding:2px 8px; border-radius:6px; font-size:0.8rem;">${esc(String(u.role).toUpperCase())}</span></td><td>${esc(u.allowed_nodes)}</td>
<td style="text-align:center;"> <td style="text-align:center;">
<button onclick="startEditUser(${u.id}, '${u.username}', '${u.role}', '${u.allowed_nodes}')" class="btn-cmd" style="background:var(--accent); padding:6px; width:auto; display:inline-block; margin-right:5px;">✏️</button> <button onclick="startEditUser(${Number(u.id)})" class="btn-cmd" style="background:var(--accent); padding:6px; width:auto; display:inline-block; margin-right:5px;">✏️</button>
<button onclick="deleteUser(${u.id})" class="btn-cmd" style="background:var(--danger); padding:6px; width:auto; display:inline-block;">🗑️</button> <button onclick="deleteUser(${u.id})" class="btn-cmd" style="background:var(--danger); padding:6px; width:auto; display:inline-block;">🗑️</button>
</td> </td>
</tr>`).join(''); </tr>`).join('');
} }
function startEditUser(id, username, role, nodes) { function startEditUser(id) {
const u = usersCache[id]; if (!u) return;
const username = u.username, role = u.role, nodes = u.allowed_nodes;
editingUserId = id; document.getElementById('new-user').value = username; document.getElementById('new-user').disabled = true; document.getElementById('new-pass').value = ""; document.getElementById('new-pass').placeholder = t('phNewPass'); document.getElementById('new-role').value = role; document.getElementById('new-nodes').value = nodes; editingUserId = id; document.getElementById('new-user').value = username; document.getElementById('new-user').disabled = true; document.getElementById('new-pass').value = ""; document.getElementById('new-pass').placeholder = t('phNewPass'); document.getElementById('new-role').value = role; document.getElementById('new-nodes').value = nodes;
const btnSubmit = document.getElementById('btn-user-submit'); btnSubmit.innerHTML = t('titleSave'); btnSubmit.style.background = "var(--accent)"; document.getElementById('btn-user-cancel').style.display = "block"; const btnSubmit = document.getElementById('btn-user-submit'); btnSubmit.innerHTML = t('titleSave'); btnSubmit.style.background = "var(--accent)"; document.getElementById('btn-user-cancel').style.display = "block";
} }
@@ -964,15 +1030,15 @@
if (editingUserId) { if (editingUserId) {
const res = await fetch(`/api/users/${editingUserId}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); const data = await res.json(); const res = await fetch(`/api/users/${editingUserId}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); const data = await res.json();
if (data.success) { cancelEdit(); loadUsers(); } else customAlert(t('titleError'), data.error, true); if (data.success) { cancelEdit(); loadUsers(); } else customAlert(t('titleError'), esc(data.error), true);
} else { } else {
if (!password) return customAlert(t('titleError'), t('msgMissPass'), true); if (!password) return customAlert(t('titleError'), t('msgMissPass'), true);
const res = await fetch('/api/users', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); const data = await res.json(); const res = await fetch('/api/users', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); const data = await res.json();
if (data.success) { cancelEdit(); loadUsers(); } else customAlert(t('titleError'), data.error, true); if (data.success) { cancelEdit(); loadUsers(); } else customAlert(t('titleError'), esc(data.error), true);
} }
} }
function deleteUser(id) { customConfirm(t('titleDelete'), t('msgDelUser'), "var(--danger)", async () => { await fetch(`/api/users/${id}`, {method: 'DELETE'}); loadUsers(); cancelEdit(); }); } function deleteUser(id) { customConfirm(t('titleDelete'), t('msgDelUser'), "var(--danger)", async () => { const r = await fetch(`/api/users/${id}`, {method: 'DELETE'}); const dd = await r.json().catch(() => ({})); if (!dd.success) customAlert(t('titleError'), esc(dd.error || 'Error'), true); loadUsers(); cancelEdit(); }); }
// --- SETTINGS --- // --- SETTINGS ---
function triggerGlobalEmergency() { function triggerGlobalEmergency() {
@@ -993,7 +1059,7 @@
for (const [k, v] of Object.entries(uniqueProfiles)) { for (const [k, v] of Object.entries(uniqueProfiles)) {
let col = colors[idx % colors.length]; let col = colors[idx % colors.length];
html += `<button onclick="sendGlobalAction('${k}')" class="btn-cmd" style="background:${col}; padding:15px; font-size:1.1rem; font-weight:bold;">${v}</button>`; html += `<button onclick="sendGlobalAction(${q(k)})" class="btn-cmd" style="background:${col}; padding:15px; font-size:1.1rem; font-weight:bold;">${esc(v)}</button>`;
idx++; idx++;
} }
@@ -1010,7 +1076,7 @@
document.getElementById('override-modal').style.display = 'none'; document.getElementById('override-modal').style.display = 'none';
customConfirm(t('titleGlobal'), t('promptOvrConfirm'), "var(--danger)", async () => { customConfirm(t('titleGlobal'), t('promptOvrConfirm'), "var(--danger)", async () => {
try { const res = await fetch('/api/global_command', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({type: action}) }); const d = await res.json(); try { const res = await fetch('/api/global_command', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({type: action}) }); const d = await res.json();
if(d.success) customAlert(t('titleSuccess'), t('msgOvrOk')); else customAlert(t('titleError'), d.error, true); refreshStates(); if(d.success) customAlert(t('titleSuccess'), t('msgOvrOk')); else customAlert(t('titleError'), esc(d.error), true); refreshStates();
} catch(e) { console.error(e); } } catch(e) { console.error(e); }
}); });
} }
@@ -1019,16 +1085,16 @@
function closePasswordModal() { document.getElementById('password-modal').style.display = 'none'; } function closePasswordModal() { document.getElementById('password-modal').style.display = 'none'; }
async function executePasswordChange() { async function executePasswordChange() {
const p = document.getElementById('new-password-input').value; if (!p) return; closePasswordModal(); const p = document.getElementById('new-password-input').value; if (!p) return; closePasswordModal();
try { const res = await fetch('/api/change_password', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username: sessionStorage.getItem('user_name'), new_password: p}) }); try { const res = await fetch('/api/change_password', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username: me.user, new_password: p}) });
if ((await res.json()).success) { customAlert(t('titleSuccess'), t('msgPassOk')); setTimeout(() => logout(), 1500); } else { customAlert(t('titleError'), t('msgPassErr'), true); } if ((await res.json()).success) { customAlert(t('titleSuccess'), t('msgPassOk')); setTimeout(() => logout(), 1500); } else { customAlert(t('titleError'), t('msgPassErr'), true); }
} catch(e) { console.error(e); } } catch(e) { console.error(e); }
} }
async function loadSettings() { try { const res = await fetch('/api/config'); const data = await res.json(); document.getElementById('update-time-input').value = data.update_schedule; document.getElementById('url-dmr-input').value = data.url_dmr; document.getElementById('url-nxdn-input').value = data.url_nxdn; } catch (e) { console.error(e); } } async function loadSettings() { try { const res = await fetch('/api/config'); if (!res.ok) return; const data = await res.json(); document.getElementById('update-time-input').value = data.update_schedule; document.getElementById('url-dmr-input').value = data.url_dmr; document.getElementById('url-nxdn-input').value = data.url_nxdn; } catch (e) { console.error(e); } }
async function saveSettings() { async function saveSettings() {
const payload = { update_schedule: document.getElementById('update-time-input').value, url_dmr: document.getElementById('url-dmr-input').value, url_nxdn: document.getElementById('url-nxdn-input').value }; const payload = { update_schedule: document.getElementById('update-time-input').value, url_dmr: document.getElementById('url-dmr-input').value, url_nxdn: document.getElementById('url-nxdn-input').value };
try { const res = await fetch('/api/config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); const data = await res.json(); try { const res = await fetch('/api/config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); const data = await res.json();
if (data.success) customAlert(t('titleSuccess'), t('msgConfigSaved')); else customAlert(t('titleError'), t('msgConfigErr'), true); if (data.success) customAlert(t('titleSuccess'), t('msgConfigSaved')); else customAlert(t('titleError'), esc(data.error || t('msgConfigErr')), true);
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
@@ -1036,7 +1102,7 @@
function openConfigsModal(clientId) { function openConfigsModal(clientId) {
const data = globalHealthData[clientId.toLowerCase()]; const listDiv = document.getElementById('configs-list'); let listaFile = data ? (data.config_files || data.files || []) : []; const data = globalHealthData[clientId.toLowerCase()]; const listDiv = document.getElementById('configs-list'); let listaFile = data ? (data.config_files || data.files || []) : [];
if (listaFile.length === 0) listDiv.innerHTML = `<div style="text-align:center; padding:20px;"><p style="color:var(--text-muted); margin-bottom:15px;">${t('waitData')}</p><button onclick="sendGlobalUpdate(); closeConfigsModal();" class="btn-cmd" style="background:var(--accent);">${t('forceUpdate')}</button></div>`; if (listaFile.length === 0) listDiv.innerHTML = `<div style="text-align:center; padding:20px;"><p style="color:var(--text-muted); margin-bottom:15px;">${t('waitData')}</p><button onclick="sendGlobalUpdate(); closeConfigsModal();" class="btn-cmd" style="background:var(--accent);">${t('forceUpdate')}</button></div>`;
else listDiv.innerHTML = listaFile.map(f => `<div style="display:flex; justify-content:space-between; align-items:center; padding:12px; background:rgba(128,128,128,0.1); border-radius:12px; border:1px solid var(--border-color); margin-bottom:8px;"><span style="font-family:'JetBrains Mono',monospace; font-weight:bold;">${f.toUpperCase()}.ini</span><button onclick="closeConfigsModal(); openEditorModal('${clientId}', '${f}')" class="btn-cmd" style="background:#8e44ad; max-width:120px;">${t('btnEdit')}</button></div>`).join(''); else listDiv.innerHTML = listaFile.map(f => `<div style="display:flex; justify-content:space-between; align-items:center; padding:12px; background:rgba(128,128,128,0.1); border-radius:12px; border:1px solid var(--border-color); margin-bottom:8px;"><span style="font-family:'JetBrains Mono',monospace; font-weight:bold;">${esc(String(f).toUpperCase())}.ini</span><button onclick="closeConfigsModal(); openEditorModal(${q(clientId)}, ${q(f)})" class="btn-cmd" style="background:#8e44ad; max-width:120px;">${t('btnEdit')}</button></div>`).join('');
document.getElementById('configs-modal').style.display = 'flex'; document.getElementById('configs-modal').style.display = 'flex';
} }
function closeConfigsModal() { document.getElementById('configs-modal').style.display = 'none'; } function closeConfigsModal() { document.getElementById('configs-modal').style.display = 'none'; }
@@ -1050,12 +1116,12 @@
for (const [name, status] of Object.entries(data.processes)) { for (const [name, status] of Object.entries(data.processes)) {
const isOnline = status.toLowerCase() === 'online'; const statusColor = isOnline ? 'var(--success)' : 'var(--danger)'; const isOnline = status.toLowerCase() === 'online'; const statusColor = isOnline ? 'var(--success)' : 'var(--danger)';
html += `<div style="display:flex; justify-content:space-between; align-items:center; padding:15px; border-bottom:1px solid var(--border-color); background:rgba(128,128,128,0.05); border-radius:12px; margin-bottom:8px; border-left: 5px solid ${statusColor};"> html += `<div style="display:flex; justify-content:space-between; align-items:center; padding:15px; border-bottom:1px solid var(--border-color); background:rgba(128,128,128,0.05); border-radius:12px; margin-bottom:8px; border-left: 5px solid ${statusColor};">
<div><strong style="font-size:1.1rem; display:block; font-family:'JetBrains Mono',monospace;">${name}</strong><span class="${isOnline?'':'blink'}" style="color:${statusColor}; font-size:0.8rem; font-weight:800;">${status.toUpperCase()}</span></div> <div><strong style="font-size:1.1rem; display:block; font-family:'JetBrains Mono',monospace;">${esc(name)}</strong><span class="${isOnline?'':'blink'}" style="color:${statusColor}; font-size:0.8rem; font-weight:800;">${esc(String(status).toUpperCase())}</span></div>
<div style="display:flex; gap:8px;"> <div style="display:flex; gap:8px;">
${!isOnline ? `<button onclick="controlService('${clientId}', '${name}', 'start')" class="btn-cmd" style="background:var(--success);">▶</button>` : ''} ${!isOnline ? `<button onclick="controlService(${q(clientId)}, ${q(name)}, 'start')" class="btn-cmd" style="background:var(--success);">▶</button>` : ''}
<button onclick="controlService('${clientId}', '${name}', 'restart')" class="btn-cmd" style="background:#f59e0b;">🔄</button> <button onclick="controlService(${q(clientId)}, ${q(name)}, 'restart')" class="btn-cmd" style="background:#f59e0b;">🔄</button>
<button onclick="openEditorModal('${clientId}', '${name}')" class="btn-cmd" style="background:#8e44ad;">📝</button> <button onclick="openEditorModal(${q(clientId)}, ${q(name)})" class="btn-cmd" style="background:#8e44ad;">📝</button>
${isOnline ? `<button onclick="controlService('${clientId}', '${name}', 'stop')" class="btn-cmd" style="background:var(--danger);">🛑</button>` : ''} ${isOnline ? `<button onclick="controlService(${q(clientId)}, ${q(name)}, 'stop')" class="btn-cmd" style="background:var(--danger);">🛑</button>` : ''}
</div> </div>
</div>`; </div>`;
} }
@@ -1063,9 +1129,9 @@
} }
function controlService(clientId, service, action) { function controlService(clientId, service, action) {
customConfirm(t('titleAction'), `${t('confOp')}<b>${service}</b>?`, "var(--accent)", async () => { customConfirm(t('titleAction'), `${t('confOp')}<b>${esc(service)}</b>?`, "var(--accent)", async () => {
try { const res = await fetch('/api/service_control', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ clientId, service, action }) }); const data = await res.json(); try { const res = await fetch('/api/service_control', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ clientId, service, action }) }); const data = await res.json();
if(!data.success) customAlert(t('titleError'), "Error: " + data.error, true); if(!data.success) customAlert(t('titleError'), "Error: " + esc(data.error), true);
} catch(e) { console.error(e); } } catch(e) { console.error(e); }
}); });
} }
@@ -1076,7 +1142,7 @@
document.getElementById('editor-title').innerText = `${service.toUpperCase()} @ ${clientId.toUpperCase()}`; document.getElementById('editor-title').innerText = `${service.toUpperCase()} @ ${clientId.toUpperCase()}`;
document.getElementById('config-textarea').value = "Connecting to server..."; document.getElementById('editor-status').innerText = ""; document.getElementById('config-textarea').value = "Connecting to server..."; document.getElementById('editor-status').innerText = "";
document.getElementById('editor-modal').style.display = 'flex'; document.getElementById('editor-modal').style.display = 'flex';
try { const res = await fetch(`/api/config_file/${clientId}/${service}`); const data = await res.json(); if (data.success) document.getElementById('config-textarea').value = data.data.raw_text || "Empty file"; else document.getElementById('config-textarea').value = "ERROR:\n" + data.error; } catch(e) { document.getElementById('config-textarea').value = "Connection error."; } try { const res = await fetch(`/api/config_file/${encodeURIComponent(clientId)}/${encodeURIComponent(service)}`); const data = await res.json(); if (data.success) document.getElementById('config-textarea').value = data.data.raw_text || "Empty file"; else document.getElementById('config-textarea').value = "ERROR:\n" + data.error; } catch(e) { document.getElementById('config-textarea').value = "Connection error."; }
} }
function closeEditorModal() { document.getElementById('editor-modal').style.display = 'none'; } function closeEditorModal() { document.getElementById('editor-modal').style.display = 'none'; }
@@ -1087,20 +1153,22 @@
try { try {
const res = await fetch('/api/config_file', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ clientId: currentEditClient, service: currentEditService, config_data: { "raw_text": textValue } }) }); const res = await fetch('/api/config_file', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ clientId: currentEditClient, service: currentEditService, config_data: { "raw_text": textValue } }) });
const data = await res.json(); const data = await res.json();
if (data.success) { customAlert(t('titleSuccess'), "File updated!"); closeEditorModal(); } else { customAlert(t('titleError'), "Server error: " + data.error, true); statusSpan.innerText = "❌ Error"; } if (data.success) { customAlert(t('titleSuccess'), "File updated!"); closeEditorModal(); } else { customAlert(t('titleError'), "Server error: " + esc(data.error), true); statusSpan.innerText = "❌ Error"; }
} catch(e) { customAlert(t('titleError'), t('msgNetErr'), true); statusSpan.innerText = "❌ Network Error"; } } catch(e) { customAlert(t('titleError'), t('msgNetErr'), true); statusSpan.innerText = "❌ Network Error"; }
}); });
} }
// --- PUSH NOTIFICATIONS --- // --- PUSH NOTIFICATIONS ---
async function subscribeToPush() { async function subscribeToPush() {
if (!('serviceWorker' in navigator)) return; if (!('serviceWorker' in navigator) || !('PushManager' in window)) { customAlert("Error", "Push notifications are not supported by this browser.", true); return; }
if (!me.logged_in) { openLoginModal(); return; } // le notifiche si attivano solo da loggati
const permission = await Notification.requestPermission(); const permission = await Notification.requestPermission();
if (permission !== 'granted') { customAlert("Error", "Push notifications permission denied.", true); return; } if (permission !== 'granted') { customAlert("Error", "Push notifications permission denied.", true); return; }
try { try {
const reg = await navigator.serviceWorker.ready; const res = await fetch('/api/vapid_public_key'); const { public_key } = await res.json(); 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) }); 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) }); const subRes = await fetch('/api/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(subscription) });
if (!subRes.ok) throw new Error('subscribe failed: HTTP ' + subRes.status);
customAlert("Success", "Push notifications enabled successfully!"); document.getElementById('push-btn').style.color = 'var(--success)'; 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); } } catch (e) { console.error(e); customAlert("Error", "Failed to enable notifications.", true); }
} }
@@ -1113,14 +1181,26 @@
initUI(); initUI();
checkPushStatus(); checkPushStatus();
// --- AGGIORNAMENTI PERIODICI ---
// Fallback se il WebSocket non è disponibile + spegne l'hang time scaduto anche senza nuovi eventi
setInterval(refreshStates, 15000);
setInterval(() => { refreshLogs(); refreshStats(); }, 60000);
// --- WEBSOCKET REAL-TIME --- // --- WEBSOCKET REAL-TIME ---
const socket = io(); function debounce(fn, ms) { let tm = null; return () => { clearTimeout(tm); tm = setTimeout(fn, ms); }; }
socket.on('mqtt_status', function(data) { const refreshStatesSoon = debounce(refreshStates, 300);
const badge = document.getElementById('mqtt-badge'); const refreshLogsSoon = debounce(() => { refreshLogs(); refreshStats(); }, 500);
if (badge) { if (data.connected) { badge.innerText = "MQTT: ONLINE"; badge.className = "mqtt-badge mqtt-online"; } else { badge.innerText = "MQTT: OFFLINE"; badge.className = "mqtt-badge mqtt-offline"; } }
}); if (typeof io !== 'undefined') {
socket.on('connect', () => { refreshStates(); refreshLogs(); refreshStats(); }); const socket = io();
socket.on('dati_aggiornati', function() { refreshStates(); refreshLogs(); refreshStats(); }); socket.on('mqtt_status', function(data) {
const badge = document.getElementById('mqtt-badge');
if (badge) { if (data.connected) { badge.innerText = "MQTT: ONLINE"; badge.className = "mqtt-badge mqtt-online"; } else { badge.innerText = "MQTT: OFFLINE"; badge.className = "mqtt-badge mqtt-offline"; } }
});
socket.on('connect', () => { refreshStates(); refreshLogs(); refreshStats(); });
socket.on('dati_aggiornati', refreshStatesSoon); // stato nodi / telemetria
socket.on('log_aggiornato', refreshLogsSoon); // nuovi ascolti -> lastheard + statistiche
}
</script> </script>
<script> <script>
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
File diff suppressed because it is too large Load Diff