Compare commits
12
Commits
fa451fbf93
...
v2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
869b09f8a9 | ||
|
|
bdfc4043f8 | ||
|
|
caf77819a2 | ||
|
|
47e51e10f0 | ||
|
|
d56d3d6b5b | ||
|
|
cd2960d00b | ||
|
|
95b5dd4cbc | ||
|
|
74d7c41b93 | ||
|
|
cf4dd93c06 | ||
|
|
9fb0b01f30 | ||
|
|
a5dd99ecdc | ||
|
|
f74fa8f7c8 |
@@ -95,6 +95,10 @@ def init_db():
|
|||||||
try:
|
try:
|
||||||
c.execute("ALTER TABLE radio_logs ADD COLUMN source_ext TEXT DEFAULT ''")
|
c.execute("ALTER TABLE radio_logs ADD COLUMN source_ext TEXT DEFAULT ''")
|
||||||
except sqlite3.OperationalError: pass
|
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
|
c.execute('''CREATE TABLE IF NOT EXISTS users
|
||||||
(id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, password_hash TEXT,
|
(id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, password_hash TEXT,
|
||||||
@@ -185,8 +189,15 @@ def save_cache(data):
|
|||||||
def save_to_sqlite(client_id, data, protocol="DMR"):
|
def save_to_sqlite(client_id, data, protocol="DMR"):
|
||||||
conn = sqlite3.connect(DB_PATH, timeout=10)
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
c = conn.cursor()
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
notify_update('log_aggiornato')
|
notify_update('log_aggiornato')
|
||||||
@@ -362,16 +373,19 @@ def on_message(client, userdata, msg):
|
|||||||
notify_update() # <--- WEBSOCKET
|
notify_update() # <--- WEBSOCKET
|
||||||
|
|
||||||
# --- PUSH TRIGGER: NODE STATE ---
|
# --- PUSH TRIGGER: NODE STATE ---
|
||||||
if payload.upper() == 'OFFLINE':
|
# L'agente invia "OFFLINE - <ID>" (anche come LWT) e "ONLINE - <profilo>": si confronta il PREFISSO.
|
||||||
if last_notified_errors.get(f"{cid}_NODE") != 'OFFLINE':
|
# 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)
|
broadcast_push_notification(f"💀 NODE OFFLINE: {cid.upper()}", "Connection lost with broker.", cid=cid)
|
||||||
last_notified_errors[f"{cid}_NODE"] = 'OFFLINE'
|
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':
|
if last_notified_errors.get(f"{cid}_NODE") == 'OFFLINE':
|
||||||
broadcast_push_notification(f"🌤️ NODE ONLINE: {cid.upper()}", "Node is back online.", cid=cid)
|
broadcast_push_notification(f"🌤️ NODE ONLINE: {cid.upper()}", "Node is back online.", cid=cid)
|
||||||
del last_notified_errors[f"{cid}_NODE"]
|
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, {})
|
tel = client_telemetry.get(cid, {})
|
||||||
if isinstance(tel, dict) and '🔄' in str(tel.get('ts1', '')):
|
if isinstance(tel, dict) and '🔄' in str(tel.get('ts1', '')):
|
||||||
client_telemetry[cid] = {"ts1": "Waiting...", "ts2": "Waiting...", "alt": ""}
|
client_telemetry[cid] = {"ts1": "Waiting...", "ts2": "Waiting...", "alt": ""}
|
||||||
@@ -388,7 +402,8 @@ def on_message(client, userdata, msg):
|
|||||||
"disk": round(data.get("disk_usage_percent", 0), 1),
|
"disk": round(data.get("disk_usage_percent", 0), 1),
|
||||||
"processes": data.get("processes", {}),
|
"processes": data.get("processes", {}),
|
||||||
"files": data.get("files", data.get("config_files", [])),
|
"files": data.get("files", data.get("config_files", [])),
|
||||||
"profiles": data.get("profiles", {"A": "PROFILE A", "B": "PROFILE B"})
|
"profiles": data.get("profiles", {"A": "PROFILE A", "B": "PROFILE B"}),
|
||||||
|
"interval": data.get("update_interval")
|
||||||
}
|
}
|
||||||
health_changed = device_health.get(cid) != new_health
|
health_changed = device_health.get(cid) != new_health
|
||||||
device_health[cid] = new_health
|
device_health[cid] = new_health
|
||||||
@@ -442,7 +457,7 @@ def on_message(client, userdata, msg):
|
|||||||
logger.error(f"Error parsing DMRGateway for {cid}: {e}")
|
logger.error(f"Error parsing DMRGateway for {cid}: {e}")
|
||||||
|
|
||||||
# --- MMDVMHOST INFO MANAGEMENT (FREQUENZE & LOCATION) ---
|
# --- 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:
|
try:
|
||||||
cid = parts[1].lower()
|
cid = parts[1].lower()
|
||||||
data = json.loads(payload)
|
data = json.loads(payload)
|
||||||
@@ -471,7 +486,7 @@ def on_message(client, userdata, msg):
|
|||||||
logger.error(f"Error parsing MMDVMHost info for {cid}: {e}")
|
logger.error(f"Error parsing MMDVMHost info for {cid}: {e}")
|
||||||
|
|
||||||
# --- MMDVMHOST GENERAL MANAGEMENT (CALLSIGN & ID & DUPLEX) ---
|
# --- 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:
|
try:
|
||||||
cid = parts[1].lower()
|
cid = parts[1].lower()
|
||||||
data = json.loads(payload)
|
data = json.loads(payload)
|
||||||
@@ -627,7 +642,7 @@ def get_clients():
|
|||||||
def get_logs():
|
def get_logs():
|
||||||
conn = sqlite3.connect(DB_PATH, timeout=10)
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
c = conn.cursor()
|
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()
|
logs = c.fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify(logs)
|
return jsonify(logs)
|
||||||
|
|||||||
+164
-86
@@ -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; }
|
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 */
|
/* 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; }
|
#top-bar { background: var(--topbar-bg); padding: 12px 30px; display: flex; justify-content: space-between; align-items: center; border-radius: 6px; border: 1px solid var(--border-color); width: 100%; max-width: 1400px; }
|
||||||
.title-brand { font-size: 1.2rem; font-weight: 800; letter-spacing: 2px; color: var(--text-main); font-family: 'JetBrains Mono', monospace; }
|
.title-brand { font-size: 1.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 { background: transparent; border: 1px solid var(--border-color); color: var(--text-muted); padding: 6px 14px; border-radius: 4px; cursor: pointer; font-weight: 600; font-size: 0.85rem; font-family: 'JetBrains Mono', monospace; }
|
||||||
.theme-switch:hover { color: var(--text-main); border-color: var(--text-main); }
|
.theme-switch:hover { color: var(--text-main); border-color: var(--text-main); }
|
||||||
|
|
||||||
@@ -137,6 +152,7 @@
|
|||||||
#top-bar-container { top: 10px; padding: 0 10px; }
|
#top-bar-container { top: 10px; padding: 0 10px; }
|
||||||
#top-bar { flex-direction: column; padding: 15px; gap: 12px; border-radius: 8px; }
|
#top-bar { flex-direction: column; padding: 15px; gap: 12px; border-radius: 8px; }
|
||||||
.title-brand { font-size: 1.1rem; text-align: center; width: 100%; }
|
.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; }
|
#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 { width: 100%; justify-content: center; flex-wrap: wrap; padding-top: 12px; border-top: 1px solid var(--border-color); }
|
||||||
#auth-container > span { width: 100%; text-align: center; margin-bottom: 8px; font-family: 'JetBrains Mono', monospace; }
|
#auth-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-container">
|
||||||
<div id="top-bar">
|
<div id="top-bar">
|
||||||
<div class="title-brand">FLEET CONTROL CONSOLE</div>
|
<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;">
|
<div style="display: flex; align-items: center; gap: 12px;">
|
||||||
<span id="mqtt-badge" class="mqtt-badge mqtt-offline">MQTT: CHECKING...</span>
|
<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>
|
<button class="theme-switch" id="lang-btn" onclick="toggleLang()" data-i18n-title="ttLang">🇮🇹 ITA</button>
|
||||||
@@ -162,61 +185,68 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid" id="client-grid"></div>
|
<!-- ================= VISTA NODI ================= -->
|
||||||
|
<div id="view-nodes" style="display: none;">
|
||||||
<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;">
|
<div class="grid" id="client-grid"></div>
|
||||||
<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>
|
||||||
|
|
||||||
<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;">
|
<!-- ================= VISTA HOME ================= -->
|
||||||
<div class="card" style="padding: 15px; border-top-color: var(--accent) !important;">
|
<div id="view-home">
|
||||||
<h4 style="margin: 0 0 10px 0; color: var(--accent); font-family: 'JetBrains Mono', monospace;" data-i18n="statTopTG">🎯 TOP TALKGROUPS</h4>
|
<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;">
|
||||||
<div id="stat-tgs" style="font-size: 0.85rem; color: var(--text-muted); line-height: 1.8;" data-i18n="statLoading">Loading...</div>
|
<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>
|
||||||
|
|
||||||
<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 style="max-width: 1400px; margin: 0 auto;">
|
<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;">
|
||||||
<h3 style="margin-left: 20px; font-weight: 800; display: flex; align-items: center; gap: 10px; color: var(--text-main);">
|
<div class="card" style="padding: 15px; border-top-color: var(--accent) !important;">
|
||||||
<span style="font-size: 1.5rem;">📡</span> <span data-i18n="lastTransits">Lastheard</span>
|
<h4 style="margin: 0 0 10px 0; color: var(--accent); font-family: 'JetBrains Mono', monospace;" data-i18n="statTopTG">🎯 TOP TALKGROUPS</h4>
|
||||||
</h3>
|
<div id="stat-tgs" style="font-size: 0.85rem; color: var(--text-muted); line-height: 1.8;" data-i18n="statLoading">Loading...</div>
|
||||||
<div class="table-container">
|
</div>
|
||||||
<table>
|
|
||||||
<thead>
|
<div class="card" style="padding: 15px; border-top-color: var(--success) !important;">
|
||||||
<tr>
|
<h4 style="margin: 0 0 10px 0; color: var(--success); font-family: 'JetBrains Mono', monospace;" data-i18n="statTopCall">🗣️ TOP CALLSIGNS</h4>
|
||||||
<th data-i18n="thTime">Time</th>
|
<div id="stat-calls" style="font-size: 0.85rem; color: var(--text-muted); line-height: 1.8;" data-i18n="statLoading">Loading...</div>
|
||||||
<th data-i18n="thRep">Repeater</th>
|
</div>
|
||||||
<th data-i18n="thMode">Mode</th>
|
|
||||||
<th style="text-align: center;">Slot</th>
|
<div class="card" style="padding: 15px; border-top-color: var(--primary) !important; display: flex; justify-content: space-around; align-items: center; text-align: center;">
|
||||||
<th data-i18n="thCall">Callsign</th>
|
<div>
|
||||||
<th>Target / TG</th>
|
<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>
|
||||||
<th data-i18n="thDur">Duration</th>
|
<div style="font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; font-weight: bold;" data-i18n="statAvgDur">⏱️ AVERAGE DURATION</div>
|
||||||
<th>BER</th>
|
</div>
|
||||||
</tr>
|
<div style="width: 1px; height: 50px; background: var(--border-color);"></div>
|
||||||
</thead>
|
<div>
|
||||||
<tbody id="log-body"></tbody>
|
<div style="font-size: 1.8rem; font-weight: 800; color: var(--text-main); font-family: 'JetBrains Mono', monospace;"><span id="stat-today">--</span></div>
|
||||||
</table>
|
<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 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -474,11 +504,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- HELPER DI SICUREZZA ---
|
// --- 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||||
// q(): letterale JS sicuro da usare dentro onclick="...": onclick="fn(${q(valore)})"
|
|
||||||
function q(s) { return esc(JSON.stringify(String(s ?? ''))); }
|
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) ---
|
// --- DIAGNOSTICA: "ultimo messaggio ricevuto" per nodo (MMDVM / gateway / agent) ---
|
||||||
function fmtAge(sec) {
|
function fmtAge(sec) {
|
||||||
@@ -501,8 +547,6 @@
|
|||||||
const upSec = data.uptime || 0;
|
const upSec = data.uptime || 0;
|
||||||
let html = '';
|
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 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.`
|
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.`;
|
: `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.`;
|
: `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);
|
html += seenItem('MMDVM', ages.mmdvm, warnM ? tipMW : tipM, warnM);
|
||||||
|
|
||||||
// Gateway: solo se il nodo ne ha mai pubblicati
|
|
||||||
if (ages.gateway !== undefined) {
|
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);
|
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 hObj = (data.health && data.health[c.id.toLowerCase()]) || {};
|
||||||
const warnA = isOnline && (ages.health === undefined ? upSec > 300 : ages.health > 180);
|
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
|
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 ? '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)');
|
: (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 ---
|
// --- GLOBAL VARIABLES ---
|
||||||
let clients = [];
|
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 isAuthenticated = false;
|
||||||
let usersCache = {};
|
let usersCache = {};
|
||||||
let globalHealthData = {};
|
let globalHealthData = {};
|
||||||
@@ -592,8 +636,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function confirmReboot(id) { sendCommand(id, 'REBOOT', `🔄 ${t('titleBoot')}`, `${t('confBoot')}<b>${id.toUpperCase()}</b>?`, "var(--danger)"); }
|
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) {
|
function confirmHatReset(id) {
|
||||||
currentResetHatId = id;
|
currentResetHatId = id;
|
||||||
@@ -644,7 +687,6 @@
|
|||||||
|
|
||||||
const response = await fetch('/api/clients');
|
const response = await fetch('/api/clients');
|
||||||
clients = await response.json();
|
clients = await response.json();
|
||||||
// Gli id nodo finiscono in id HTML e onclick: accettiamo solo caratteri sicuri
|
|
||||||
clients = clients.filter(c => /^[A-Za-z0-9_\-]+$/.test(String(c.id)));
|
clients = clients.filter(c => /^[A-Za-z0-9_\-]+$/.test(String(c.id)));
|
||||||
|
|
||||||
const statSelect = document.getElementById('stat-node-filter');
|
const statSelect = document.getElementById('stat-node-filter');
|
||||||
@@ -765,17 +807,15 @@
|
|||||||
const statusDiv = document.getElementById(`status-${c.id}`); const cardDiv = document.getElementById(`card-${c.id}`);
|
const statusDiv = document.getElementById(`status-${c.id}`); const cardDiv = document.getElementById(`card-${c.id}`);
|
||||||
if (!statusDiv || !cardDiv) return;
|
if (!statusDiv || !cardDiv) return;
|
||||||
|
|
||||||
// 1. PRIMA CALCOLIAMO SE È ONLINE
|
|
||||||
let stateValue = data.states[c.id.toLowerCase()] || data.states[c.id] || "OFFLINE";
|
let stateValue = data.states[c.id.toLowerCase()] || data.states[c.id] || "OFFLINE";
|
||||||
stateValue = String(stateValue).trim().toUpperCase(); statusDiv.innerText = stateValue;
|
stateValue = String(stateValue).trim().toUpperCase(); statusDiv.innerText = stateValue;
|
||||||
const isOnline = !stateValue.includes("OFF") && stateValue !== "";
|
const isOnline = !stateValue.includes("OFF") && stateValue !== "";
|
||||||
renderSeen(c, data, isOnline);
|
renderSeen(c, data, isOnline);
|
||||||
|
|
||||||
// 2. POI AGGIORNIAMO NOME E ID DINAMICI
|
|
||||||
let genObj = data.general && data.general[c.id.toLowerCase()];
|
let genObj = data.general && data.general[c.id.toLowerCase()];
|
||||||
const nameSpan = cardDiv.querySelector('.client-name');
|
const nameSpan = cardDiv.querySelector('.client-name');
|
||||||
const idBadge = cardDiv.querySelector('.badge-id');
|
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) {
|
if (genObj && isOnline) {
|
||||||
nameSpan.innerText = genObj.callsign;
|
nameSpan.innerText = genObj.callsign;
|
||||||
@@ -784,7 +824,6 @@
|
|||||||
idBadge.style.color = "#ffffff";
|
idBadge.style.color = "#ffffff";
|
||||||
idBadge.style.borderColor = "var(--primary)";
|
idBadge.style.borderColor = "var(--primary)";
|
||||||
|
|
||||||
// --- LOGICA BADGE HOTSPOT/REPEATER ---
|
|
||||||
if (typeBadge && genObj.duplex !== undefined) {
|
if (typeBadge && genObj.duplex !== undefined) {
|
||||||
typeBadge.style.display = 'inline-block';
|
typeBadge.style.display = 'inline-block';
|
||||||
if (genObj.duplex === "1") {
|
if (genObj.duplex === "1") {
|
||||||
@@ -802,10 +841,9 @@
|
|||||||
idBadge.style.color = "var(--text-muted)";
|
idBadge.style.color = "var(--text-muted)";
|
||||||
idBadge.style.borderColor = "var(--border-color)";
|
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 };
|
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 };
|
if (typeof telemetryObj === 'string') telemetryObj = { ts1: telemetryObj, ts2: "...", alt: "", idle: true };
|
||||||
|
|
||||||
@@ -836,14 +874,10 @@
|
|||||||
activeModeColor = "var(--primary)";
|
activeModeColor = "var(--primary)";
|
||||||
|
|
||||||
if (ts1Div && ts2Div) {
|
if (ts1Div && ts2Div) {
|
||||||
// --- LOGICA DUPLEX / SIMPLEX ---
|
|
||||||
let isSimplex = (genObj && genObj.duplex === "0");
|
let isSimplex = (genObj && genObj.duplex === "0");
|
||||||
|
|
||||||
// Nascondiamo il div del TS1 se il nodo è Simplex
|
|
||||||
ts1Div.style.display = isSimplex ? 'none' : 'block';
|
ts1Div.style.display = isSimplex ? 'none' : 'block';
|
||||||
|
|
||||||
[ts1Div, ts2Div].forEach((div, idx) => {
|
[ts1Div, ts2Div].forEach((div, idx) => {
|
||||||
// Se è simplex, saltiamo del tutto l'aggiornamento grafico del TS1
|
|
||||||
if (isSimplex && idx === 0) return;
|
if (isSimplex && idx === 0) return;
|
||||||
|
|
||||||
const val = String((idx === 0 ? telemetryObj.ts1 : telemetryObj.ts2) ?? ''); const netName = idx === 0 ? netObj.ts1 : netObj.ts2;
|
const val = String((idx === 0 ? telemetryObj.ts1 : telemetryObj.ts2) ?? ''); const netName = idx === 0 ? netObj.ts1 : netObj.ts2;
|
||||||
@@ -933,15 +967,12 @@
|
|||||||
if (isOnline) {
|
if (isOnline) {
|
||||||
cardDiv.classList.add('online'); statusDiv.classList.remove('status-offline'); cardDiv.style.opacity = "1"; cardDiv.style.filter = "none";
|
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();
|
||||||
if (isTx) cardDiv.dataset.lastTx = Date.now(); // Memorizza quando è avvenuto l'ultimo TX
|
|
||||||
let timeSinceTx = Date.now() - (parseInt(cardDiv.dataset.lastTx) || 0);
|
let timeSinceTx = Date.now() - (parseInt(cardDiv.dataset.lastTx) || 0);
|
||||||
|
|
||||||
// Accendi se in TX, OPPURE se in Hang Time (ma spegni a forza dopo HANG_TIME_MS di inattività)
|
|
||||||
let showActiveColor = isTx || (!isIdle && timeSinceTx < HANG_TIME_MS);
|
let showActiveColor = isTx || (!isIdle && timeSinceTx < HANG_TIME_MS);
|
||||||
|
|
||||||
cardDiv.style.setProperty('border-top-color', showActiveColor ? activeModeColor : "var(--border-color)", 'important');
|
cardDiv.style.setProperty('border-top-color', showActiveColor ? activeModeColor : "var(--border-color)", 'important');
|
||||||
// --- FINE LOGICA ---
|
|
||||||
if(isTx) {
|
if(isTx) {
|
||||||
let shadowRGB = '59, 130, 246';
|
let shadowRGB = '59, 130, 246';
|
||||||
if (activeModeColor === '#10b981') shadowRGB = '16, 185, 129';
|
if (activeModeColor === '#10b981') shadowRGB = '16, 185, 129';
|
||||||
@@ -967,17 +998,66 @@
|
|||||||
const time = row[0] ? row[0].split(' ')[1] : "--:--";
|
const time = row[0] ? row[0].split(' ')[1] : "--:--";
|
||||||
const clientId = row[1]; const protocol = row[2] || "DMR"; let source = row[3] || "---"; const target = row[4] || "---";
|
const clientId = row[1]; const protocol = row[2] || "DMR"; let source = row[3] || "---"; const target = row[4] || "---";
|
||||||
const rawSlot = row[5]; const source_ext = row[8];
|
const rawSlot = row[5]; const source_ext = row[8];
|
||||||
|
const rawRssi = row[9];
|
||||||
|
|
||||||
let sourceHtml = esc(source);
|
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>`; }
|
if (source_ext && source_ext.trim() !== "") { sourceHtml += ` <span style="font-size:0.8em; color:#94a3b8; font-family:'JetBrains Mono', monospace;">/${esc(source_ext)}</span>`; }
|
||||||
|
|
||||||
const slotDisplay = protocol === "DMR" ? `TS${rawSlot}` : "--";
|
const slotDisplay = protocol === "DMR" ? `TS${rawSlot}` : "--";
|
||||||
let protoColor = "#3b82f6"; if (protocol === "NXDN") protoColor = "#10b981"; else if (protocol === "YSF") protoColor = "#8b5cf6"; else if (protocol === "D-STAR") protoColor = "#06b6d4"; else if (protocol === "P25") protoColor = "#f59e0b";
|
let protoColor = "#3b82f6"; if (protocol === "NXDN") protoColor = "#10b981"; else if (protocol === "YSF") protoColor = "#8b5cf6"; else if (protocol === "D-STAR") protoColor = "#06b6d4"; else if (protocol === "P25") protoColor = "#f59e0b";
|
||||||
|
|
||||||
|
// 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 (source.includes("🌐")) {
|
||||||
if (!networkLogs[clientId]) networkLogs[clientId] = "";
|
if (!networkLogs[clientId]) networkLogs[clientId] = "";
|
||||||
networkLogs[clientId] += `<div style="border-bottom:1px solid rgba(255,255,255,0.1); padding:4px 0;"><span style="color:#64748b;">[${esc(time)}]</span> <span style="color:${protoColor}; font-weight:bold;">[${esc(protocol)}]</span> <span style="color:#cbd5e1;">${esc(source.replace("🌐", "").trim())}</span></div>`;
|
networkLogs[clientId] += `<div style="border-bottom:1px solid rgba(255,255,255,0.1); padding:4px 0;"><span style="color:#64748b;">[${esc(time)}]</span> <span style="color:${protoColor}; font-weight:bold;">[${esc(protocol)}]</span> <span style="color:#cbd5e1;">${esc(source.replace("🌐", "").trim())}</span></div>`;
|
||||||
} else {
|
} else {
|
||||||
tableHTML += `<tr><td>${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;
|
tbody.innerHTML = tableHTML;
|
||||||
@@ -1043,7 +1123,6 @@
|
|||||||
// --- SETTINGS ---
|
// --- SETTINGS ---
|
||||||
function triggerGlobalEmergency() {
|
function triggerGlobalEmergency() {
|
||||||
let uniqueProfiles = {};
|
let uniqueProfiles = {};
|
||||||
// Raccoglie tutti i profili unici trovati nei nodi online
|
|
||||||
for (const id in globalHealthData) {
|
for (const id in globalHealthData) {
|
||||||
if (globalHealthData[id] && globalHealthData[id].profiles) {
|
if (globalHealthData[id] && globalHealthData[id].profiles) {
|
||||||
for (const [k, v] of Object.entries(globalHealthData[id].profiles)) {
|
for (const [k, v] of Object.entries(globalHealthData[id].profiles)) {
|
||||||
@@ -1118,10 +1197,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};">
|
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><strong style="font-size:1.1rem; display:block; font-family:'JetBrains Mono',monospace;">${esc(name)}</strong><span class="${isOnline?'':'blink'}" style="color:${statusColor}; font-size:0.8rem; font-weight:800;">${esc(String(status).toUpperCase())}</span></div>
|
||||||
<div style="display:flex; gap:8px;">
|
<div style="display:flex; gap:8px;">
|
||||||
${!isOnline ? `<button onclick="controlService(${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="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>
|
<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>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -1161,7 +1240,7 @@
|
|||||||
// --- PUSH NOTIFICATIONS ---
|
// --- PUSH NOTIFICATIONS ---
|
||||||
async function subscribeToPush() {
|
async function subscribeToPush() {
|
||||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) { customAlert("Error", "Push notifications are not supported by this browser.", true); return; }
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) { customAlert("Error", "Push notifications are not supported by this browser.", true); return; }
|
||||||
if (!me.logged_in) { openLoginModal(); return; } // le notifiche si attivano solo da loggati
|
if (!me.logged_in) { openLoginModal(); return; }
|
||||||
const permission = await Notification.requestPermission();
|
const permission = await Notification.requestPermission();
|
||||||
if (permission !== 'granted') { customAlert("Error", "Push notifications permission denied.", true); return; }
|
if (permission !== 'granted') { customAlert("Error", "Push notifications permission denied.", true); return; }
|
||||||
try {
|
try {
|
||||||
@@ -1182,7 +1261,6 @@
|
|||||||
checkPushStatus();
|
checkPushStatus();
|
||||||
|
|
||||||
// --- AGGIORNAMENTI PERIODICI ---
|
// --- AGGIORNAMENTI PERIODICI ---
|
||||||
// Fallback se il WebSocket non è disponibile + spegne l'hang time scaduto anche senza nuovi eventi
|
|
||||||
setInterval(refreshStates, 15000);
|
setInterval(refreshStates, 15000);
|
||||||
setInterval(() => { refreshLogs(); refreshStats(); }, 60000);
|
setInterval(() => { refreshLogs(); refreshStats(); }, 60000);
|
||||||
|
|
||||||
@@ -1198,8 +1276,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"; } }
|
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('connect', () => { refreshStates(); refreshLogs(); refreshStats(); });
|
||||||
socket.on('dati_aggiornati', refreshStatesSoon); // stato nodi / telemetria
|
socket.on('dati_aggiornati', refreshStatesSoon);
|
||||||
socket.on('log_aggiornato', refreshLogsSoon); // nuovi ascolti -> lastheard + statistiche
|
socket.on('log_aggiornato', refreshLogsSoon);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
Reference in New Issue
Block a user