adding RESET-HAT function & makeup
This commit is contained in:
+98
-56
@@ -12,47 +12,55 @@ import requests
|
||||
from pathlib import Path
|
||||
import configparser
|
||||
|
||||
try:
|
||||
import RPi.GPIO as GPIO
|
||||
# This variable must be GLOBAL, so defined at the top!
|
||||
GPIO_AVAILABLE = True
|
||||
except ImportError:
|
||||
GPIO_AVAILABLE = False
|
||||
print("Warning: RPi.GPIO library not found. Hardware reset disabled.")
|
||||
|
||||
# ==========================================
|
||||
# 0. CARICAMENTO CONFIGURAZIONE UNIFICATA
|
||||
# 0. UNIFIED CONFIGURATION LOADING
|
||||
# ==========================================
|
||||
CONFIG_PATH = Path("/opt/node_config.json")
|
||||
|
||||
def load_config():
|
||||
try:
|
||||
if not CONFIG_PATH.exists():
|
||||
print(f"❌ ERRORE: File {CONFIG_PATH} non trovato!")
|
||||
print(f"❌ ERROR: File {CONFIG_PATH} not found!")
|
||||
sys.exit(1)
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"❌ ERRORE CRITICO JSON: {e}")
|
||||
print(f"❌ CRITICAL JSON ERROR: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Carichiamo l'unica configurazione necessaria
|
||||
# Load the single necessary configuration
|
||||
cfg = load_config()
|
||||
|
||||
# Identificativi e Topic
|
||||
# Identifiers and Topics
|
||||
CLIENT_ID = cfg.get('client_id', 'iv3jdv').lower()
|
||||
BASE_TOPIC = cfg.get('mqtt', {}).get('base_topic', f"servizi/{CLIENT_ID}")
|
||||
|
||||
TOPIC_CMD = f"{BASE_TOPIC}/cmnd"
|
||||
TOPIC_STAT = f"{BASE_TOPIC}/stat"
|
||||
|
||||
# Variabili di Stato Globali
|
||||
# Global State Variables
|
||||
boot_recovered = False
|
||||
current_status = "ONLINE - Pronto"
|
||||
current_status = "ONLINE - Ready"
|
||||
auto_healing_counter = {}
|
||||
|
||||
# ==========================================
|
||||
# 1. FUNZIONE NOTIFICA TELEGRAM
|
||||
# 1. TELEGRAM NOTIFICATION FUNCTION
|
||||
# ==========================================
|
||||
def send_telegram_message(message):
|
||||
t_cfg = cfg.get('telegram', {})
|
||||
if not t_cfg.get('enabled', False): return
|
||||
|
||||
ora_attuale = int(time.strftime("%H"))
|
||||
if ora_attuale >= 23 or ora_attuale < 7:
|
||||
print(f"🌙 Notte fonda ({ora_attuale}:00): Notifica evitata.")
|
||||
current_hour = int(time.strftime("%H"))
|
||||
if current_hour >= 23 or current_hour < 7:
|
||||
print(f"🌙 Late night ({current_hour}:00): Notification skipped.")
|
||||
return
|
||||
|
||||
token = t_cfg.get('token')
|
||||
@@ -65,58 +73,58 @@ def send_telegram_message(message):
|
||||
payload = {"chat_id": chat_id, "text": f"[{CLIENT_ID.upper()}]\n{clean_msg}"}
|
||||
requests.post(url, json=payload, timeout=10)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Errore invio Telegram: {e}")
|
||||
print(f"⚠️ Telegram send error: {e}")
|
||||
|
||||
# ==========================================
|
||||
# 2. LOGICA CAMBIO PROFILO MULTIPLO
|
||||
# 2. MULTIPLE PROFILE SWITCH LOGIC
|
||||
# ==========================================
|
||||
|
||||
def get_actual_config_from_disk():
|
||||
return "ONLINE - Da memoria"
|
||||
return "ONLINE - From memory"
|
||||
|
||||
def switch_config(config_type):
|
||||
profile = cfg.get('profiles', {}).get(config_type)
|
||||
|
||||
if not profile:
|
||||
return f"ERRORE: Profilo {config_type} non trovato in JSON"
|
||||
return f"ERROR: Profile {config_type} not found in JSON"
|
||||
|
||||
label = profile.get('label', f"Profilo {config_type}")
|
||||
label = profile.get('label', f"Profile {config_type}")
|
||||
services = profile.get('services', [])
|
||||
|
||||
if not services:
|
||||
return f"ERRORE: Nessun servizio configurato per {config_type}"
|
||||
return f"ERROR: No services configured for {config_type}"
|
||||
|
||||
try:
|
||||
# 1. STOP: Ferma prima tutti i demoni coinvolti per liberare i file
|
||||
# 1. STOP: Stop all involved daemons first to release files
|
||||
for s in services:
|
||||
subprocess.run(["sudo", "systemctl", "stop", s['name']], check=False)
|
||||
|
||||
# 2. COPIA: Verifica e copia tutti i file di configurazione
|
||||
# 2. COPY: Verify and copy all configuration files
|
||||
for s in services:
|
||||
if not os.path.exists(s['source']):
|
||||
return f"ERRORE: Manca il file sorgente {s['source']}"
|
||||
return f"ERROR: Missing source file {s['source']}"
|
||||
shutil.copy(s['source'], s['target'])
|
||||
|
||||
# 3. START: Fa ripartire tutti i demoni con i nuovi file
|
||||
# 3. START: Restart all daemons with the new files
|
||||
for s in services:
|
||||
subprocess.run(["sudo", "systemctl", "start", s['name']], check=False)
|
||||
|
||||
send_telegram_message(f"✅ Switch multiplo completato: {label}")
|
||||
send_telegram_message(f"✅ Multiple switch completed: {label}")
|
||||
return f"ONLINE - {label}"
|
||||
|
||||
except Exception as e:
|
||||
return f"ERRORE: {str(e)}"
|
||||
return f"ERROR: {str(e)}"
|
||||
|
||||
def force_online_if_needed(client):
|
||||
global boot_recovered, current_status
|
||||
if not boot_recovered:
|
||||
print("⚠️ Recupero memoria saltato. Imposto stato da disco...")
|
||||
print("⚠️ Memory recovery skipped. Setting status from disk...")
|
||||
current_status = get_actual_config_from_disk()
|
||||
client.publish(TOPIC_STAT, current_status, retain=True)
|
||||
boot_recovered = True
|
||||
|
||||
# ==========================================
|
||||
# 3. TELEMETRIA E AUTO-HEALING
|
||||
# 3. TELEMETRY AND AUTO-HEALING
|
||||
# ==========================================
|
||||
def get_cpu_temperature():
|
||||
temp = 0.0
|
||||
@@ -139,8 +147,8 @@ def get_system_status():
|
||||
"processes": {},
|
||||
"timestamp": time.strftime("%H:%M:%S"),
|
||||
"profiles": {
|
||||
"A": cfg.get('profiles', {}).get('A', {}).get('label', 'PROFILO A'),
|
||||
"B": cfg.get('profiles', {}).get('B', {}).get('label', 'PROFILO B')
|
||||
"A": cfg.get('profiles', {}).get('A', {}).get('label', 'PROFILE A'),
|
||||
"B": cfg.get('profiles', {}).get('B', {}).get('label', 'PROFILE B')
|
||||
}
|
||||
}
|
||||
proc_path = Path(cfg['paths'].get('process_list', ''))
|
||||
@@ -151,7 +159,7 @@ def get_system_status():
|
||||
for name in target_processes:
|
||||
name = name.strip().lower()
|
||||
if name: status["processes"][name] = "online" if name in running_names else "offline"
|
||||
except Exception as e: print(f"Errore processi: {e}")
|
||||
except Exception as e: print(f"Process error: {e}")
|
||||
return status
|
||||
|
||||
def check_auto_healing(client, status):
|
||||
@@ -161,12 +169,12 @@ def check_auto_healing(client, status):
|
||||
attempts = auto_healing_counter.get(proc_name, 0)
|
||||
if attempts < 3:
|
||||
auto_healing_counter[proc_name] = attempts + 1
|
||||
msg = f"🛠 Auto-healing: {proc_name} offline. Riavvio {attempts+1}/3..."
|
||||
msg = f"🛠 Auto-healing: {proc_name} offline. Restarting {attempts+1}/3..."
|
||||
client.publish(f"devices/{CLIENT_ID}/logs", msg)
|
||||
send_telegram_message(msg)
|
||||
subprocess.run(["sudo", "systemctl", "restart", proc_name])
|
||||
elif attempts == 3:
|
||||
msg = f"🚨 CRITICO: {proc_name} fallito!"
|
||||
msg = f"🚨 CRITICAL: {proc_name} failed!"
|
||||
client.publish(f"devices/{CLIENT_ID}/logs", msg)
|
||||
send_telegram_message(msg)
|
||||
auto_healing_counter[proc_name] = 4
|
||||
@@ -176,7 +184,7 @@ def check_auto_healing(client, status):
|
||||
def publish_all(client):
|
||||
status = get_system_status()
|
||||
|
||||
# Lettura della lista file per il menu della Dashboard
|
||||
# Read file list for Dashboard menu
|
||||
file_list_path = Path(cfg['paths'].get('file_list', ''))
|
||||
status["config_files"] = []
|
||||
status["files"] = []
|
||||
@@ -184,9 +192,9 @@ def publish_all(client):
|
||||
if file_list_path.exists():
|
||||
try:
|
||||
files = file_list_path.read_text(encoding="utf-8").splitlines()
|
||||
nomi_estrattti = [Path(f.strip()).stem for f in files if f.strip()]
|
||||
status["config_files"] = nomi_estrattti
|
||||
status["files"] = nomi_estrattti
|
||||
extracted_names = [Path(f.strip()).stem for f in files if f.strip()]
|
||||
status["config_files"] = extracted_names
|
||||
status["files"] = extracted_names
|
||||
except: pass
|
||||
|
||||
client.publish(f"devices/{CLIENT_ID}/services", json.dumps(status), qos=1)
|
||||
@@ -209,7 +217,7 @@ def publish_all_ini_files(client):
|
||||
with open(file_list_path, 'r') as f:
|
||||
files_to_parse = [line.strip() for line in f if line.strip()]
|
||||
except Exception as e:
|
||||
print(f"Errore lettura {file_list_path}: {e}")
|
||||
print(f"Error reading {file_list_path}: {e}")
|
||||
return
|
||||
|
||||
for file_path in files_to_parse:
|
||||
@@ -218,38 +226,38 @@ def publish_all_ini_files(client):
|
||||
try:
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
|
||||
# --- INIZIO PARSER MANUALE (Anti-Chiavi Doppie) ---
|
||||
# --- START MANUAL PARSER (Anti-Duplicate Keys) ---
|
||||
ini_data = {}
|
||||
current_section = None
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# Salta righe vuote o commenti
|
||||
# Skip empty lines or comments
|
||||
if not line or line.startswith(('#', ';')):
|
||||
continue
|
||||
# Riconosce le sezioni [Nome Sezione]
|
||||
# Recognize sections [Section Name]
|
||||
if line.startswith('[') and line.endswith(']'):
|
||||
current_section = line[1:-1].strip()
|
||||
ini_data[current_section] = {}
|
||||
# Riconosce le chiavi e i valori
|
||||
# Recognize keys and values
|
||||
elif '=' in line and current_section is not None:
|
||||
k, v = line.split('=', 1)
|
||||
k, v = k.strip(), v.strip()
|
||||
|
||||
# LA MAGIA: Se la chiave esiste già, la unisce con una virgola!
|
||||
# THE MAGIC: If the key already exists, merge it with a comma!
|
||||
if k in ini_data[current_section]:
|
||||
ini_data[current_section][k] = str(ini_data[current_section][k]) + "," + v
|
||||
else:
|
||||
ini_data[current_section][k] = v
|
||||
|
||||
# Pubblicazione sul broker MQTT
|
||||
# Publish on MQTT broker
|
||||
for section, payload in ini_data.items():
|
||||
topic = f"data/{CLIENT_ID}/{base_name}/{section}"
|
||||
client.publish(topic, json.dumps(payload), retain=True)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Errore parsing INI per {file_path}: {e}")
|
||||
print(f"INI parsing error for {file_path}: {e}")
|
||||
|
||||
def write_config_from_json(slug, json_payload):
|
||||
file_list_path = Path(cfg['paths'].get('file_list', ''))
|
||||
@@ -259,20 +267,20 @@ def write_config_from_json(slug, json_payload):
|
||||
for f in files:
|
||||
p = Path(f.strip())
|
||||
if p.stem.lower() == slug.lower():
|
||||
nuovi_dati = json.loads(json_payload)
|
||||
new_data = json.loads(json_payload)
|
||||
shutil.copy(p, str(p) + ".bak")
|
||||
with open(p, 'w', encoding="utf-8") as file: file.write(nuovi_dati.get("raw_text", ""))
|
||||
with open(p, 'w', encoding="utf-8") as file: file.write(new_data.get("raw_text", ""))
|
||||
os.system(f"sudo systemctl restart {slug}")
|
||||
send_telegram_message(f"📝 Config {slug.upper()} aggiornata via Web.")
|
||||
send_telegram_message(f"📝 Config {slug.upper()} updated via Web.")
|
||||
break
|
||||
except Exception as e: print(f"Errore scrittura config: {e}")
|
||||
except Exception as e: print(f"Config write error: {e}")
|
||||
|
||||
# ==========================================
|
||||
# 4. CALLBACK MQTT
|
||||
# 4. MQTT CALLBACKS
|
||||
# ==========================================
|
||||
def on_connect(client, userdata, flags, rc, properties=None):
|
||||
if rc == 0:
|
||||
print(f"✅ Connesso: {CLIENT_ID.upper()}")
|
||||
print(f"✅ Connected: {CLIENT_ID.upper()}")
|
||||
client.subscribe([(TOPIC_CMD, 0), (TOPIC_STAT, 0)])
|
||||
client.subscribe([
|
||||
("devices/control/request", 0),
|
||||
@@ -281,7 +289,7 @@ def on_connect(client, userdata, flags, rc, properties=None):
|
||||
])
|
||||
threading.Timer(5.0, force_online_if_needed, [client]).start()
|
||||
publish_all(client)
|
||||
publish_all_ini_files(client) # Pubblica gli INI appena si connette
|
||||
publish_all_ini_files(client) # Publish INIs as soon as connected
|
||||
|
||||
def on_message(client, userdata, msg):
|
||||
global boot_recovered, current_status, cfg
|
||||
@@ -289,7 +297,7 @@ def on_message(client, userdata, msg):
|
||||
topic = msg.topic
|
||||
|
||||
if topic == TOPIC_STAT and not boot_recovered:
|
||||
if not any(x in payload.upper() for x in ["OFFLINE", "ERRORE", "RIAVVIO"]):
|
||||
if not any(x in payload.upper() for x in ["OFFLINE", "ERROR", "REBOOT"]):
|
||||
current_status = payload
|
||||
boot_recovered = True
|
||||
client.publish(TOPIC_STAT, current_status, retain=True)
|
||||
@@ -302,16 +310,50 @@ def on_message(client, userdata, msg):
|
||||
boot_recovered = True
|
||||
publish_all(client)
|
||||
elif cmd == "REBOOT":
|
||||
client.publish(TOPIC_STAT, f"OFFLINE - Riavvio {CLIENT_ID.upper()}...", retain=False)
|
||||
client.publish(TOPIC_STAT, f"OFFLINE - Rebooting {CLIENT_ID.upper()}...", retain=False)
|
||||
time.sleep(1)
|
||||
subprocess.run(["sudo", "reboot"], check=True)
|
||||
elif cmd == 'RESET_HAT':
|
||||
# Correct GPIO pin for MMDVM board hardware reset
|
||||
RESET_PIN = 21
|
||||
|
||||
if GPIO_AVAILABLE:
|
||||
try:
|
||||
GPIO.setwarnings(False)
|
||||
GPIO.setmode(GPIO.BCM)
|
||||
GPIO.setup(RESET_PIN, GPIO.OUT)
|
||||
|
||||
# 1. Send reset pulse (LOW for 0.5 seconds)
|
||||
GPIO.output(RESET_PIN, GPIO.LOW)
|
||||
time.sleep(0.5)
|
||||
GPIO.output(RESET_PIN, GPIO.HIGH)
|
||||
|
||||
# Release GPIO resources
|
||||
GPIO.cleanup(RESET_PIN)
|
||||
print(f"[{CLIENT_ID}] RESET pulse sent to GPIO {RESET_PIN}")
|
||||
|
||||
# 2. Wait 1.5 seconds to let the microcontroller firmware reboot
|
||||
time.sleep(1.5)
|
||||
|
||||
# 3. Restart MMDVMHost service to realign serial communication
|
||||
print(f"[{CLIENT_ID}] Restarting MMDVMHost...")
|
||||
subprocess.run(["sudo", "systemctl", "restart", "mmdvmhost"], check=False)
|
||||
|
||||
# 4. Send confirmations to dashboard
|
||||
client.publish(f"fleet/{CLIENT_ID}/status", "HAT RESET + MMDVM RESTART OK")
|
||||
client.publish(f"devices/{CLIENT_ID}/logs", "🔌 HAT Reset + MMDVMHost Restarted")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during GPIO/MMDVMHost reset: {e}")
|
||||
client.publish(f"fleet/{CLIENT_ID}/status", f"RESET ERROR: {e}")
|
||||
|
||||
elif cmd in ["TG:OFF", "TG:ON"]:
|
||||
nuovo_stato = (cmd == "TG:ON")
|
||||
cfg['telegram']['enabled'] = nuovo_stato
|
||||
new_state = (cmd == "TG:ON")
|
||||
cfg['telegram']['enabled'] = new_state
|
||||
try:
|
||||
with open(CONFIG_PATH, 'w') as f: json.dump(cfg, f, indent=4)
|
||||
client.publish(f"devices/{CLIENT_ID}/logs", f"{'🔔' if nuovo_stato else '🔇'} Notifiche {'ON' if nuovo_stato else 'OFF'}")
|
||||
if nuovo_stato: send_telegram_message("Notifiche riattivate!")
|
||||
client.publish(f"devices/{CLIENT_ID}/logs", f"{'🔔' if new_state else '🔇'} Notifications {'ON' if new_state else 'OFF'}")
|
||||
if new_state: send_telegram_message("Notifications enabled!")
|
||||
except: pass
|
||||
|
||||
elif topic == "devices/control/request" and payload.lower() in ["status", "update"]:
|
||||
@@ -338,7 +380,7 @@ def on_message(client, userdata, msg):
|
||||
def auto_publish_task(client):
|
||||
while True:
|
||||
status = publish_all(client)
|
||||
publish_all_ini_files(client) # <--- ECCO IL LOOP CORRETTO!
|
||||
publish_all_ini_files(client) # <--- HERE IS THE CORRECT LOOP!
|
||||
check_auto_healing(client, status)
|
||||
time.sleep(cfg['settings'].get('update_interval', 30))
|
||||
|
||||
|
||||
+289
-96
@@ -28,7 +28,7 @@
|
||||
* { 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 */
|
||||
/* Floating Top Bar */
|
||||
#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; }
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
.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; }
|
||||
|
||||
/* Stile intestazione tabella (vetro smerigliato) */
|
||||
/* Table Header Frosted Glass */
|
||||
thead { background: rgba(255, 255, 255, 0.9); position: sticky; top: 0; z-index: 1; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05); }
|
||||
body.dark-mode thead { background: rgba(15, 23, 42, 0.95); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.3); }
|
||||
th { padding: 15px; font-weight: 700; text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.5px; color: var(--text-muted); }
|
||||
@@ -80,7 +80,7 @@
|
||||
@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) */
|
||||
/* Modals (Dark Glass) */
|
||||
.modal-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); backdrop-filter:blur(5px); -webkit-backdrop-filter:blur(5px); z-index:1000; align-items:center; justify-content:center; }
|
||||
.modal-content { background:var(--card-bg); backdrop-filter:blur(20px); border:1px solid var(--border-color); padding:30px; border-radius:24px; box-shadow:0 25px 50px -12px rgba(0,0,0,0.5); max-height: 90vh; overflow-y: auto; }
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
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 */
|
||||
/* Dropdown Fix in Dark Mode */
|
||||
option { background: #ffffff; color: #1e293b; }
|
||||
body.dark-mode option { background: #0f172a; color: #f8fafc; }
|
||||
</style>
|
||||
@@ -151,7 +151,7 @@
|
||||
<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>
|
||||
<h2 style="margin:0; color:var(--primary);" data-i18n="loginTitle">🔒 System Login</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;">
|
||||
@@ -177,8 +177,8 @@
|
||||
<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>
|
||||
<button id="btn-user-submit" onclick="submitUser()" class="btn-cmd" style="background:var(--success); flex:0.5;" data-i18n="btnAdd">+ ADD</button>
|
||||
<button id="btn-user-cancel" onclick="cancelEdit()" class="btn-cmd" style="background:var(--text-muted); flex:0.2; display:none;" title="Cancel">✖</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:20px; background:rgba(59, 130, 246, 0.05); padding:20px; border-radius:16px; border: 1px solid var(--primary);">
|
||||
@@ -240,12 +240,65 @@
|
||||
|
||||
<div id="override-modal" class="modal-overlay" style="z-index: 3000;">
|
||||
<div class="modal-content" style="width:90%; max-width:400px; text-align:center;">
|
||||
<h2 style="margin-top:0; color:var(--danger);">🚨 <span data-i18n="btnGlobal">OVERRIDE GLOBALE</span> 🚨</h2>
|
||||
<p style="margin-bottom:25px; color:var(--text-muted); font-weight: 600;" id="override-desc">Seleziona il profilo da inviare a TUTTA la rete:</p>
|
||||
<h2 style="margin-top:0; color:var(--danger);">🚨 <span data-i18n="titleGlobal">GLOBAL OVERRIDE</span> 🚨</h2>
|
||||
<p style="margin-bottom:25px; color:var(--text-muted); font-weight: 600;" id="override-desc">Select the profile to send to the ENTIRE network:</p>
|
||||
<div style="display:flex; flex-direction:column; gap:15px;">
|
||||
<button id="btn-global-A" onclick="sendGlobalAction('A')" class="btn-cmd" style="background:var(--accent); padding:15px; font-size:1.1rem;">PROFILO A</button>
|
||||
<button id="btn-global-B" onclick="sendGlobalAction('B')" class="btn-cmd" style="background:#eab308; padding:15px; font-size:1.1rem;">PROFILO B</button>
|
||||
<button onclick="document.getElementById('override-modal').style.display='none'" class="btn-cmd" style="background:var(--text-muted); padding:12px; margin-top:10px;">ANNULLA</button>
|
||||
<button id="btn-global-A" onclick="sendGlobalAction('A')" class="btn-cmd" style="background:var(--accent); padding:15px; font-size:1.1rem;">PROFILE A</button>
|
||||
<button id="btn-global-B" onclick="sendGlobalAction('B')" class="btn-cmd" style="background:#eab308; padding:15px; font-size:1.1rem;">PROFILE B</button>
|
||||
<button onclick="document.getElementById('override-modal').style.display='none'" class="btn-cmd" style="background:var(--text-muted); padding:12px; margin-top:10px;" data-i18n="btnCancel">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="reset-hat-modal" class="modal-overlay" style="z-index: 3000;">
|
||||
<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>
|
||||
<p style="margin-bottom:25px; color:var(--text-main); font-weight: 600;" id="reset-hat-desc">Do you want to send a PHYSICAL RESET to the radio board?</p>
|
||||
<div style="display:flex; flex-direction:column; gap:15px;">
|
||||
<button onclick="executeHatReset()" class="btn-cmd blink" style="background:var(--danger); padding:15px; font-size:1.1rem; font-weight:800;" data-i18n="btnConfReset">⚠️ CONFIRM RESET</button>
|
||||
<button onclick="closeHatResetModal()" class="btn-cmd" style="background:var(--text-muted); padding:12px; margin-top:10px;" data-i18n="btnCancel">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="update-modal" class="modal-overlay" style="z-index: 3000;">
|
||||
<div class="modal-content" style="width:90%; max-width:400px; text-align:center; border: 1px solid var(--accent);">
|
||||
<h2 style="margin-top:0; color:var(--accent);">🔄 <span data-i18n="modUpdateTitle">GLOBAL UPDATE</span> 🔄</h2>
|
||||
<p style="margin-bottom:25px; color:var(--text-main); font-weight: 600;" data-i18n="confUpdate">Do you want to request updated data from all nodes in the fleet?</p>
|
||||
<div style="display:flex; flex-direction:column; gap:15px;">
|
||||
<button onclick="executeUpdateRequest()" class="btn-cmd" style="background:var(--accent); padding:15px; font-size:1.1rem; font-weight:800;" data-i18n="btnReqCfg">🔄 REQ CONFIG</button>
|
||||
<button onclick="closeUpdateModal()" class="btn-cmd" style="background:var(--text-muted); padding:12px; margin-top:10px;" data-i18n="btnCancel">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="alert-modal" class="modal-overlay" style="z-index: 5000;">
|
||||
<div class="modal-content" style="width:90%; max-width:400px; text-align:center; border: 2px solid var(--primary);" id="alert-box">
|
||||
<h2 style="margin-top:0;" id="alert-title">ℹ️ INFO</h2>
|
||||
<p style="margin-bottom:25px; color:var(--text-main); font-weight: 600;" id="alert-desc">Message</p>
|
||||
<button onclick="document.getElementById('alert-modal').style.display='none'" class="btn-cmd" style="background:var(--primary); padding:12px; width:100%;">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="confirm-modal" class="modal-overlay" style="z-index: 4000;">
|
||||
<div class="modal-content" style="width:90%; max-width:400px; text-align:center; border: 2px solid var(--danger);" id="confirm-box">
|
||||
<h2 style="margin-top:0;" id="confirm-title">⚠️ WARNING</h2>
|
||||
<p style="margin-bottom:25px; color:var(--text-main); font-weight: 600;" id="confirm-desc">Are you sure?</p>
|
||||
<div style="display:flex; flex-direction:column; gap:15px;">
|
||||
<button id="confirm-yes-btn" onclick="executeConfirmAction()" class="btn-cmd" style="background:var(--danger); padding:15px; font-size:1.1rem; font-weight:800;">YES, PROCEED</button>
|
||||
<button id="confirm-cancel-btn" onclick="document.getElementById('confirm-modal').style.display='none'" class="btn-cmd" style="background:var(--text-muted); padding:12px; margin-top:10px;">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="password-modal" class="modal-overlay" style="z-index: 3000;">
|
||||
<div class="modal-content" style="width:90%; max-width:400px; text-align:center; border: 1px solid var(--accent);">
|
||||
<h2 style="margin-top:0; color:var(--accent);">🔑 <span data-i18n="btnPass">PASSWORD CHANGE</span></h2>
|
||||
<p style="margin-bottom:20px; color:var(--text-main); font-weight: 600;" data-i18n="promptPass">Enter the new password:</p>
|
||||
<input type="password" id="new-password-input" style="width:100%; padding:12px; font-size:1rem; margin-bottom:20px; text-align:center;" placeholder="***">
|
||||
<div style="display:flex; flex-direction:column; gap:10px;">
|
||||
<button onclick="executePasswordChange()" class="btn-cmd" style="background:var(--success); padding:15px; font-size:1.1rem; font-weight:800;" data-i18n="titleSave">💾 SAVE</button>
|
||||
<button onclick="closePasswordModal()" class="btn-cmd" style="background:var(--text-muted); padding:12px;" data-i18n="btnCancel">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,37 +306,53 @@
|
||||
<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",
|
||||
btnSvc: "⚙️ SVC", btnFile: "📂 FILE", btnBoot: "🔄 BOOT", btnAdd: "+ ADD",
|
||||
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",
|
||||
btnHat: "🔌 RESET HAT", confHat: "Are you sure you want to physically reset the MMDVM board on node ",
|
||||
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?"
|
||||
promptOvr: "GLOBAL OVERRIDE", promptOvrConfirm: "Confirm sending to ENTIRE network?",
|
||||
modUpdateTitle: "🔄 GLOBAL UPDATE", confUpdate: "Do you want to request updated data and configuration files from all nodes in the fleet?", alertUpdateOk: "Request sent successfully!",
|
||||
btnConfReset: "⚠️ CONFIRM RESET", btnCancel: "CANCEL", promptPass: "Enter the new password:",
|
||||
titleError: "❌ ERROR", titleSuccess: "✅ SUCCESS", titleAction: "⚙️ CONFIRM ACTION", titleDelete: "🗑️ DELETE USER", titleSave: "💾 SAVE", titleGlobal: "🚨 GLOBAL OVERRIDE",
|
||||
btnYes: "YES, PROCEED", msgDelUser: "Confirm user deletion?", msgPassOk: "Password updated successfully!", msgPassErr: "Failed to update password.", msgLoginFail: "Login Failed",
|
||||
msgConfigSaved: "Configuration saved!", msgConfigErr: "Error saving configuration", msgNetErr: "Network Error", msgOvrSel: "Select the profile to send to the ENTIRE network:",
|
||||
msgOvrOk: "Command successfully sent to the network!", msgMissUser: "Missing Username", msgMissPass: "Password required for new user", btnSvcKo: "⚠️ DAEMON KO",
|
||||
phNewPass: "New pass (empty to keep)", phPass: "Password"
|
||||
},
|
||||
it: {
|
||||
themeLight: "☀️ CHIARO", themeDark: "🌙 SCURO",
|
||||
lastTransits: "Ultimi Transiti Radio", 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", btnAdd: "+ AGGIUNGI",
|
||||
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",
|
||||
btnHat: "🔌 RESET HAT", confHat: "Sei sicuro di voler resettare fisicamente la scheda MMDVM sul nodo ",
|
||||
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", promptOvrConfirm: "Confermi l'invio a TUTTA la rete?",
|
||||
modUpdateTitle: "🔄 AGGIORNAMENTO GLOBALE", confUpdate: "Vuoi richiedere i dati e i file di configurazione aggiornati a tutti i nodi della flotta?", alertUpdateOk: "Richiesta inviata con successo!",
|
||||
btnConfReset: "⚠️ CONFERMA RESET", btnCancel: "ANNULLA", promptPass: "Inserisci la nuova password:",
|
||||
titleError: "❌ ERRORE", titleSuccess: "✅ OK", titleAction: "⚙️ CONFERMA AZIONE", titleDelete: "🗑️ ELIMINA UTENTE", titleSave: "💾 SALVATAGGIO", titleGlobal: "🚨 OVERRIDE GLOBALE",
|
||||
btnYes: "SI, PROCEDI", msgDelUser: "Confermi l'eliminazione dell'utente?", msgPassOk: "Password aggiornata con successo!", msgPassErr: "Errore durante l'aggiornamento della password.", msgLoginFail: "Login Fallito",
|
||||
msgConfigSaved: "Configurazione salvata!", msgConfigErr: "Errore durante il salvataggio", msgNetErr: "Errore di rete", msgOvrSel: "Seleziona il profilo da inviare a TUTTA la rete:",
|
||||
msgOvrOk: "Comando inviato con successo a tutta la rete!", msgMissUser: "Username mancante", msgMissPass: "Password obbligatoria per il nuovo utente", btnSvcKo: "⚠️ DEMONE KO",
|
||||
phNewPass: "Nuova pass (vuota per non cambiare)", phPass: "Password"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -296,13 +365,18 @@
|
||||
const key = el.getAttribute('data-i18n');
|
||||
if (i18n[currentLang][key]) el.innerHTML = i18n[currentLang][key];
|
||||
});
|
||||
const newPassInput = document.getElementById('new-pass');
|
||||
if (newPassInput && editingUserId) newPassInput.placeholder = t('phNewPass');
|
||||
else if (newPassInput) newPassInput.placeholder = t('phPass');
|
||||
}
|
||||
|
||||
// --- 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
|
||||
let editingUserId = null;
|
||||
let currentResetHatId = null;
|
||||
let confirmActionCallback = null;
|
||||
|
||||
function toggleTheme() {
|
||||
const isDark = document.body.classList.toggle('dark-mode');
|
||||
@@ -310,30 +384,96 @@
|
||||
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); }
|
||||
// --- NEW GLOBAL GLASSMORPHISM POPUPS ---
|
||||
function customAlert(title, desc, isError = false) {
|
||||
const color = isError ? 'var(--danger)' : 'var(--success)';
|
||||
document.getElementById('alert-title').innerHTML = title;
|
||||
document.getElementById('alert-title').style.color = color;
|
||||
document.getElementById('alert-box').style.borderColor = color;
|
||||
document.getElementById('alert-desc').innerHTML = desc;
|
||||
document.getElementById('alert-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function customConfirm(title, desc, color, callback) {
|
||||
document.getElementById('confirm-title').innerHTML = title;
|
||||
document.getElementById('confirm-title').style.color = color;
|
||||
document.getElementById('confirm-box').style.borderColor = color;
|
||||
document.getElementById('confirm-desc').innerHTML = desc;
|
||||
document.getElementById('confirm-yes-btn').style.background = color;
|
||||
document.getElementById('confirm-yes-btn').innerText = t('btnYes');
|
||||
document.getElementById('confirm-cancel-btn').innerText = t('btnCancel');
|
||||
confirmActionCallback = callback;
|
||||
document.getElementById('confirm-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function executeConfirmAction() {
|
||||
document.getElementById('confirm-modal').style.display = 'none';
|
||||
if (confirmActionCallback) {
|
||||
confirmActionCallback();
|
||||
confirmActionCallback = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- API & COMMAND FUNCTIONS ---
|
||||
function sendCommand(clientId, type) {
|
||||
customConfirm(t('titleAction'), `${t('confOp')}${clientId.toUpperCase()}?`, "var(--primary)", async () => {
|
||||
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) { customAlert(t('titleError'), data.error, true); }
|
||||
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;
|
||||
function confirmHatReset(id) {
|
||||
currentResetHatId = id;
|
||||
document.getElementById('reset-hat-desc').innerText = `${t('confHat')}${id.toUpperCase()}?`;
|
||||
document.getElementById('reset-hat-modal').style.display = 'flex';
|
||||
}
|
||||
function closeHatResetModal() {
|
||||
document.getElementById('reset-hat-modal').style.display = 'none';
|
||||
currentResetHatId = null;
|
||||
}
|
||||
async function executeHatReset() {
|
||||
if (!currentResetHatId) return;
|
||||
const clientId = currentResetHatId;
|
||||
closeHatResetModal();
|
||||
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: 'RESET_HAT' })
|
||||
});
|
||||
const data = await res.json();
|
||||
if(!data.success) { alert(data.error); }
|
||||
if(!data.success) { customAlert(t('titleError'), data.error, true); }
|
||||
refreshStates();
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
function sendGlobalUpdate() { fetch('/api/update_nodes', { method: 'POST' }).then(() => { alert("Request sent to nodes!"); }); }
|
||||
|
||||
function sendGlobalUpdate() { document.getElementById('update-modal').style.display = 'flex'; }
|
||||
function closeUpdateModal() { document.getElementById('update-modal').style.display = 'none'; }
|
||||
|
||||
async function executeUpdateRequest() {
|
||||
closeUpdateModal();
|
||||
try {
|
||||
await fetch('/api/update_nodes', { method: 'POST' });
|
||||
customAlert(t('titleSuccess'), t('alertUpdateOk'));
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function sendTgCommand(clientId, comando) {
|
||||
const msg = (comando === 'TG:ON') ? t('confTgOn') : t('confTgOff');
|
||||
customConfirm("💬 TELEGRAM", `${msg}${clientId.toUpperCase()}?`, "var(--primary)", async () => {
|
||||
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) { customAlert(t('titleError'), data.error, true); }
|
||||
refreshStates();
|
||||
} catch (e) { console.error(e); }
|
||||
});
|
||||
}
|
||||
|
||||
// --- MAIN UI INIT ---
|
||||
async function initUI() {
|
||||
@@ -399,8 +539,8 @@
|
||||
</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>
|
||||
<button id="btn-profA-${c.id}" class="btn-cmd" style="background: var(--accent);" onclick="confirmSwitch('${c.id}', 'A')">PROFILE A</button>
|
||||
<button id="btn-profB-${c.id}" class="btn-cmd" style="background: #eab308;" onclick="confirmSwitch('${c.id}', 'B')">PROFILE 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>
|
||||
@@ -409,6 +549,7 @@
|
||||
${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" style="background: #ea580c;" onclick="confirmHatReset('${c.id}')">${t('btnHat')}</button>
|
||||
<button class="btn-cmd btn-reboot" style="background: var(--danger);" onclick="confirmReboot('${c.id}')">${t('btnBoot')}</button>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -435,7 +576,11 @@
|
||||
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"); }
|
||||
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 {
|
||||
customAlert(t('titleError'), t('msgLoginFail'), true);
|
||||
}
|
||||
}
|
||||
function logout() { sessionStorage.clear(); location.reload(); }
|
||||
|
||||
@@ -515,7 +660,7 @@
|
||||
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";
|
||||
let profA = (healthObj.profiles && healthObj.profiles.A) ? healthObj.profiles.A : "PROFILE A"; let profB = (healthObj.profiles && healthObj.profiles.B) ? healthObj.profiles.B : "PROFILE 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'; }
|
||||
@@ -530,7 +675,7 @@
|
||||
|
||||
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'; }
|
||||
if (hasOfflineService) { btnSvc.style.background = 'var(--danger)'; btnSvc.classList.add('blink'); btnSvc.innerHTML = t('btnSvcKo'); }
|
||||
else { btnSvc.style.background = '#334155'; btnSvc.classList.remove('blink'); btnSvc.innerHTML = t('btnSvc'); }
|
||||
}
|
||||
|
||||
@@ -578,7 +723,7 @@
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
// --- GESTIONE UTENTI (ADD & EDIT) ---
|
||||
// --- USER MANAGEMENT (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(); }
|
||||
|
||||
@@ -591,8 +736,8 @@
|
||||
<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>
|
||||
<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="Edit User">✏️</button>
|
||||
<button onclick="deleteUser(${u.id})" class="btn-cmd" style="background:var(--danger); padding:6px; width:auto; display:inline-block;" title="Delete User">🗑️</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
@@ -600,14 +745,15 @@
|
||||
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-user').disabled = true;
|
||||
document.getElementById('new-pass').value = "";
|
||||
document.getElementById('new-pass').placeholder = "Nuova pass (vuoto per non cambiare)";
|
||||
document.getElementById('new-pass').placeholder = t('phNewPass');
|
||||
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)";
|
||||
const btnSubmit = document.getElementById('btn-user-submit');
|
||||
btnSubmit.innerHTML = t('titleSave');
|
||||
btnSubmit.style.background = "var(--accent)";
|
||||
document.getElementById('btn-user-cancel').style.display = "block";
|
||||
}
|
||||
|
||||
@@ -616,12 +762,13 @@
|
||||
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-pass').placeholder = t('phPass');
|
||||
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)";
|
||||
const btnSubmit = document.getElementById('btn-user-submit');
|
||||
btnSubmit.innerHTML = t('btnAdd');
|
||||
btnSubmit.style.background = "var(--success)";
|
||||
document.getElementById('btn-user-cancel').style.display = "none";
|
||||
}
|
||||
|
||||
@@ -631,71 +778,98 @@
|
||||
const role = document.getElementById('new-role').value;
|
||||
let allowed = document.getElementById('new-nodes').value;
|
||||
|
||||
if (!username) return alert("Username mancante");
|
||||
if (!username) return customAlert(t('titleError'), t('msgMissUser'), true);
|
||||
|
||||
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);
|
||||
if (data.success) { cancelEdit(); loadUsers(); } else customAlert(t('titleError'), data.error, true);
|
||||
} else {
|
||||
// ADD MODE
|
||||
if (!password) return alert("Password obbligatoria per nuovo utente");
|
||||
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();
|
||||
if (data.success) { cancelEdit(); loadUsers(); } else alert(data.error);
|
||||
if (data.success) { cancelEdit(); loadUsers(); } else customAlert(t('titleError'), data.error, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(id) { if (confirm("Delete user?")) { await fetch(`/api/users/${id}`, {method: 'DELETE'}); loadUsers(); cancelEdit(); } }
|
||||
function deleteUser(id) {
|
||||
customConfirm(t('titleDelete'), t('msgDelUser'), "var(--danger)", async () => {
|
||||
await fetch(`/api/users/${id}`, {method: 'DELETE'});
|
||||
loadUsers();
|
||||
cancelEdit();
|
||||
});
|
||||
}
|
||||
|
||||
// --- EMERGENCY & SETTINGS ---
|
||||
function triggerGlobalEmergency() {
|
||||
// Recupera i nomi dinamici dei profili dal primo nodo disponibile online
|
||||
let nameA = "PROFILO A";
|
||||
let nameB = "PROFILO B";
|
||||
let nameA = "PROFILE A";
|
||||
let nameB = "PROFILE B";
|
||||
for (const id in globalHealthData) {
|
||||
if (globalHealthData[id] && globalHealthData[id].profiles) {
|
||||
nameA = globalHealthData[id].profiles.A || nameA;
|
||||
nameB = globalHealthData[id].profiles.B || nameB;
|
||||
break; // Ne basta uno, la rete condivide i nomi dei profili
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Aggiorna dinamicamente i testi dei pulsanti del popup
|
||||
document.getElementById('btn-global-A').innerText = nameA;
|
||||
document.getElementById('btn-global-B').innerText = nameB;
|
||||
document.getElementById('override-desc').innerText = currentLang === 'it'
|
||||
? "Seleziona il profilo da inviare a TUTTA la rete:"
|
||||
: "Select the profile to send to the ENTIRE network:";
|
||||
|
||||
// Mostra il popup
|
||||
document.getElementById('override-desc').innerText = t('msgOvrSel');
|
||||
document.getElementById('override-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
async function sendGlobalAction(action) {
|
||||
document.getElementById('override-modal').style.display = 'none'; // Chiudi il modal
|
||||
function sendGlobalAction(action) {
|
||||
document.getElementById('override-modal').style.display = 'none';
|
||||
|
||||
// Chiede l'ultima conferma di sicurezza prima di sparare il comando a tutti
|
||||
if (confirm(t('promptOvrConfirm'))) {
|
||||
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();
|
||||
if(d.success) {
|
||||
alert(currentLang === 'it' ? "Comando inviato con successo a tutta la rete!" : "Command successfully sent to the network!");
|
||||
customAlert(t('titleSuccess'), t('msgOvrOk'));
|
||||
} else {
|
||||
alert(d.error);
|
||||
customAlert(t('titleError'), d.error, true);
|
||||
}
|
||||
refreshStates();
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
});
|
||||
}
|
||||
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(); } } }
|
||||
|
||||
function changeMyPassword() {
|
||||
document.getElementById('new-password-input').value = '';
|
||||
document.getElementById('password-modal').style.display = 'flex';
|
||||
setTimeout(() => document.getElementById('new-password-input').focus(), 100);
|
||||
}
|
||||
function closePasswordModal() { document.getElementById('password-modal').style.display = 'none'; }
|
||||
async function executePasswordChange() {
|
||||
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}) });
|
||||
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); }
|
||||
}
|
||||
|
||||
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); } }
|
||||
|
||||
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) customAlert(t('titleSuccess'), t('msgConfigSaved'));
|
||||
else customAlert(t('titleError'), t('msgConfigErr'), true);
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function openConfigsModal(clientId) {
|
||||
const data = globalHealthData[clientId.toLowerCase()];
|
||||
@@ -739,7 +913,15 @@
|
||||
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); } }
|
||||
function controlService(clientId, service, action) {
|
||||
customConfirm(t('titleAction'), `${t('confOp')}${service}?`, "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();
|
||||
if(!data.success) customAlert(t('titleError'), data.error, true);
|
||||
} catch(e) { console.error(e); }
|
||||
});
|
||||
}
|
||||
|
||||
let currentEditClient = ""; let currentEditService = "";
|
||||
async function openEditorModal(clientId, service) {
|
||||
@@ -750,15 +932,26 @@
|
||||
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() {
|
||||
|
||||
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"; }
|
||||
customConfirm(t('titleSave'), `${t('confOvr')}${currentEditClient.toUpperCase()}?`, "var(--danger)", async () => {
|
||||
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) {
|
||||
customAlert(t('titleSuccess'), "File updated!");
|
||||
closeEditorModal();
|
||||
} else {
|
||||
customAlert(t('titleError'), "Server error: " + data.error, true);
|
||||
statusSpan.innerText = "❌ Error";
|
||||
}
|
||||
} catch(e) {
|
||||
customAlert(t('titleError'), t('msgNetErr'), true);
|
||||
statusSpan.innerText = "❌ Network Error";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initUI();
|
||||
|
||||
Reference in New Issue
Block a user