Primo rilascio: Fleet Control Console v1.0

This commit is contained in:
root
2026-04-18 14:00:34 +02:00
commit a599a3fef2
6 changed files with 1317 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# Ignora le cartelle temporanee di Python
__pycache__/
*.pyc
# Ignora il database SQLite (non vogliamo pubblicare i log e le password hashate!)
*.db
monitor.db
# Ignora i file di configurazione reali (pubblicherai solo i .example.json)
config.json
clients.json
# Ignora i file dati scaricati in automatico (sono grandi e dinamici)
dmrid.dat
nxdn.csv
telemetry_cache.json
# Ignora log di sistema eventuali
*.log
+574
View File
@@ -0,0 +1,574 @@
from flask import Flask, render_template, request, session, jsonify
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
# --- PERCORSI ---
DB_PATH = '/opt/web-control/monitor.db'
CACHE_FILE = '/opt/web-control/telemetry_cache.json'
CONFIG_PATH = '/opt/web-control/config.json'
DMR_IDS_PATH = '/opt/web-control/dmrid.dat'
NXDN_IDS_PATH = '/opt/web-control/nxdn.csv'
CLIENTS_PATH = '/opt/web-control/clients.json'
def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
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
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 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'))
print(">>> UTENTE DI DEFAULT CREATO - User: admin | Pass: admin123 <<<")
conn.commit()
conn.close()
init_db()
# --- CARICAMENTO DATABASE ID ---
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:
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():
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)
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) VALUES (datetime('now', 'localtime'), ?, ?, ?, ?, ?, ?, ?)",
(client_id, protocol, str(data.get('source_id', '---')), str(data.get('destination_id', '---')), data.get('slot', 0), round(data.get('duration', 0), 1), round(data.get('ber', 0), 2)))
conn.commit()
conn.close()
app = Flask(__name__)
app.secret_key = 'ari_fvg_secret_ultra_secure'
client_states = {}
device_configs = {}
client_telemetry = {}
device_health = {}
last_seen_reflector = {}
network_mapping = {} # Memorizza quale network gestisce TS1 e TS2 per ogni nodo
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)
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()
# --- CATTURA CONFIGURAZIONI COMPLETE ---
if parts[0] == 'data' and len(parts) >= 4 and parts[3] == '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)
print(f"DEBUG: Configurazione salvata per {cid_conf} -> {svc_name}")
except Exception as e:
print(f"Errore parsing config JSON: {e}")
elif parts[0] == 'servizi':
client_states[cid] = payload
if payload.upper() not in ['OFF', 'OFFLINE', '']:
tel = client_telemetry.get(cid, {})
if isinstance(tel, dict) and '🔄' in str(tel.get('ts1', '')):
client_telemetry[cid] = {"ts1": "In attesa...", "ts2": "In attesa...", "alt": ""}
save_cache(client_telemetry)
elif parts[0] == 'devices' and len(parts) >= 3 and parts[2] == '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": "PROFILO A", "B": "PROFILO B"})
}
except Exception as e: print(f"Errore parsing health: {e}")
# NUOVO BLOCCO: Intercettazione configurazione DMRGateway
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()
data = json.loads(payload)
# Inizializza il dizionario per questo nodo se non esiste
if cid not in network_mapping:
network_mapping[cid] = {"ts1": "", "ts2": ""}
# Se la rete è abilitata, cerchiamo di capire su che TimeSlot lavora
if str(data.get("Enabled")) == "1":
net_name = data.get("Name", "Net").upper()
# In DMRGateway, il primo numero di QUALSIASI regola indica il TimeSlot (1 o 2).
# Analizziamo tutte le regole di routing possibili.
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
# Assegniamo il nome trovato allo Slot corrispondente
if is_ts1: network_mapping[cid]["ts1"] = net_name
if is_ts2: network_mapping[cid]["ts2"] = net_name
except Exception as e:
print(f"Errore parsing DMRGateway per {cid}: {e}")
elif parts[0] in ['dmr-gateway', 'nxdn-gateway', 'ysf-gateway', 'p25-gateway', 'dstar-gateway']:
data = json.loads(payload)
proto = "DMR"
if "nxdn" in parts[0]: proto = "NXDN"
elif "ysf" in parts[0]: proto = "YSF"
elif "p25" in parts[0]: proto = "P25"
elif "dstar" in parts[0]: 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, {'source_id': "🌐 " + m, 'destination_id': 'NET'}, protocol=proto)
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}
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('source_id'))
dst = str(d.get('destination_id')) # <-- Catturiamo il TG!
active_calls[cid][sk] = {'src': src, 'dst': dst}
client_telemetry[cid]["alt"] = ""
client_telemetry[cid][sk] = f"🎙️ {src} ➔ TG {dst}"
elif act in ['end', 'lost']:
info = active_calls[cid].get(sk, {'src': '---', 'dst': '---'})
d['source_id'], d['destination_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('source_id', '---'), 'NXDN')
elif k == 'P25': src = get_call(p.get('source_id', '---'), 'DMR')
else: src = str(p.get('Callsign', p.get('source_cs', p.get('source_info', p.get('source_id', '---')))))
t_list = [p.get('reflector'), p.get('destination_cs'), p.get('destination_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}
client_telemetry[cid].update({"ts1":"","ts2":"","alt": f"{ico} {name}: {src}{target}"})
elif act in ['end', 'lost']:
info = active_calls[cid].get(k, {'src': '---', 'dst': '---'})
p.update({'source_id': info['src'], 'destination_id': info['dst']})
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: print(f"ERRORE MQTT: {e}")
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_message = on_message
mqtt_backend.connect(config['mqtt']['broker'], config['mqtt']['port'])
mqtt_backend.subscribe([("servizi/+/stat",0), ("dmr-gateway/+/json",0), ("devices/+/services",0), ("nxdn-gateway/+/json",0), ("ysf-gateway/+/json",0), ("p25-gateway/+/json",0), ("dstar-gateway/+/json",0), ("mmdvm/+/json",0), ("devices/#", 0), ("data/#", 0)])
mqtt_backend.loop_start()
@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)
c = conn.cursor()
c.execute("SELECT timestamp, client_id, protocol, source_id, target, slot, duration, ber 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
})
@app.route('/api/service_control', methods=['POST'])
def service_control():
if session.get('role') != 'admin': return jsonify({"error": "Non autorizzato"}), 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": "Non autenticato"}), 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": "Solo gli Admin possono riavviare."}), 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": "🔄 Inviato...", "ts2": "🔄 Inviato...", "alt": ""}
return jsonify({"success": True})
return jsonify({"success": False, "error": "Non hai i permessi per questo nodo."}), 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
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
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": "Non autorizzato"}), 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"})
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 già esistente"})
@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
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!"})
c.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
conn.close()
return jsonify({"success": True})
# --- NUOVA API PER LA MODIFICA DEGLI UTENTI ESISTENTI ---
@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
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": "Non autenticato"}), 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
if not new_pass:
return jsonify({"success": False, "error": "La password non può essere vuota"}), 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": "Azione riservata all'Admin!"}), 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():
while True:
try:
with open(CONFIG_PATH, 'r') as f:
current_cfg = json.load(f)
target_time = current_cfg.get("update_schedule", "03:00")
urls = current_cfg.get("id_urls", {
"dmr": "https://radioid.net/static/dmrid.dat",
"nxdn": "https://radioid.net/static/nxdn.csv"
})
now = time.strftime("%H:%M")
if now == target_time:
print(f">>> [AUTO-UPDATE] Orario raggiunto ({now}). Download in corso...")
urllib.request.urlretrieve(urls["dmr"], DMR_IDS_PATH)
urllib.request.urlretrieve(urls["nxdn"], NXDN_IDS_PATH)
load_ids()
print(f">>> [AUTO-UPDATE] Completato con successo.")
time.sleep(65)
except Exception as e:
print(f">>> [AUTO-UPDATE] Errore: {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": "PROFILO A",
"profileA_Color": "#3498db",
"profileB_Name": "PROFILO 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": "Non autorizzato"}), 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": "Non autorizzato"}), 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": "Non autorizzato"}), 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({"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
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})
if __name__ == '__main__':
threading.Thread(target=auto_update_ids, daemon=True).start()
app.run(host='0.0.0.0', port=5000)
+6
View File
@@ -0,0 +1,6 @@
[
{"_comment": "Enter each repeater/node you want to control. The ID must match the MQTT topic"},
{ "id": "repeater1", "name": "NAME 1" },
{ "id": "repeater2", "name": "NAME 2" },
{ "id": "repeater3", "name": "NAME 3" },
]
+19
View File
@@ -0,0 +1,19 @@
{
"_comment": "Default admin username and password"
},
"web_admin": {
"user": "user",
"pass": "password"
},
"mqtt": {
"broker": "127.0.0.1",
"port": 1883,
"user": "mmdvm",
"password": "password"
},
"update_schedule": "03:00",
"id_urls": {
"dmr": "https://radioid.net/static/dmrid.dat",
"nxdn": "https://radioid.net/static/nxdn.csv"
}
}
Binary file not shown.
+699
View File
@@ -0,0 +1,699 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="icon" href="data:,">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fleet Control Console</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
: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);
}
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);
}
* { 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; }
/* Top Bar Fluttuante */
#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; }
.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: 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: 30px; max-width: 1400px; margin: 40px auto; padding: 0 20px; }
/* 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; }
.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); }
.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); }
.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 */
.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 */
.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); }
/* 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); }
.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; }
thead { background: rgba(0,0,0,0.05); position: sticky; top: 0; z-index: 1; backdrop-filter: blur(5px); }
body.dark-mode document thead { background: rgba(255,255,255,0.05); }
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 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; }
/* Modals (Vetro scuro) */
.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; }
/* 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; }
/* Fix per il menu a tendina (selezioni) in Dark Mode */
option { background: #ffffff; color: #1e293b; }
body.dark-mode option { background: #0f172a; color: #f8fafc; }
</style>
</head>
<body>
<div id="top-bar-container">
<div id="top-bar">
<div class="title-brand">FLEET MANAGER</div>
<div style="display: flex; align-items: center; gap: 12px;">
<button class="theme-switch" id="lang-btn" onclick="toggleLang()">🇮🇹 ITA</button>
<button class="theme-switch" id="theme-btn" onclick="toggleTheme()">🌙 DARK</button>
<div id="auth-container" style="display:flex; align-items:center; gap:8px;"></div>
</div>
</div>
</div>
<div class="grid" id="client-grid"></div>
<div style="max-width: 1400px; margin: 0 auto;">
<h3 style="margin-left: 20px; font-weight: 800; display: flex; align-items: center; gap: 10px; color: var(--text-main);">
<span style="font-size: 1.5rem;">📡</span> <span data-i18n="lastTransits">Latest Radio Transits</span>
</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th data-i18n="thTime">Time</th>
<th data-i18n="thRep">Repeater</th>
<th data-i18n="thMode">Mode</th>
<th style="text-align: center;">Slot</th>
<th data-i18n="thCall">Callsign</th>
<th>Target / TG</th>
<th data-i18n="thDur">Duration</th>
<th>BER</th>
</tr>
</thead>
<tbody id="log-body"></tbody>
</table>
</div>
</div>
<footer>
<div style="text-align: center; padding: 20px; color: var(--text-muted); font-size: 0.85rem; font-weight: 600;">
&copy; 2026 <strong>IV3JDV @ ARIFVG</strong> | <span id="last-update">Sync: --:--</span>
</div>
</footer>
<div id="login-modal" class="modal-overlay">
<div class="modal-content" style="width:90%; max-width:400px;">
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-color); padding-bottom:15px; margin-bottom:20px;">
<h2 style="margin:0; color:var(--primary);" data-i18n="loginTitle">🔒 Login di Sistema</h2>
<button onclick="closeLoginModal()" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--danger);"></button>
</div>
<div style="display:flex; flex-direction:column; gap:15px;">
<input type="text" id="modal-username" placeholder="Username" style="width:100%; padding:12px; font-size:1rem;" onkeypress="handleLoginEnter(event)">
<input type="password" id="modal-password" placeholder="Password" style="width:100%; padding:12px; font-size:1rem;" onkeypress="handleLoginEnter(event)">
<button onclick="performLogin()" class="btn-cmd" style="background:var(--success); width:100%; padding:12px; margin-top:10px; font-size:1rem;">LOGIN</button>
</div>
</div>
</div>
<div id="admin-modal" class="modal-overlay">
<div class="modal-content" style="width:90%; max-width:900px;">
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-color); padding-bottom:15px; margin-bottom:20px;">
<h2 style="margin:0;" data-i18n="adminTitle">🛠️ User & System Management</h2>
<button onclick="closeAdmin()" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--danger);"></button>
</div>
<div style="display:flex; flex-wrap:wrap; gap:10px; margin-bottom:20px; background:rgba(0,0,0,0.05); padding:15px; border-radius:16px; align-items:center;">
<input type="text" id="new-user" placeholder="Username" style="flex:1; min-width:120px;">
<input type="password" id="new-pass" placeholder="Password" style="flex:1; min-width:120px;">
<select id="new-role" style="flex:0.5; min-width:100px;">
<option value="operator" data-i18n="roleOp">Operator</option>
<option value="admin">Admin</option>
</select>
<input type="text" id="new-nodes" placeholder="Nodes (eg: ir3uic,ir3q)" style="flex:2; min-width:150px;">
<button id="btn-user-submit" onclick="submitUser()" class="btn-cmd" style="background:var(--success); flex:0.5;">+ ADD</button>
<button id="btn-user-cancel" onclick="cancelEdit()" class="btn-cmd" style="background:var(--text-muted); flex:0.2; display:none;" title="Annulla Modifica"></button>
</div>
<div style="margin-bottom:20px; background:rgba(59, 130, 246, 0.05); padding:20px; border-radius:16px; border: 1px solid var(--primary);">
<h4 style="margin:0 0 15px 0; color:var(--primary);" data-i18n="adminDBSync">⚙️ Database Sync Configuration</h4>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:15px; margin-bottom:15px;">
<div><label style="font-size:0.8rem; font-weight:bold; display:block; margin-bottom:5px;">URL DB DMR (.dat):</label><input type="text" id="url-dmr-input" style="width:100%;"></div>
<div><label style="font-size:0.8rem; font-weight:bold; display:block; margin-bottom:5px;">URL DB NXDN (.csv):</label><input type="text" id="url-nxdn-input" style="width:100%;"></div>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; border-top:1px solid var(--border-color); padding-top:15px;">
<div style="display:flex; align-items:center; gap:10px;"><label style="font-size:0.8rem; font-weight:bold;" data-i18n="adminTime">Daily Update Time:</label><input type="time" id="update-time-input"></div>
<button onclick="saveSettings()" class="btn-cmd" style="background:var(--accent); max-width: 250px;" data-i18n="adminSave">SAVE CONFIGURATION</button>
</div>
</div>
<div style="overflow-x:auto;">
<table style="width:100%; border-collapse:collapse; text-align:left;">
<thead><tr><th>ID</th><th data-i18n="thUser">User</th><th data-i18n="thRole">Role</th><th data-i18n="thNodes">Nodes</th><th style="text-align:center;" data-i18n="thActs">Actions</th></tr></thead>
<tbody id="users-table-body"></tbody>
</table>
</div>
</div>
</div>
<div id="services-modal" class="modal-overlay">
<div class="modal-content" style="width:90%; max-width:550px;">
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-color); padding-bottom:15px; margin-bottom:15px;">
<h2 style="margin:0; color:var(--accent);"><span data-i18n="modSvcTitle">⚙️ System Daemons:</span> <span id="svc-modal-title" style="color:var(--text-main);"></span></h2>
<button onclick="closeServicesModal()" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--danger);"></button>
</div>
<div id="services-list" style="max-height: 400px; overflow-y: auto; padding-right:10px;"></div>
</div>
</div>
<div id="configs-modal" class="modal-overlay">
<div class="modal-content" style="width:90%; max-width:500px;">
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-color); padding-bottom:15px; margin-bottom:15px;">
<h2 style="margin:0; color:#8e44ad;" data-i18n="modFileTitle">📂 Configuration Files</h2>
<button onclick="closeConfigsModal()" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--danger);"></button>
</div>
<div id="configs-list" style="display:flex; flex-direction:column; gap:10px;"></div>
</div>
</div>
<div id="editor-modal" class="modal-overlay" style="background:rgba(0,0,0,0.8); z-index: 3000;">
<div class="modal-content" style="width:95%; max-width:900px; border: 1px solid var(--accent);">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<h3 style="margin:0; color:var(--accent);"><span data-i18n="modEditTitle">📝 INI File Editor:</span> <span id="editor-title" style="color:var(--text-main);"></span></h3>
<button onclick="closeEditorModal()" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--danger);"></button>
</div>
<div style="background:rgba(239, 68, 68, 0.1); border-left:4px solid var(--danger); padding:10px; margin-bottom:15px; font-size:0.85rem; font-weight:bold; border-radius: 8px;" data-i18n="warnEdit">
⚠️ WARNING: This editor directly manipulates remote node parameters.
</div>
<textarea id="config-textarea" spellcheck="false" style="width:100%; height:55vh; background:#0f172a; color:#10b981; font-family:'JetBrains Mono', monospace; font-size:0.95rem; padding:15px; border-radius:12px; border:1px solid #334155; resize:none; box-sizing:border-box; outline:none;"></textarea>
<div style="display:flex; justify-content:space-between; margin-top:20px; align-items:center;">
<span id="editor-status" style="font-size:0.9rem; font-weight:bold;"></span>
<button onclick="saveConfig()" class="btn-cmd" style="background:var(--danger); max-width: 250px;" data-i18n="btnSave">💾 SAVE & SEND</button>
</div>
</div>
</div>
<script>
// --- 1. TRANSLATION SYSTEM (i18n) ---
const i18n = {
it: {
themeLight: "☀️ LIGHT", themeDark: "🌙 DARK",
lastTransits: "Ultimi Transiti Radio (MMDVM)", loginTitle: "🔒 Login di Sistema",
thTime: "Ora", thRep: "Ripetitore", thMode: "Modo", thCall: "Nominativo", thDur: "Durata",
thUser: "Utente", thRole: "Ruolo", thNodes: "Nodi", thActs: "Azioni",
btnReqCfg: "🔄 RICHIEDI CONFIG", btnGlobal: "🚨 OVERRIDE GLOBALE", btnAdmin: "🛠️ ADMIN", btnPass: "🔑 PASS", btnLogout: "LOGOUT",
btnSvc: "⚙️ SVC", btnFile: "📂 FILE", btnBoot: "🔄 BOOT",
waitNet: "> Attesa rete...", waitData: "In attesa dei dati dal nodo...", forceUpdate: "🔄 FORZA AGGIORNAMENTO",
adminTitle: "🛠️ Gestione Utenti e Sistema", adminDBSync: "⚙️ Sincronizzazione Database", adminTime: "Orario Aggiornamento:", adminSave: "SALVA CONFIG", roleOp: "Operatore",
modSvcTitle: "⚙️ Demoni Sistema:", modFileTitle: "📂 File di Configurazione", modEditTitle: "📝 Editor INI:",
warnEdit: "⚠️ ATTENZIONE: Questo editor manipola direttamente i parametri del nodo remoto.",
btnEdit: "📝 MODIFICA", btnStart: "▶ START", btnRestart: "🔄 RESTART", btnStop: "🛑 STOP", btnSave: "💾 SALVA ED INVIA",
confOp: "Confermi l'operazione su ", confTgOn: "Vuoi ATTIVARE le notifiche Telegram per il nodo ", confTgOff: "Vuoi SILENZIARE le notifiche Telegram per il nodo ", confOvr: "Sei sicuro di voler sovrascrivere il file su ",
warnDaemon: "⚠️ SVC KO - CONTROLLA DEMONI ⚠️",
promptOvr: "OVERRIDE GLOBALE: Digita 'A' o 'B'", promptOvrConfirm: "Confermi l'invio a TUTTA la rete?"
},
en: {
themeLight: "☀️ LIGHT", themeDark: "🌙 DARK",
lastTransits: "Latest Radio Transits", loginTitle: "🔒 System Login",
thTime: "Time", thRep: "Repeater", thMode: "Mode", thCall: "Callsign", thDur: "Duration",
thUser: "User", thRole: "Role", thNodes: "Nodes", thActs: "Actions",
btnReqCfg: "🔄 REQ CONFIG", btnGlobal: "🚨 GLOBAL OVERRIDE", btnAdmin: "🛠️ ADMIN", btnPass: "🔑 PASS", btnLogout: "LOGOUT",
btnSvc: "⚙️ SVC", btnFile: "📂 FILE", btnBoot: "🔄 BOOT",
waitNet: "> Waiting network...", waitData: "Waiting for node data...", forceUpdate: "🔄 FORCE UPDATE",
adminTitle: "🛠️ User & System Management", adminDBSync: "⚙️ Database Sync Config", adminTime: "Daily Update Time:", adminSave: "SAVE CONFIG", roleOp: "Operator",
modSvcTitle: "⚙️ System Daemons:", modFileTitle: "📂 Config Files", modEditTitle: "📝 INI Editor:",
warnEdit: "⚠️ WARNING: This editor directly manipulates remote node parameters.",
btnEdit: "📝 EDIT", btnStart: "▶ START", btnRestart: "🔄 RESTART", btnStop: "🛑 STOP", btnSave: "💾 SAVE & SEND",
confOp: "Confirm operation on ", confTgOn: "ENABLE Telegram notifications for ", confTgOff: "MUTE Telegram notifications for ", confOvr: "Are you sure you want to overwrite file on ",
warnDaemon: "⚠️ SVC KO - CHECK DAEMONS ⚠️",
promptOvr: "GLOBAL OVERRIDE: Enter 'A' or 'B'", promptOvrConfirm: "Confirm sending to ENTIRE network?"
}
};
let currentLang = localStorage.getItem('lang') || 'en';
function t(key) { return i18n[currentLang][key] || key; }
function toggleLang() { currentLang = currentLang === 'it' ? 'en' : 'it'; localStorage.setItem('lang', currentLang); location.reload(); }
function applyTranslations() {
document.getElementById('lang-btn').innerText = currentLang === 'it' ? "🇬🇧 ENG" : "🇮🇹 ITA";
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (i18n[currentLang][key]) el.innerHTML = i18n[currentLang][key];
});
}
// --- GLOBAL VARIABLES & THEMES ---
let clients = [];
let isAuthenticated = sessionStorage.getItem('is_admin') === 'true';
let globalHealthData = {};
let editingUserId = null; // Memorizza l'ID dell'utente in fase di modifica
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');
}
// --- API & COMMAND FUNCTIONS ---
async function sendCommand(clientId, type) {
if (!confirm(`${t('confOp')}${clientId.toUpperCase()}?`)) return;
try {
const res = await fetch('/api/command', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId, type }) });
const data = await res.json();
if(!data.success) { alert(data.error); }
refreshStates();
} catch (e) { console.error(e); }
}
function confirmSwitch(id, mode) { sendCommand(id, mode); }
function confirmReboot(id) { sendCommand(id, 'REBOOT'); }
async function sendTgCommand(clientId, comando) {
const msg = (comando === 'TG:ON') ? t('confTgOn') : t('confTgOff');
if (!confirm(`${msg}${clientId.toUpperCase()}?`)) return;
try {
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();
if(!data.success) { alert(data.error); }
refreshStates();
} catch (e) { console.error(e); }
}
function sendGlobalUpdate() { fetch('/api/update_nodes', { method: 'POST' }).then(() => { alert("Request sent to nodes!"); }); }
// --- MAIN UI INIT ---
async function initUI() {
applyTranslations();
try {
const response = await fetch('/api/clients');
clients = await response.json();
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') || "";
if (isAuthenticated) {
let adminBtn = role === 'admin' ? `
<button onclick="sendGlobalUpdate()" class="auth-btn" style="background:#f59e0b; color:white;">${t('btnReqCfg')}</button>
<button onclick="triggerGlobalEmergency()" class="auth-btn" style="background:#ef4444; color:white;">${t('btnGlobal')}</button>
<button onclick="openAdmin()" class="auth-btn" style="background:var(--accent); color:white;">${t('btnAdmin')}</button>` : '';
authContainer.innerHTML = `${adminBtn}
<span style="font-weight:800; font-size:0.9rem; margin: 0 10px;">👤 ${sessionStorage.getItem('user_name').toUpperCase()}</span>
<button onclick="changeMyPassword()" class="auth-btn" style="background:#64748b; color:white;">${t('btnPass')}</button>
<button onclick="logout()" class="auth-btn" style="background:var(--text-main); color:var(--card-bg);">${t('btnLogout')}</button>`;
} else {
authContainer.innerHTML = `<button onclick="openLoginModal()" class="auth-btn" style="background:var(--primary); color:white; padding: 8px 25px;">LOGIN</button>`;
}
grid.innerHTML = clients.map(c => {
let canControl = (role === 'admin' || allowed === 'all' || allowed.includes(c.id));
let showReboot = (role === 'admin');
return `
<div class="card" id="card-${c.id}">
<div class="card-header">
<span class="client-name" title="${c.name}">${c.name}</span>
<span class="badge-id">ID: ${c.id.toUpperCase()}</span>
</div>
<div class="health-bar" id="health-${c.id}" style="display: none;">
<span>⚡ <span id="cpu-${c.id}">--</span>%</span>
<span>🌡️ <span id="temp-${c.id}">--</span>°C</span>
<span>🧠 <span id="ram-${c.id}">--</span>%</span>
<span>💾 <span id="disk-${c.id}">--</span>%</span>
</div>
<div class="status-display" id="status-${c.id}">Offline</div>
<div id="ts-container-${c.id}" class="ts-container">
<div class="dmr-info" id="dmr-ts1-${c.id}">TS1: ...</div>
<div class="dmr-info" id="dmr-ts2-${c.id}">TS2: ...</div>
</div>
<div class="dmr-info" id="dmr-alt-${c.id}" style="display: none; text-align: center; font-weight: 800;"></div>
<div class="terminal-log" id="sys-log-${c.id}">${t('waitNet')}</div>
<div id="svc-warn-${c.id}" class="blink" style="display: none; text-align: center; margin-bottom: 15px; border-radius: 12px; padding: 10px;">
<span style="font-size: 0.85rem;" data-i18n="warnDaemon">${t('warnDaemon')}</span>
</div>
<div class="actions" style="${(isAuthenticated && canControl) ? 'display:flex;' : 'display:none'}">
<button id="btn-profA-${c.id}" class="btn-cmd" style="background: var(--accent);" onclick="confirmSwitch('${c.id}', 'A')">PROFILO A</button>
<button id="btn-profB-${c.id}" class="btn-cmd" style="background: #eab308;" onclick="confirmSwitch('${c.id}', 'B')">PROFILO B</button>
<div style="width: 100%; display: flex; gap: 10px;">
<button class="btn-cmd" style="background: var(--success);" onclick="sendTgCommand('${c.id}', 'TG:ON')">🔔 Telegram ON</button>
<button class="btn-cmd" style="background: var(--text-muted);" onclick="sendTgCommand('${c.id}', 'TG:OFF')">🔇 Telegram OFF</button>
</div>
${showReboot ? `
<button id="btn-svc-${c.id}" class="btn-cmd" style="background: #334155;" onclick="openServicesModal('${c.id}')">${t('btnSvc')}</button>
<button class="btn-cmd" style="background: #8e44ad;" onclick="openConfigsModal('${c.id}')">${t('btnFile')}</button>
<button class="btn-cmd btn-reboot" style="background: var(--danger);" onclick="confirmReboot('${c.id}')">${t('btnBoot')}</button>
` : ''}
</div>
</div>`;
}).join('');
refreshStates(); refreshLogs();
setInterval(() => { refreshStates(); refreshLogs(); }, 3000);
} catch (e) { console.error(e); }
}
// --- LOGIN MODAL LOGIC ---
function openLoginModal() {
document.getElementById('login-modal').style.display = 'flex';
setTimeout(() => document.getElementById('modal-username').focus(), 100);
}
function closeLoginModal() {
document.getElementById('login-modal').style.display = 'none';
document.getElementById('modal-username').value = ''; document.getElementById('modal-password').value = '';
}
function handleLoginEnter(e) { if (e.key === 'Enter') performLogin(); }
async function performLogin() {
const user = document.getElementById('modal-username').value; const pass = document.getElementById('modal-password').value;
if (!user || !pass) return;
const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user, pass }) });
const data = await res.json();
if (res.ok) { 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 { alert("Login Failed"); }
}
function logout() { sessionStorage.clear(); location.reload(); }
async function refreshStates() {
try {
const res = await fetch('/api/states');
const data = await res.json();
clients.forEach(c => {
const statusDiv = document.getElementById(`status-${c.id}`);
const cardDiv = document.getElementById(`card-${c.id}`);
if (!statusDiv || !cardDiv) return;
let stateValue = data.states[c.id.toLowerCase()] || data.states[c.id] || "OFFLINE";
stateValue = String(stateValue).trim().toUpperCase();
statusDiv.innerText = stateValue;
const isOnline = !stateValue.includes("OFF") && stateValue !== "";
let telemetryObj = data.telemetry[c.id.toLowerCase()] || data.telemetry[c.id] || { ts1: "...", ts2: "...", alt: "", idle: true };
if (typeof telemetryObj === 'string') telemetryObj = { ts1: telemetryObj, ts2: "...", alt: "", idle: true };
const tsContainer = document.getElementById(`ts-container-${c.id}`);
const ts1Div = document.getElementById(`dmr-ts1-${c.id}`);
const ts2Div = document.getElementById(`dmr-ts2-${c.id}`);
const altDiv = document.getElementById(`dmr-alt-${c.id}`);
let isTx = false;
let activeModeColor = "var(--success)";
let isIdle = telemetryObj.idle === true;
if (telemetryObj.alt && telemetryObj.alt !== "") {
if (tsContainer) tsContainer.style.display = "none";
if (altDiv) {
altDiv.style.display = "block"; altDiv.innerText = telemetryObj.alt;
let altText = telemetryObj.alt.toUpperCase();
if (altText.includes("NXDN")) activeModeColor = "#10b981"; else if (altText.includes("YSF")) activeModeColor = "#8b5cf6"; else if (altText.includes("D-STAR")) activeModeColor = "#06b6d4"; else if (altText.includes("P25")) activeModeColor = "#f59e0b";
isTx = altText.includes("🟢") || altText.includes("🟣") || altText.includes("🔵") || altText.includes("🟠");
altDiv.style.setProperty('color', activeModeColor, 'important');
altDiv.style.setProperty('border-left', `4px solid ${activeModeColor}`, 'important');
if (isTx) { altDiv.classList.add('blink'); } else { altDiv.classList.remove('blink'); }
}
} else {
if (altDiv) altDiv.style.display = "none";
if (tsContainer) tsContainer.style.display = "flex";
let netObj = data.networks && data.networks[c.id.toLowerCase()] ? data.networks[c.id.toLowerCase()] : {ts1: "", ts2: ""};
activeModeColor = "var(--success)";
if (ts1Div && ts2Div) {
[ts1Div, ts2Div].forEach((div, idx) => {
const val = idx === 0 ? telemetryObj.ts1 : telemetryObj.ts2;
const netName = idx === 0 ? netObj.ts1 : netObj.ts2;
const baseLabel = `TS${idx + 1}`;
const fullLabel = netName ? `${baseLabel} [${netName}]` : baseLabel;
div.innerText = `${fullLabel}: ${val}`;
if (val.includes("🎙️")) {
isTx = true;
div.classList.add('blink');
} else {
div.classList.remove('blink');
div.style.setProperty('color', 'var(--text-main)', 'important');
div.style.setProperty('border-left-color', 'var(--primary)', 'important');
div.style.setProperty('background', 'rgba(59, 130, 246, 0.1)', 'important');
}
});
}
}
let healthObj = data.health && data.health[c.id.toLowerCase()];
const healthContainer = document.getElementById(`health-${c.id}`);
const cpuSpan = document.getElementById(`cpu-${c.id}`); const tempSpan = document.getElementById(`temp-${c.id}`); const ramSpan = document.getElementById(`ram-${c.id}`); const diskSpan = document.getElementById(`disk-${c.id}`);
if (healthObj && isOnline) {
healthContainer.style.display = 'flex';
let cpu = healthObj.cpu; cpuSpan.innerText = cpu; cpuSpan.style.color = cpu < 50 ? 'var(--success)' : (cpu < 80 ? '#f59e0b' : 'var(--danger)');
let t = healthObj.temp; tempSpan.innerText = t; tempSpan.style.color = t < 55 ? 'var(--success)' : (t < 70 ? '#f59e0b' : 'var(--danger)');
ramSpan.innerText = healthObj.ram; let d = healthObj.disk; diskSpan.innerText = d; diskSpan.style.color = d < 85 ? 'inherit' : (d < 95 ? '#f59e0b' : 'var(--danger)');
let profA = (healthObj.profiles && healthObj.profiles.A) ? healthObj.profiles.A : "PROFILO A"; let profB = (healthObj.profiles && healthObj.profiles.B) ? healthObj.profiles.B : "PROFILO B";
const btnA = document.getElementById(`btn-profA-${c.id}`); const btnB = document.getElementById(`btn-profB-${c.id}`);
if (btnA && btnA.innerText !== profA) btnA.innerText = profA; if (btnB && btnB.innerText !== profB) btnB.innerText = profB;
} else { if (healthContainer) healthContainer.style.display = 'none'; }
globalHealthData[c.id.toLowerCase()] = healthObj;
let hasOfflineService = false;
if (healthObj && healthObj.processes) {
for (const [svcName, svcStatus] of Object.entries(healthObj.processes)) { if (svcStatus.toLowerCase() !== 'online') { hasOfflineService = true; break; } }
}
const warnBadge = document.getElementById(`svc-warn-${c.id}`); const btnSvc = document.getElementById(`btn-svc-${c.id}`);
if (warnBadge) warnBadge.style.display = hasOfflineService ? 'block' : 'none';
if (btnSvc) {
if (hasOfflineService) { btnSvc.style.background = 'var(--danger)'; btnSvc.classList.add('blink'); btnSvc.innerHTML = '⚠️ DEMONE KO'; }
else { btnSvc.style.background = '#334155'; btnSvc.classList.remove('blink'); btnSvc.innerHTML = t('btnSvc'); }
}
if (isOnline) {
cardDiv.classList.add('online'); statusDiv.classList.remove('status-offline');
cardDiv.style.opacity = "1"; cardDiv.style.filter = "none";
let targetBorderColor = activeModeColor;
if (isTx) { targetBorderColor = (telemetryObj.alt === "") ? "var(--danger)" : activeModeColor; } else if (isIdle) { targetBorderColor = "var(--border-color)"; }
cardDiv.style.setProperty('border-top-color', targetBorderColor, 'important');
if(isTx) cardDiv.style.boxShadow = `0 0 20px rgba(${targetBorderColor === 'var(--danger)' ? '239,68,68' : '16,185,129'}, 0.4)`;
} else {
cardDiv.classList.remove('online'); statusDiv.classList.add('status-offline');
cardDiv.style.opacity = "0.7"; cardDiv.style.filter = "grayscale(80%)";
cardDiv.style.setProperty('border-top-color', '#64748b', 'important');
}
});
document.getElementById('last-update').innerText = "Sync: " + new Date().toLocaleTimeString();
} catch (e) { console.error(e); }
}
async function refreshLogs() {
try {
const res = await fetch('/api/logs'); const logs = await res.json();
const tbody = document.getElementById('log-body'); let tableHTML = ""; let networkLogs = {};
logs.forEach(row => {
const time = row[0] ? row[0].split(' ')[1] : "--:--"; const clientId = row[1]; const protocol = row[2] || "DMR"; const source = row[3] || "---"; const target = row[4] || "---"; const rawSlot = row[5];
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";
if (source.includes("🌐")) {
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>`;
} 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>`;
}
});
tbody.innerHTML = tableHTML;
clients.forEach(c => { const localDiv = document.getElementById(`sys-log-${c.id}`); if (localDiv && networkLogs[c.id]) { localDiv.innerHTML = networkLogs[c.id]; } });
} catch (e) { console.error(e); }
}
// --- GESTIONE UTENTI (ADD & EDIT) ---
async function openAdmin() { document.getElementById('admin-modal').style.display = 'flex'; loadUsers(); loadSettings(); cancelEdit(); }
function closeAdmin() { document.getElementById('admin-modal').style.display = 'none'; cancelEdit(); }
async function loadUsers() {
const res = await fetch('/api/users'); const users = await res.json();
document.getElementById('users-table-body').innerHTML = users.map(u => `
<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 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;" title="Modifica Utente">✏️</button>
<button onclick="deleteUser(${u.id})" class="btn-cmd" style="background:var(--danger); padding:6px; width:auto; display:inline-block;" title="Elimina Utente">🗑️</button>
</td>
</tr>`).join('');
}
function startEditUser(id, username, role, nodes) {
editingUserId = id;
document.getElementById('new-user').value = username;
document.getElementById('new-user').disabled = true; // Impedisce di cambiare il nome
document.getElementById('new-pass').value = "";
document.getElementById('new-pass').placeholder = "Nuova pass (vuoto per non cambiare)";
document.getElementById('new-role').value = role;
document.getElementById('new-nodes').value = nodes;
document.getElementById('btn-user-submit').innerText = "💾 SALVA";
document.getElementById('btn-user-submit').style.background = "var(--accent)";
document.getElementById('btn-user-cancel').style.display = "block";
}
function cancelEdit() {
editingUserId = null;
document.getElementById('new-user').value = "";
document.getElementById('new-user').disabled = false;
document.getElementById('new-pass').value = "";
document.getElementById('new-pass').placeholder = "Password";
document.getElementById('new-role').value = "operator";
document.getElementById('new-nodes').value = "";
document.getElementById('btn-user-submit').innerText = "+ ADD";
document.getElementById('btn-user-submit').style.background = "var(--success)";
document.getElementById('btn-user-cancel').style.display = "none";
}
async function submitUser() {
const username = document.getElementById('new-user').value;
const password = document.getElementById('new-pass').value;
const role = document.getElementById('new-role').value;
let allowed = document.getElementById('new-nodes').value;
if (!username) return alert("Username mancante");
const payload = { username, role, allowed_nodes: allowed || 'all' };
if (password) payload.password = password;
if (editingUserId) {
// EDIT MODE
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 alert(data.error);
} else {
// ADD MODE
if (!password) return alert("Password obbligatoria per nuovo utente");
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 alert(data.error);
}
}
async function deleteUser(id) { if (confirm("Delete user?")) { await fetch(`/api/users/${id}`, {method: 'DELETE'}); loadUsers(); cancelEdit(); } }
// --- EMERGENCY & SETTINGS ---
async function triggerGlobalEmergency() { let action = prompt(`${t('promptOvr')}`); if (action && confirm(t('promptOvrConfirm'))) { const res = await fetch('/api/global_command', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({type: action.toUpperCase()}) }); const d = await res.json(); if(d.success) alert("Command sent!"); else alert(d.error); } }
async function changeMyPassword() { const p = prompt("New password:"); if (p) { 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}) }); if ((await res.json()).success) { alert("Password updated!"); logout(); } } }
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 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 }; 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) alert("Configuration saved!"); else alert("Error saving"); } catch (e) { console.error(e); } }
function openConfigsModal(clientId) {
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>`;
} else {
listDiv.innerHTML = listaFile.map(filename => `
<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;">${filename.toUpperCase()}.ini</span>
<button onclick="closeConfigsModal(); openEditorModal('${clientId}', '${filename}')" class="btn-cmd" style="background:#8e44ad; max-width:120px;">${t('btnEdit')}</button>
</div>`).join('');
}
document.getElementById('configs-modal').style.display = 'flex';
}
function closeConfigsModal() { document.getElementById('configs-modal').style.display = 'none'; }
function openServicesModal(clientId) { document.getElementById('svc-modal-title').innerText = clientId.toUpperCase(); document.getElementById('services-modal').style.display = 'flex'; renderServicesList(clientId); }
function closeServicesModal() { document.getElementById('services-modal').style.display = 'none'; }
function renderServicesList(clientId) {
const data = globalHealthData[clientId.toLowerCase()]; const listDiv = document.getElementById('services-list');
if (!data || !data.processes || Object.keys(data.processes).length === 0) { listDiv.innerHTML = "<p style='text-align:center; color:var(--text-muted);'>No service data available.</p>"; return; }
let html = "";
for (const [name, status] of Object.entries(data.processes)) {
const isOnline = status.toLowerCase() === 'online'; const statusColor = isOnline ? 'var(--success)' : 'var(--danger)'; const statusAnim = isOnline ? '' : 'animation: pulse-glow 1.5s infinite;';
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 style="color:${statusColor}; font-size:0.8rem; font-weight:800; ${statusAnim}">${status.toUpperCase()}</span>
</div>
<div style="display:flex; gap:8px;">
${!isOnline ? `<button onclick="controlService('${clientId}', '${name}', 'restart')" class="btn-cmd" style="background:var(--success);">▶</button>` : ''}
<button onclick="controlService('${clientId}', '${name}', 'restart')" class="btn-cmd" style="background:#f59e0b;">🔄</button>
<button onclick="openEditorModal('${clientId}', '${name}')" class="btn-cmd" style="background:#8e44ad;">📝</button>
${isOnline ? `<button onclick="controlService('${clientId}', '${name}', 'stop')" class="btn-cmd" style="background:var(--danger);">🛑</button>` : ''}
</div>
</div>`;
}
listDiv.innerHTML = html;
}
async function controlService(clientId, service, action) { if (!confirm(`${t('confOp')}${service}?`)) return; 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) alert("Error: " + data.error); } catch(e) { console.error(e); } }
let currentEditClient = ""; let currentEditService = "";
async function openEditorModal(clientId, service) {
currentEditClient = clientId; currentEditService = service;
document.getElementById('editor-title').innerText = `${service.toUpperCase()} @ ${clientId.toUpperCase()}`;
document.getElementById('config-textarea').value = "Connecting to server..."; document.getElementById('editor-status').innerText = "";
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."; }
}
function closeEditorModal() { document.getElementById('editor-modal').style.display = 'none'; }
async function saveConfig() {
const textValue = document.getElementById('config-textarea').value; const statusSpan = document.getElementById('editor-status');
if (!confirm(`${t('confOvr')}${currentEditClient.toUpperCase()}?`)) return;
statusSpan.innerText = "Sending..."; statusSpan.style.color = "var(--success)";
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 data = await res.json();
if (data.success) { alert("File updated!"); closeEditorModal(); } else { alert("Server error: " + data.error); statusSpan.innerText = "❌ Error"; }
} catch(e) { alert("Network error."); statusSpan.innerText = "❌ Network Error"; }
}
initUI();
</script>
</body>
</html>