26 Commits
Author SHA1 Message Date
iv3jdv 255066eefa Delete system_monitor.py 2026-10-17 18:50:35 +02:00
iv3jdv 74d7910fec Upload files to "/" 2026-10-17 18:48:51 +02:00
iv3jdv bcb885c878 Merge pull request 'update' (#6) from develop into main
Reviewed-on: #6
2026-10-17 18:36:45 +02:00
iv3jdv 3f59b905c7 update 2026-10-17 18:35:22 +02:00
iv3jdv 869b09f8a9 Merge pull request 'update index.html' (#5) from develop into main
Reviewed-on: #5
2026-10-16 23:49:36 +02:00
iv3jdv bdfc4043f8 update index.html 2026-10-16 23:48:27 +02:00
iv3jdv caf77819a2 Merge pull request 'update index.html' (#4) from develop into main
Reviewed-on: #4
2026-10-16 21:01:30 +02:00
iv3jdv 47e51e10f0 update index.html 2026-10-16 21:00:21 +02:00
iv3jdv d56d3d6b5b Merge pull request 'update index.html' (#3) from develop into main
Reviewed-on: #3
2026-10-16 20:48:43 +02:00
iv3jdv cd2960d00b update index.html 2026-10-16 20:45:13 +02:00
iv3jdv 95b5dd4cbc update index.html 2026-10-16 20:41:36 +02:00
iv3jdv 74d7c41b93 enhanceded RSSI 2026-10-16 20:31:23 +02:00
iv3jdv cf4dd93c06 Merge pull request 'add RSSI data' (#2) from develop into main
Reviewed-on: #2
2026-10-16 19:51:12 +02:00
iv3jdv 9fb0b01f30 add RSSI data 2026-10-16 19:39:58 +02:00
iv3jdv a5dd99ecdc Merge pull request 'Release Rev 2.0' (#1) from develop into main
Reviewed-on: #1
2026-10-16 12:06:46 +02:00
iv3jdv f74fa8f7c8 enanched security 2026-10-16 10:54:08 +02:00
iv3jdv fa451fbf93 remove test file 2026-10-15 20:10:31 +02:00
iv3jdv 39e6536db1 Test installazione pulita 2026-10-15 01:30:16 +02:00
iv3jdv 8ff42c8cc0 Test fix automatico Gitea Doctor 2026-10-15 01:23:52 +02:00
iv3jdv 1de64378f2 Forza aggiornamento hook con file reale 2026-10-15 01:19:28 +02:00
iv3jdv 3f91ba88f9 Rigenerazione hook via Web UI 2026-10-15 01:17:02 +02:00
iv3jdv 3b2b2fd082 Test fuori da tmp 2026-10-15 01:11:23 +02:00
iv3jdv d54b9ac132 Test tracciatore principale 2026-10-15 01:08:51 +02:00
iv3jdv 787f138488 Test tracciamento hook 2026-10-15 01:07:30 +02:00
iv3jdv 2aa915d5fb Riallineamento hook Gitea 2026-10-15 01:05:39 +02:00
iv3jdv 690aec65e7 remove test file 2026-10-15 00:54:10 +02:00
3 changed files with 203 additions and 105 deletions
+26 -10
View File
@@ -96,6 +96,10 @@ def init_db():
c.execute("ALTER TABLE radio_logs ADD COLUMN source_ext TEXT DEFAULT ''")
except sqlite3.OperationalError: pass
try:
c.execute("ALTER TABLE radio_logs ADD COLUMN rssi REAL DEFAULT 0")
except sqlite3.OperationalError: 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)''')
@@ -185,8 +189,15 @@ def save_cache(data):
def save_to_sqlite(client_id, data, protocol="DMR"):
conn = sqlite3.connect(DB_PATH, timeout=10)
c = conn.cursor()
c.execute("INSERT INTO radio_logs (timestamp, client_id, protocol, source_id, target, slot, duration, ber, source_ext) VALUES (datetime('now', 'localtime'), ?, ?, ?, ?, ?, ?, ?, ?)",
(client_id, protocol, str(data.get('src_id', '---')), str(data.get('dst_id', '---')), data.get('slot', 0), round(data.get('duration', 0), 1), round(data.get('ber', 0), 2), str(data.get('src_ext', ''))))
rssi_val = 0
if 'rssi' in data and isinstance(data['rssi'], dict):
rssi_val = data['rssi'].get('ave', 0)
elif 'rssi' in data:
rssi_val = data['rssi']
c.execute("INSERT INTO radio_logs (timestamp, client_id, protocol, source_id, target, slot, duration, ber, source_ext, rssi) VALUES (datetime('now', 'localtime'), ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(client_id, protocol, str(data.get('src_id', '---')), str(data.get('dst_id', '---')), data.get('slot', 0), round(data.get('duration', 0), 1), round(data.get('ber', 0), 2), str(data.get('src_ext', '')), rssi_val))
conn.commit()
conn.close()
notify_update('log_aggiornato')
@@ -362,16 +373,19 @@ def on_message(client, userdata, msg):
notify_update() # <--- WEBSOCKET
# --- PUSH TRIGGER: NODE STATE ---
if payload.upper() == 'OFFLINE':
if last_notified_errors.get(f"{cid}_NODE") != 'OFFLINE':
# L'agente invia "OFFLINE - <ID>" (anche come LWT) e "ONLINE - <profilo>": si confronta il PREFISSO.
# I messaggi retained (già sul broker quando il server si avvia) non generano notifiche.
state_up = payload.upper()
if state_up.startswith('OFFLINE'):
if not msg.retain and last_notified_errors.get(f"{cid}_NODE") != 'OFFLINE':
broadcast_push_notification(f"💀 NODE OFFLINE: {cid.upper()}", "Connection lost with broker.", cid=cid)
last_notified_errors[f"{cid}_NODE"] = 'OFFLINE'
elif payload.upper() == 'ONLINE':
elif state_up.startswith('ONLINE'):
if last_notified_errors.get(f"{cid}_NODE") == 'OFFLINE':
broadcast_push_notification(f"🌤️ NODE ONLINE: {cid.upper()}", "Node is back online.", cid=cid)
del last_notified_errors[f"{cid}_NODE"]
if payload.upper() not in ['OFF', 'OFFLINE', '']:
if not state_up.startswith('OFF') and state_up != '':
tel = client_telemetry.get(cid, {})
if isinstance(tel, dict) and '🔄' in str(tel.get('ts1', '')):
client_telemetry[cid] = {"ts1": "Waiting...", "ts2": "Waiting...", "alt": ""}
@@ -388,7 +402,9 @@ def on_message(client, userdata, msg):
"disk": round(data.get("disk_usage_percent", 0), 1),
"processes": data.get("processes", {}),
"files": data.get("files", data.get("config_files", [])),
"profiles": data.get("profiles", {"A": "PROFILE A", "B": "PROFILE B"})
"file_names": data.get("file_names", []),
"profiles": data.get("profiles", {"A": "PROFILE A", "B": "PROFILE B"}),
"interval": data.get("update_interval")
}
health_changed = device_health.get(cid) != new_health
device_health[cid] = new_health
@@ -442,7 +458,7 @@ def on_message(client, userdata, msg):
logger.error(f"Error parsing DMRGateway for {cid}: {e}")
# --- MMDVMHOST INFO MANAGEMENT (FREQUENZE & LOCATION) ---
elif len(parts) >= 4 and p0 == 'data' and p2 == 'mmdvm' and parts[3].lower() == 'info':
elif len(parts) >= 4 and p0 == 'data' and p2 in ('mmdvm', 'mmdvmhost') and parts[3].lower() == 'info':
try:
cid = parts[1].lower()
data = json.loads(payload)
@@ -471,7 +487,7 @@ def on_message(client, userdata, msg):
logger.error(f"Error parsing MMDVMHost info for {cid}: {e}")
# --- MMDVMHOST GENERAL MANAGEMENT (CALLSIGN & ID & DUPLEX) ---
elif len(parts) >= 4 and p0 == 'data' and p2 == 'mmdvm' and parts[3].lower() == 'general':
elif len(parts) >= 4 and p0 == 'data' and p2 in ('mmdvm', 'mmdvmhost') and parts[3].lower() == 'general':
try:
cid = parts[1].lower()
data = json.loads(payload)
@@ -627,7 +643,7 @@ def get_clients():
def get_logs():
conn = sqlite3.connect(DB_PATH, timeout=10)
c = conn.cursor()
c.execute("SELECT timestamp, client_id, protocol, source_id, target, slot, duration, ber, source_ext FROM radio_logs ORDER BY id DESC LIMIT 60")
c.execute("SELECT timestamp, client_id, protocol, source_id, target, slot, duration, ber, source_ext, rssi FROM radio_logs ORDER BY id DESC LIMIT 60")
logs = c.fetchall()
conn.close()
return jsonify(logs)
+172 -84
View File
@@ -28,10 +28,25 @@
body { font-family: 'Inter', sans-serif; background: var(--bg-gradient); margin: 0; padding: 0; color: var(--text-main); min-height: 100vh; }
/* Barra superiore squadrata e tecnica */
#top-bar-container { position: sticky; top: 15px; z-index: 100; padding: 0 20px; display: flex; justify-content: center; }
#top-bar-container {
position: sticky;
top: 0;
z-index: 100;
padding: 15px 20px;
display: flex;
justify-content: center;
background: var(--bg-gradient);
margin-bottom: 30px;
}
#top-bar { background: var(--topbar-bg); padding: 12px 30px; display: flex; justify-content: space-between; align-items: center; border-radius: 6px; border: 1px solid var(--border-color); width: 100%; max-width: 1400px; }
.title-brand { font-size: 1.2rem; font-weight: 800; letter-spacing: 2px; color: var(--text-main); font-family: 'JetBrains Mono', monospace; }
/* --- Navigation Tabs --- */
.nav-tabs { display: flex; gap: 15px; margin-left: 30px; flex: 1; }
.nav-tab { background: transparent; border: none; color: var(--text-muted); font-size: 0.95rem; font-weight: 800; cursor: pointer; padding: 6px 12px; border-bottom: 3px solid transparent; transition: all 0.2s ease; font-family: 'JetBrains Mono', monospace; letter-spacing: 1px; }
.nav-tab:hover { color: var(--text-main); }
.nav-tab.active { color: var(--primary); border-bottom: 3px solid var(--primary); }
.theme-switch { background: transparent; border: 1px solid var(--border-color); color: var(--text-muted); padding: 6px 14px; border-radius: 4px; cursor: pointer; font-weight: 600; font-size: 0.85rem; font-family: 'JetBrains Mono', monospace; }
.theme-switch:hover { color: var(--text-main); border-color: var(--text-main); }
@@ -137,6 +152,7 @@
#top-bar-container { top: 10px; padding: 0 10px; }
#top-bar { flex-direction: column; padding: 15px; gap: 12px; border-radius: 8px; }
.title-brand { font-size: 1.1rem; text-align: center; width: 100%; }
.nav-tabs { margin-left: 0; width: 100%; justify-content: center; border-bottom: 1px solid var(--border-color); padding-bottom: 10px; margin-bottom: 10px; }
#top-bar > div { width: 100%; flex-wrap: wrap; justify-content: center; gap: 8px !important; }
#auth-container { width: 100%; justify-content: center; flex-wrap: wrap; padding-top: 12px; border-top: 1px solid var(--border-color); }
#auth-container > span { width: 100%; text-align: center; margin-bottom: 8px; font-family: 'JetBrains Mono', monospace; }
@@ -153,6 +169,13 @@
<div id="top-bar-container">
<div id="top-bar">
<div class="title-brand">FLEET CONTROL CONSOLE</div>
<!-- NUOVO: Menu a schede -->
<div class="nav-tabs">
<button class="nav-tab active" onclick="switchView('home')" id="tab-home">HOME</button>
<button class="nav-tab" onclick="switchView('nodes')" id="tab-nodes">NODES</button>
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<span id="mqtt-badge" class="mqtt-badge mqtt-offline">MQTT: CHECKING...</span>
<button class="theme-switch" id="lang-btn" onclick="toggleLang()" data-i18n-title="ttLang">🇮🇹 ITA</button>
@@ -162,61 +185,68 @@
</div>
</div>
<div class="grid" id="client-grid"></div>
<div style="max-width: 1400px; margin: 0 auto 15px auto; padding: 0 20px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
<h3 style="margin: 0; font-weight: 800; display: flex; align-items: center; gap: 10px; color: var(--text-main);">
<span style="font-size: 1.5rem;">📊</span> <span data-i18n="statTitle">DAILY STATISTICS</span>
</h3>
<select id="stat-node-filter" onchange="refreshStats()" style="background: var(--card-bg); border: 1px solid var(--border-color); color: var(--text-main); padding: 8px 15px; border-radius: 6px; font-family: 'JetBrains Mono', monospace; font-weight: bold; cursor: pointer; outline: none;">
<option value="all">🌐 ALL NODES</option>
</select>
<!-- ================= VISTA NODI ================= -->
<div id="view-nodes" style="display: none;">
<div class="grid" id="client-grid"></div>
</div>
<div style="max-width: 1400px; margin: 0 auto 30px auto; padding: 0 20px; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px;">
<div class="card" style="padding: 15px; border-top-color: var(--accent) !important;">
<h4 style="margin: 0 0 10px 0; color: var(--accent); font-family: 'JetBrains Mono', monospace;" data-i18n="statTopTG">🎯 TOP TALKGROUPS</h4>
<div id="stat-tgs" style="font-size: 0.85rem; color: var(--text-muted); line-height: 1.8;" data-i18n="statLoading">Loading...</div>
<!-- ================= VISTA HOME ================= -->
<div id="view-home">
<div style="max-width: 1400px; margin: 0 auto 15px auto; padding: 0 20px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
<h3 style="margin: 0; font-weight: 800; display: flex; align-items: center; gap: 10px; color: var(--text-main);">
<span style="font-size: 1.5rem;">📊</span> <span data-i18n="statTitle">DAILY STATISTICS</span>
</h3>
<select id="stat-node-filter" onchange="refreshStats()" style="background: var(--card-bg); border: 1px solid var(--border-color); color: var(--text-main); padding: 8px 15px; border-radius: 6px; font-family: 'JetBrains Mono', monospace; font-weight: bold; cursor: pointer; outline: none;">
<option value="all">🌐 ALL NODES</option>
</select>
</div>
<div class="card" style="padding: 15px; border-top-color: var(--success) !important;">
<h4 style="margin: 0 0 10px 0; color: var(--success); font-family: 'JetBrains Mono', monospace;" data-i18n="statTopCall">🗣️ TOP CALLSIGNS</h4>
<div id="stat-calls" style="font-size: 0.85rem; color: var(--text-muted); line-height: 1.8;" data-i18n="statLoading">Loading...</div>
</div>
<div class="card" style="padding: 15px; border-top-color: var(--primary) !important; display: flex; justify-content: space-around; align-items: center; text-align: center;">
<div>
<div style="font-size: 1.8rem; font-weight: 800; color: var(--text-main); font-family: 'JetBrains Mono', monospace;"><span id="stat-avg">--</span>s</div>
<div style="font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; font-weight: bold;" data-i18n="statAvgDur">⏱️ AVERAGE DURATION</div>
<div style="max-width: 1400px; margin: 0 auto 30px auto; padding: 0 20px; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px;">
<div class="card" style="padding: 15px; border-top-color: var(--accent) !important;">
<h4 style="margin: 0 0 10px 0; color: var(--accent); font-family: 'JetBrains Mono', monospace;" data-i18n="statTopTG">🎯 TOP TALKGROUPS</h4>
<div id="stat-tgs" style="font-size: 0.85rem; color: var(--text-muted); line-height: 1.8;" data-i18n="statLoading">Loading...</div>
</div>
<div style="width: 1px; height: 50px; background: var(--border-color);"></div>
<div>
<div style="font-size: 1.8rem; font-weight: 800; color: var(--text-main); font-family: 'JetBrains Mono', monospace;"><span id="stat-today">--</span></div>
<div style="font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; font-weight: bold;" data-i18n="statTodayTx">📡 TRANSITS TODAY</div>
<div class="card" style="padding: 15px; border-top-color: var(--success) !important;">
<h4 style="margin: 0 0 10px 0; color: var(--success); font-family: 'JetBrains Mono', monospace;" data-i18n="statTopCall">🗣️ TOP CALLSIGNS</h4>
<div id="stat-calls" style="font-size: 0.85rem; color: var(--text-muted); line-height: 1.8;" data-i18n="statLoading">Loading...</div>
</div>
<div class="card" style="padding: 15px; border-top-color: var(--primary) !important; display: flex; justify-content: space-around; align-items: center; text-align: center;">
<div>
<div style="font-size: 1.8rem; font-weight: 800; color: var(--text-main); font-family: 'JetBrains Mono', monospace;"><span id="stat-avg">--</span>s</div>
<div style="font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; font-weight: bold;" data-i18n="statAvgDur">⏱️ AVERAGE DURATION</div>
</div>
<div style="width: 1px; height: 50px; background: var(--border-color);"></div>
<div>
<div style="font-size: 1.8rem; font-weight: 800; color: var(--text-main); font-family: 'JetBrains Mono', monospace;"><span id="stat-today">--</span></div>
<div style="font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; font-weight: bold;" data-i18n="statTodayTx">📡 TRANSITS TODAY</div>
</div>
</div>
</div>
</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">Lastheard</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 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">Lastheard</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>
<th>RSSI</th> <!-- Colonna S-Meter -->
</tr>
</thead>
<tbody id="log-body"></tbody>
</table>
</div>
</div>
</div>
@@ -474,11 +504,27 @@
}
// --- HELPER DI SICUREZZA ---
// esc(): escape HTML per tutto ciò che arriva da MQTT / database / utenti prima di finire in innerHTML
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
// q(): letterale JS sicuro da usare dentro onclick="...": onclick="fn(${q(valore)})"
function q(s) { return esc(JSON.stringify(String(s ?? ''))); }
const HANG_TIME_MS = 180000; // tempo massimo in cui la card resta "accesa" dopo l'ultimo TX
const HANG_TIME_MS = 180000;
// --- GESTIONE SCHEDE (TABS) ---
function switchView(viewName) {
document.getElementById('view-home').style.display = 'none';
document.getElementById('view-nodes').style.display = 'none';
document.getElementById('tab-home').classList.remove('active');
document.getElementById('tab-nodes').classList.remove('active');
document.getElementById(`view-${viewName}`).style.display = 'block';
document.getElementById(`tab-${viewName}`).classList.add('active');
localStorage.setItem('fleetDefaultView', viewName);
}
window.addEventListener('DOMContentLoaded', () => {
const savedView = localStorage.getItem('fleetDefaultView') || 'home';
switchView(savedView);
});
// --- DIAGNOSTICA: "ultimo messaggio ricevuto" per nodo (MMDVM / gateway / agent) ---
function fmtAge(sec) {
@@ -501,8 +547,6 @@
const upSec = data.uptime || 0;
let html = '';
// MMDVM: pubblica eventi (TX, cambio modo), quindi su un nodo tranquillo l'età può crescere senza problemi.
// Si segnala solo il caso "mai ricevuto nulla" con nodo online e server attivo da un po'.
const warnM = ages.mmdvm === undefined && isOnline && upSec > 600;
const tipM = it ? `Ultimo evento dal topic mmdvm/${c.id}/json (TX, cambio modo...). Su un nodo tranquillo può passare molto tempo.`
: `Last event from topic mmdvm/${c.id}/json (TX, mode change...). It can be a long time on a quiet node.`;
@@ -510,13 +554,13 @@
: `No MMDVM event received since the server started (${fmtAge(upSec)}): check that MMDVMHost is connected to the broker.`;
html += seenItem('MMDVM', ages.mmdvm, warnM ? tipMW : tipM, warnM);
// Gateway: solo se il nodo ne ha mai pubblicati
if (ages.gateway !== undefined) {
html += seenItem('GW', ages.gateway, it ? 'Ultimo evento dai gateway (DMR/NXDN/YSF/P25/D-Star)' : 'Last event from the gateways (DMR/NXDN/YSF/P25/D-Star)', false);
}
// Agent: heartbeat periodico, se tace a lungo con nodo "online" qualcosa non va
const warnA = isOnline && (ages.health === undefined ? upSec > 300 : ages.health > 180);
const hObj = (data.health && data.health[c.id.toLowerCase()]) || {};
const iv = Number(hObj.interval) > 0 ? Number(hObj.interval) : 60;
const warnA = isOnline && (ages.health === undefined ? upSec > Math.max(300, iv * 5) : ages.health > Math.max(180, iv * 3));
const tipA = warnA
? (it ? 'L\'agent del nodo non invia più lo stato (devices/<id>/services): connessione bloccata?' : 'The node agent stopped sending its status (devices/<id>/services): stuck connection?')
: (it ? 'Ultimo stato ricevuto dall\'agent del nodo (devices/<id>/services)' : 'Last status received from the node agent (devices/<id>/services)');
@@ -528,7 +572,7 @@
// --- GLOBAL VARIABLES ---
let clients = [];
let me = { logged_in: false, user: '', role: '', allowed_nodes: '' }; // stato reale della sessione (da /api/me)
let me = { logged_in: false, user: '', role: '', allowed_nodes: '' };
let isAuthenticated = false;
let usersCache = {};
let globalHealthData = {};
@@ -592,8 +636,7 @@
}
function confirmReboot(id) { sendCommand(id, 'REBOOT', `🔄 ${t('titleBoot')}`, `${t('confBoot')}<b>${id.toUpperCase()}</b>?`, "var(--danger)"); }
function confirmUpdateAgent(id) { sendCommand(id, 'UPDATE_AGENT', `⬇️ UPDATE OTA`, `Confermi l'aggiornamento dello script tramite git pull sul nodo <b>${id.toUpperCase()}</b>?`, "#2563eb");
}
function confirmUpdateAgent(id) { sendCommand(id, 'UPDATE_AGENT', `⬇️ UPDATE OTA`, `Confermi l'aggiornamento dello script tramite git pull sul nodo <b>${id.toUpperCase()}</b>?`, "#2563eb"); }
function confirmHatReset(id) {
currentResetHatId = id;
@@ -644,7 +687,6 @@
const response = await fetch('/api/clients');
clients = await response.json();
// Gli id nodo finiscono in id HTML e onclick: accettiamo solo caratteri sicuri
clients = clients.filter(c => /^[A-Za-z0-9_\-]+$/.test(String(c.id)));
const statSelect = document.getElementById('stat-node-filter');
@@ -765,17 +807,15 @@
const statusDiv = document.getElementById(`status-${c.id}`); const cardDiv = document.getElementById(`card-${c.id}`);
if (!statusDiv || !cardDiv) return;
// 1. PRIMA CALCOLIAMO SE È ONLINE
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 !== "";
renderSeen(c, data, isOnline);
// 2. POI AGGIORNIAMO NOME E ID DINAMICI
let genObj = data.general && data.general[c.id.toLowerCase()];
const nameSpan = cardDiv.querySelector('.client-name');
const idBadge = cardDiv.querySelector('.badge-id');
const typeBadge = document.getElementById(`type-badge-${c.id}`); // <--- Peschiamo il nostro nuovo badge
const typeBadge = document.getElementById(`type-badge-${c.id}`);
if (genObj && isOnline) {
nameSpan.innerText = genObj.callsign;
@@ -784,7 +824,6 @@
idBadge.style.color = "#ffffff";
idBadge.style.borderColor = "var(--primary)";
// --- LOGICA BADGE HOTSPOT/REPEATER ---
if (typeBadge && genObj.duplex !== undefined) {
typeBadge.style.display = 'inline-block';
if (genObj.duplex === "1") {
@@ -802,10 +841,9 @@
idBadge.style.color = "var(--text-muted)";
idBadge.style.borderColor = "var(--border-color)";
if (typeBadge) typeBadge.style.display = 'none'; // Nascondilo se il nodo va offline
if (typeBadge) typeBadge.style.display = 'none';
}
// 3. TELEMETRIA E STATO (PULITO DAI DUPLICATI)
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 };
@@ -836,14 +874,10 @@
activeModeColor = "var(--primary)";
if (ts1Div && ts2Div) {
// --- LOGICA DUPLEX / SIMPLEX ---
let isSimplex = (genObj && genObj.duplex === "0");
// Nascondiamo il div del TS1 se il nodo è Simplex
ts1Div.style.display = isSimplex ? 'none' : 'block';
[ts1Div, ts2Div].forEach((div, idx) => {
// Se è simplex, saltiamo del tutto l'aggiornamento grafico del TS1
if (isSimplex && idx === 0) return;
const val = String((idx === 0 ? telemetryObj.ts1 : telemetryObj.ts2) ?? ''); const netName = idx === 0 ? netObj.ts1 : netObj.ts2;
@@ -933,15 +967,12 @@
if (isOnline) {
cardDiv.classList.add('online'); statusDiv.classList.remove('status-offline'); cardDiv.style.opacity = "1"; cardDiv.style.filter = "none";
// --- INIZIO LOGICA SMART HANG-TIME ---
if (isTx) cardDiv.dataset.lastTx = Date.now(); // Memorizza quando è avvenuto l'ultimo TX
if (isTx) cardDiv.dataset.lastTx = Date.now();
let timeSinceTx = Date.now() - (parseInt(cardDiv.dataset.lastTx) || 0);
// Accendi se in TX, OPPURE se in Hang Time (ma spegni a forza dopo HANG_TIME_MS di inattività)
let showActiveColor = isTx || (!isIdle && timeSinceTx < HANG_TIME_MS);
cardDiv.style.setProperty('border-top-color', showActiveColor ? activeModeColor : "var(--border-color)", 'important');
// --- FINE LOGICA ---
if(isTx) {
let shadowRGB = '59, 130, 246';
if (activeModeColor === '#10b981') shadowRGB = '16, 185, 129';
@@ -967,17 +998,66 @@
const time = row[0] ? row[0].split(' ')[1] : "--:--";
const clientId = row[1]; const protocol = row[2] || "DMR"; let source = row[3] || "---"; const target = row[4] || "---";
const rawSlot = row[5]; const source_ext = row[8];
const rawRssi = row[9];
let sourceHtml = esc(source);
if (source_ext && source_ext.trim() !== "") { sourceHtml += ` <span style="font-size:0.8em; color:#94a3b8; font-family:'JetBrains Mono', monospace;">/${esc(source_ext)}</span>`; }
const slotDisplay = protocol === "DMR" ? `TS${rawSlot}` : "--";
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";
// Formatta l'RSSI con S-Meter grafico a segmenti
let rssiDisplay = '---';
if (rawRssi && rawRssi !== 0) {
// Calcolo livello S-Meter (Standard VHF/UHF: S9 = -93 dBm, 6dB per punto S)
let sLabel = "S0";
if (rawRssi >= -93) {
let over = Math.round((rawRssi + 93) / 10) * 10;
sLabel = over > 0 ? `S9<sup style="color: var(--danger); font-weight: 800;">+${over}</sup>` : "S9";
} else {
let sUnits = Math.floor((rawRssi + 141) / 6) + 1;
sLabel = `S${Math.max(1, Math.min(9, sUnits))}`;
}
// Calcolo segmenti accesi (9 segmenti totali)
let activeSegs = 1;
if (rawRssi >= -53) activeSegs = 9;
else if (rawRssi >= -63) activeSegs = 8;
else if (rawRssi >= -73) activeSegs = 7;
else if (rawRssi >= -83) activeSegs = 6;
else if (rawRssi >= -93) activeSegs = 5; // S9
else if (rawRssi >= -105) activeSegs = 4;
else if (rawRssi >= -117) activeSegs = 3;
else if (rawRssi >= -129) activeSegs = 2;
// Generazione barre HTML
let barsHtml = '';
const heights = [4, 5, 6, 7, 8, 10, 12, 14, 14];
for (let i = 0; i < 9; i++) {
let color = "rgba(255,255,255,0.1)"; // Spento
if (i < activeSegs) {
if (i < 5) color = "var(--success)"; // Verde
else if (i < 7) color = "#f59e0b"; // Giallo
else color = "var(--danger)"; // Rosso
}
barsHtml += `<div style="width: 4px; height: ${heights[i]}px; background: ${color}; border-radius: 1px;"></div>`;
}
rssiDisplay = `
<div style="display: flex; align-items: center; gap: 10px;" title="${esc(rawRssi)} dBm">
<div style="min-width: 40px; text-align: right; font-weight: bold; font-family: 'Inter', sans-serif;">${sLabel}</div>
<div style="display: flex; align-items: flex-end; gap: 2px; height: 14px;">
${barsHtml}
</div>
<span style="font-size: 0.85em; color: var(--text-muted); min-width: 7ch; text-align: right;">${esc(rawRssi)} dBm</span>
</div>`;
}
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;">[${esc(time)}]</span> <span style="color:${protoColor}; font-weight:bold;">[${esc(protocol)}]</span> <span style="color:#cbd5e1;">${esc(source.replace("🌐", "").trim())}</span></div>`;
} else {
tableHTML += `<tr><td>${esc(time)}</td><td><b style="color:var(--text-main);">${esc(String(clientId).toUpperCase())}</b></td><td><span style="background:${protoColor}; color:white; padding:3px 8px; border-radius:8px; font-size:0.7rem; font-weight:800;">${esc(protocol)}</span></td><td style="font-family:'JetBrains Mono', monospace; font-weight:bold; text-align:center;">${esc(slotDisplay)}</td><td style="color:var(--accent); font-weight:800;">${sourceHtml}</td><td style="font-family:'JetBrains Mono', monospace;">${esc(target.trim())}</td><td>${esc(row[6])}s</td><td>${esc(row[7])}%</td></tr>`;
tableHTML += `<tr><td>${esc(time)}</td><td><b style="color:var(--text-main);">${esc(String(clientId).toUpperCase())}</b></td><td><span style="background:${protoColor}; color:white; padding:3px 8px; border-radius:8px; font-size:0.7rem; font-weight:800;">${esc(protocol)}</span></td><td style="font-family:'JetBrains Mono', monospace; font-weight:bold; text-align:center;">${esc(slotDisplay)}</td><td style="color:var(--accent); font-weight:800;">${sourceHtml}</td><td style="font-family:'JetBrains Mono', monospace;">${esc(target.trim())}</td><td>${esc(row[6])}s</td><td>${esc(row[7])}%</td><td style="font-family:'JetBrains Mono', monospace;">${rssiDisplay}</td></tr>`;
}
});
tbody.innerHTML = tableHTML;
@@ -1043,7 +1123,6 @@
// --- SETTINGS ---
function triggerGlobalEmergency() {
let uniqueProfiles = {};
// Raccoglie tutti i profili unici trovati nei nodi online
for (const id in globalHealthData) {
if (globalHealthData[id] && globalHealthData[id].profiles) {
for (const [k, v] of Object.entries(globalHealthData[id].profiles)) {
@@ -1099,10 +1178,20 @@
}
// --- SERVICES & CONFIGS ---
// Etichetta del file: nome in maiuscolo + estensione reale (es. DSTARGATEWAY.cfg); .ini solo come ripiego per agent vecchi
function fileLabel(stem, names) {
const key = String(stem).toLowerCase();
const full = (names || []).find(n => String(n).replace(/\.[^.]*$/, '').toLowerCase() === key);
if (full) {
const m = String(full).match(/^(.*?)(\.[^.]*)?$/);
return String(m[1]).toUpperCase() + (m[2] || '');
}
return String(stem).toUpperCase() + '.ini';
}
function openConfigsModal(clientId) {
const data = globalHealthData[clientId.toLowerCase()]; const listDiv = document.getElementById('configs-list'); let listaFile = data ? (data.config_files || data.files || []) : [];
const data = globalHealthData[clientId.toLowerCase()]; const listDiv = document.getElementById('configs-list'); let listaFile = data ? (data.config_files || data.files || []) : []; const fileNames = (data && data.file_names) || [];
if (listaFile.length === 0) listDiv.innerHTML = `<div style="text-align:center; padding:20px;"><p style="color:var(--text-muted); margin-bottom:15px;">${t('waitData')}</p><button onclick="sendGlobalUpdate(); closeConfigsModal();" class="btn-cmd" style="background:var(--accent);">${t('forceUpdate')}</button></div>`;
else listDiv.innerHTML = listaFile.map(f => `<div style="display:flex; justify-content:space-between; align-items:center; padding:12px; background:rgba(128,128,128,0.1); border-radius:12px; border:1px solid var(--border-color); margin-bottom:8px;"><span style="font-family:'JetBrains Mono',monospace; font-weight:bold;">${esc(String(f).toUpperCase())}.ini</span><button onclick="closeConfigsModal(); openEditorModal(${q(clientId)}, ${q(f)})" class="btn-cmd" style="background:#8e44ad; max-width:120px;">${t('btnEdit')}</button></div>`).join('');
else listDiv.innerHTML = listaFile.map(f => `<div style="display:flex; justify-content:space-between; align-items:center; padding:12px; background:rgba(128,128,128,0.1); border-radius:12px; border:1px solid var(--border-color); margin-bottom:8px;"><span style="font-family:'JetBrains Mono',monospace; font-weight:bold;">${esc(fileLabel(f, fileNames))}</span><button onclick="closeConfigsModal(); openEditorModal(${q(clientId)}, ${q(f)})" class="btn-cmd" style="background:#8e44ad; max-width:120px;">${t('btnEdit')}</button></div>`).join('');
document.getElementById('configs-modal').style.display = 'flex';
}
function closeConfigsModal() { document.getElementById('configs-modal').style.display = 'none'; }
@@ -1118,10 +1207,10 @@
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;">${esc(name)}</strong><span class="${isOnline?'':'blink'}" style="color:${statusColor}; font-size:0.8rem; font-weight:800;">${esc(String(status).toUpperCase())}</span></div>
<div style="display:flex; gap:8px;">
${!isOnline ? `<button onclick="controlService(${q(clientId)}, ${q(name)}, 'start')" class="btn-cmd" style="background:var(--success);">▶</button>` : ''}
${!isOnline ? `<button onclick="controlService(${q(clientId)},${q(name)}, 'start')" class="btn-cmd" style="background:var(--success);">▶</button>` : ''}
<button onclick="controlService(${q(clientId)}, ${q(name)}, 'restart')" class="btn-cmd" style="background:#f59e0b;">🔄</button>
<button onclick="openEditorModal(${q(clientId)}, ${q(name)})" class="btn-cmd" style="background:#8e44ad;">📝</button>
${isOnline ? `<button onclick="controlService(${q(clientId)}, ${q(name)}, 'stop')" class="btn-cmd" style="background:var(--danger);">🛑</button>` : ''}
${isOnline ? `<button onclick="controlService(${q(clientId)},${q(name)}, 'stop')" class="btn-cmd" style="background:var(--danger);">🛑</button>` : ''}
</div>
</div>`;
}
@@ -1161,7 +1250,7 @@
// --- PUSH NOTIFICATIONS ---
async function subscribeToPush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) { customAlert("Error", "Push notifications are not supported by this browser.", true); return; }
if (!me.logged_in) { openLoginModal(); return; } // le notifiche si attivano solo da loggati
if (!me.logged_in) { openLoginModal(); return; }
const permission = await Notification.requestPermission();
if (permission !== 'granted') { customAlert("Error", "Push notifications permission denied.", true); return; }
try {
@@ -1182,7 +1271,6 @@
checkPushStatus();
// --- AGGIORNAMENTI PERIODICI ---
// Fallback se il WebSocket non è disponibile + spegne l'hang time scaduto anche senza nuovi eventi
setInterval(refreshStates, 15000);
setInterval(() => { refreshLogs(); refreshStats(); }, 60000);
@@ -1198,8 +1286,8 @@
if (badge) { if (data.connected) { badge.innerText = "MQTT: ONLINE"; badge.className = "mqtt-badge mqtt-online"; } else { badge.innerText = "MQTT: OFFLINE"; badge.className = "mqtt-badge mqtt-offline"; } }
});
socket.on('connect', () => { refreshStates(); refreshLogs(); refreshStats(); });
socket.on('dati_aggiornati', refreshStatesSoon); // stato nodi / telemetria
socket.on('log_aggiornato', refreshLogsSoon); // nuovi ascolti -> lastheard + statistiche
socket.on('dati_aggiornati', refreshStatesSoon);
socket.on('log_aggiornato', refreshLogsSoon);
}
</script>
<script>
-6
View File
@@ -1,6 +0,0 @@
hook check
test ok
test bash
test hook log
debug trace
test syslog