Files
fleet-control-console/templates/index.html
T
2026-04-22 02:08:51 +02:00

1174 lines
78 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="icon" href="data:,">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fleet Control Console</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f172a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" href="/icon-512.png">
<meta name="apple-mobile-web-app-title" content="Fleet C2">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--primary: #3b82f6; --success: #10b981; --danger: #ef4444; --accent: #8b5cf6;
--bg-gradient: linear-gradient(135deg, #e0e7ff 0%, #ede9fe 100%);
--card-bg: rgba(255, 255, 255, 0.6);
--border-color: rgba(255, 255, 255, 0.8);
--text-main: #1e293b; --text-muted: #64748b;
--glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07);
--topbar-bg: rgba(255, 255, 255, 0.8);
}
body.dark-mode {
--bg-gradient: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #020617 100%);
--card-bg: rgba(30, 41, 59, 0.5);
--border-color: rgba(255, 255, 255, 0.08);
--text-main: #f8fafc; --text-muted: #94a3b8;
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.4);
--topbar-bg: rgba(15, 23, 42, 0.8);
}
* { box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: var(--bg-gradient); background-attachment: fixed; margin: 0; padding: 0; color: var(--text-main); transition: color 0.3s; min-height: 100vh; }
/* 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; }
.title-brand { font-size: 1.3rem; font-weight: 800; letter-spacing: 1px; background: linear-gradient(to right, var(--primary), var(--accent)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.theme-switch { background: rgba(128, 128, 128, 0.1); border: 1px solid var(--border-color); color: var(--text-main); padding: 8px 18px; border-radius: 20px; cursor: pointer; font-weight: 600; transition: all 0.2s ease; font-size: 0.85rem; }
.theme-switch:hover { background: rgba(128, 128, 128, 0.2); transform: translateY(-1px); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 30px; max-width: 1400px; margin: 40px auto; padding: 0 20px; }
/* Glass Cards */
.card { background: var(--card-bg); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border-radius: 20px; padding: 25px; box-shadow: var(--glass-shadow); border: 1px solid var(--border-color); border-top: 5px solid #64748b; transition: transform 0.3s ease, box-shadow 0.3s ease; display: flex; flex-direction: column; }
.card:hover { transform: translateY(-4px); box-shadow: 0 12px 40px 0 rgba(0,0,0,0.15); }
.card.online { border-top-color: var(--success); }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.client-name { font-size: 1.25rem; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
.badge-id { font-size: 0.7rem; font-weight: 800; background: rgba(128, 128, 128, 0.15); padding: 4px 10px; border-radius: 12px; letter-spacing: 0.5px; border: 1px solid var(--border-color); }
.health-bar { display: flex; justify-content: space-between; font-size: 0.75rem; font-weight: 600; background: rgba(0,0,0,0.05); padding: 8px 12px; border-radius: 12px; margin-bottom: 15px; border: 1px solid rgba(128,128,128,0.1); }
body.dark-mode .health-bar { background: rgba(0,0,0,0.2); }
.status-display { text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; font-weight: 700; padding: 10px; border-radius: 12px; background: rgba(0,0,0,0.05); border: 1px solid var(--border-color); margin-bottom: 15px; transition: all 0.3s; }
body.dark-mode .status-display { background: rgba(0,0,0,0.2); color: #10b981; }
.status-offline { color: var(--danger) !important; opacity: 0.8; }
/* TimeSlots */
.ts-container { display: flex; flex-direction: column; gap: 8px; margin-bottom: 15px; }
.dmr-info { font-size: 0.85rem; font-weight: 600; padding: 10px 15px; border-radius: 12px; background: rgba(59, 130, 246, 0.1); border-left: 4px solid var(--primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: all 0.2s; }
/* Terminal Log */
.terminal-log { background: #0f172a; color: #10b981; font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; border-radius: 12px; padding: 12px; height: 130px; overflow-y: auto; border: 1px solid rgba(255,255,255,0.1); line-height: 1.5; margin-bottom: 15px; box-shadow: inset 0 2px 10px rgba(0,0,0,0.5); }
/* Buttons */
.actions { display: none; gap: 10px; flex-wrap: wrap; margin-top: auto; }
.btn-cmd { flex: 1; padding: 10px 12px; border: none; border-radius: 12px; font-weight: 700; font-size: 0.8rem; cursor: pointer; color: white; transition: all 0.2s ease; box-shadow: 0 4px 6px rgba(0,0,0,0.1); display: flex; align-items: center; justify-content: center; gap: 5px; }
.btn-cmd:hover { transform: translateY(-2px); filter: brightness(1.1); box-shadow: 0 6px 12px rgba(0,0,0,0.15); }
.btn-cmd:active { transform: translateY(0); }
.table-container { background: var(--card-bg); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border-radius: 20px; border: 1px solid var(--border-color); overflow: hidden; max-height: 400px; overflow-y: auto; box-shadow: var(--glass-shadow); margin: 0 20px 40px 20px; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.9rem; }
/* 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); }
td { padding: 12px 15px; border-bottom: 1px solid var(--border-color); }
@keyframes pulse-glow { 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } 70% { box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); } 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); } }
.blink { animation: pulse-glow 1.5s infinite; color: var(--danger) !important; font-weight: 800 !important; background: rgba(239, 68, 68, 0.1) !important; border-left-color: var(--danger) !important; }
@keyframes tx-glow { 0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.5); } 70% { box-shadow: 0 0 0 10px rgba(59, 130, 246, 0); } 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); } }
.tx-active { animation: tx-glow 1.5s infinite; font-weight: 800 !important; color: var(--text-main) !important; background: rgba(59, 130, 246, 0.25) !important; border-left-color: var(--primary) !important; }
/* 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; }
/* Auth Buttons Style */
.auth-btn { background: var(--text-main); color: var(--bg-gradient); border: none; padding: 8px 16px; border-radius: 20px; font-weight: 700; cursor: pointer; font-size: 0.85rem; transition: 0.2s; }
.auth-btn:hover { opacity: 0.8; }
input, select { background: rgba(128,128,128,0.1); border: 1px solid var(--border-color); color: var(--text-main); padding: 10px 15px; border-radius: 12px; font-family: inherit; outline: none; transition: 0.2s; }
input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); }
input:disabled { opacity: 0.6; cursor: not-allowed; }
/* Dropdown Fix in Dark Mode */
option { background: #ffffff; color: #1e293b; }
/* --- OTTIMIZZAZIONE MOBILE (PWA / SMARTPHONE) --- */
@media (max-width: 768px) {
#top-bar-container { top: 10px; padding: 0 10px; }
#top-bar {
flex-direction: column;
border-radius: 24px;
padding: 15px;
gap: 12px;
}
.title-brand { font-size: 1.15rem; text-align: center; width: 100%; }
/* Contenitore dei tasti lingua/tema */
#top-bar > div {
width: 100%;
flex-wrap: wrap;
justify-content: center;
gap: 8px !important;
}
/* Contenitore Bottoni Login / Admin */
#auth-container {
width: 100%;
justify-content: center;
flex-wrap: wrap;
padding-top: 12px;
margin-top: 4px;
border-top: 1px solid rgba(128, 128, 128, 0.2);
}
/* Username centrato */
#auth-container > span {
width: 100%;
text-align: center;
margin-bottom: 8px;
margin-left: 0 !important;
margin-right: 0 !important;
}
/* Bottoni ingranditi perfetti per il touch */
.auth-btn {
flex: 1 1 auto;
min-width: 100px;
padding: 12px !important;
text-align: center;
font-size: 0.9rem;
}
.theme-switch {
flex: 1 1 auto;
text-align: center;
padding: 10px;
}
}
body.dark-mode option { background: #0f172a; color: #f8fafc; }
</style>
</head>
<body>
<div id="top-bar-container">
<div id="top-bar">
<div class="title-brand">FLEET CONTROL CONSOLE</div>
<div style="display: flex; align-items: center; gap: 12px;">
<button class="theme-switch" id="lang-btn" onclick="toggleLang()" data-i18n-title="ttLang">🇮🇹 ITA</button>
<button class="theme-switch" id="theme-btn" onclick="toggleTheme()" data-i18n-title="ttTheme">🌙 DARK</button>
<button class="theme-switch" id="push-btn" onclick="subscribeToPush()">🔔 PUSH</button>
<div id="auth-container" style="display:flex; align-items:center; gap:8px;"></div>
</div>
</div>
</div>
<div class="grid" id="client-grid"></div>
<div style="max-width: 1400px; margin: 0 auto;">
<h3 style="margin-left: 20px; font-weight: 800; display: flex; align-items: center; gap: 10px; color: var(--text-main);">
<span style="font-size: 1.5rem;">📡</span> <span data-i18n="lastTransits">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>
</div>
<footer>
<div style="text-align: center; padding: 25px; color: var(--text-muted); font-size: 0.85rem; font-weight: 600; display: flex; justify-content: center; align-items: center; gap: 15px; flex-wrap: wrap;">
<span>&copy; 2026 <strong>IV3JDV @ ARIFVG</strong></span>
<span style="opacity: 0.5;">|</span>
<a href="https://github.com/picchiosat/fleet-control-console" target="_blank" style="color: inherit; text-decoration: none; display: flex; align-items: center; gap: 6px; transition: color 0.2s;" title="View Source on GitHub">
<svg height="18" viewBox="0 0 16 16" width="18" style="fill: currentColor; vertical-align: middle;">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
</svg>
<span>GitHub Project</span>
</a>
<span style="opacity: 0.5;">|</span>
<span id="last-update">Sync: --:--</span>
</div>
</footer>
<div id="login-modal" class="modal-overlay">
<div class="modal-content" style="width:90%; max-width:400px;">
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-color); padding-bottom:15px; margin-bottom:20px;">
<h2 style="margin:0; color:var(--primary);" data-i18n="loginTitle">🔒 System Login</h2>
<button onclick="closeLoginModal()" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--danger);" title="Close"></button>
</div>
<div style="display:flex; flex-direction:column; gap:15px;">
<input type="text" id="modal-username" placeholder="Username" style="width:100%; padding:12px; font-size:1rem;" onkeypress="handleLoginEnter(event)">
<input type="password" id="modal-password" placeholder="Password" style="width:100%; padding:12px; font-size:1rem;" onkeypress="handleLoginEnter(event)">
<button onclick="performLogin()" class="btn-cmd" style="background:var(--success); width:100%; padding:12px; margin-top:10px; font-size:1rem;">LOGIN</button>
</div>
</div>
</div>
<div id="admin-modal" class="modal-overlay">
<div class="modal-content" style="width:90%; max-width:900px;">
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-color); padding-bottom:15px; margin-bottom:20px;">
<h2 style="margin:0;" data-i18n="adminTitle">🛠️ User & System Management</h2>
<button onclick="closeAdmin()" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--danger);" title="Close"></button>
</div>
<div style="display:flex; flex-wrap:wrap; gap:10px; margin-bottom:20px; background:rgba(0,0,0,0.05); padding:15px; border-radius:16px; align-items:center;">
<input type="text" id="new-user" placeholder="Username" style="flex:1; min-width:120px;">
<input type="password" id="new-pass" placeholder="Password" style="flex:1; min-width:120px;">
<select id="new-role" style="flex:0.5; min-width:100px;">
<option value="operator" data-i18n="roleOp">Operator</option>
<option value="admin">Admin</option>
</select>
<input type="text" id="new-nodes" placeholder="Nodes (eg: ir3uic,ir3q)" style="flex:2; min-width:150px;">
<button id="btn-user-submit" onclick="submitUser()" class="btn-cmd" style="background:var(--success); flex:0.5;" 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);">
<h4 style="margin:0 0 15px 0; color:var(--primary);" data-i18n="adminDBSync">⚙️ Database Sync Configuration</h4>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:15px; margin-bottom:15px;">
<div><label style="font-size:0.8rem; font-weight:bold; display:block; margin-bottom:5px;">URL DB DMR (.dat):</label><input type="text" id="url-dmr-input" style="width:100%;"></div>
<div><label style="font-size:0.8rem; font-weight:bold; display:block; margin-bottom:5px;">URL DB NXDN (.csv):</label><input type="text" id="url-nxdn-input" style="width:100%;"></div>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; border-top:1px solid var(--border-color); padding-top:15px;">
<div style="display:flex; align-items:center; gap:10px;"><label style="font-size:0.8rem; font-weight:bold;" data-i18n="adminTime">Daily Update Time:</label><input type="time" id="update-time-input"></div>
<button onclick="saveSettings()" class="btn-cmd" style="background:var(--accent); max-width: 250px;" data-i18n="adminSave">SAVE CONFIGURATION</button>
</div>
</div>
<div style="overflow-x:auto;">
<table style="width:100%; border-collapse:collapse; text-align:left;">
<thead><tr><th>ID</th><th data-i18n="thUser">User</th><th data-i18n="thRole">Role</th><th data-i18n="thNodes">Nodes</th><th style="text-align:center;" data-i18n="thActs">Actions</th></tr></thead>
<tbody id="users-table-body"></tbody>
</table>
</div>
</div>
</div>
<div id="services-modal" class="modal-overlay">
<div class="modal-content" style="width:90%; max-width:550px;">
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-color); padding-bottom:15px; margin-bottom:15px;">
<h2 style="margin:0; color:var(--accent);"><span data-i18n="modSvcTitle">⚙️ System Daemons:</span> <span id="svc-modal-title" style="color:var(--text-main);"></span></h2>
<button onclick="closeServicesModal()" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--danger);" title="Close"></button>
</div>
<div id="services-list" style="max-height: 400px; overflow-y: auto; padding-right:10px;"></div>
</div>
</div>
<div id="configs-modal" class="modal-overlay">
<div class="modal-content" style="width:90%; max-width:500px;">
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-color); padding-bottom:15px; margin-bottom:15px;">
<h2 style="margin:0; color:#8e44ad;" data-i18n="modFileTitle">📂 Configuration Files</h2>
<button onclick="closeConfigsModal()" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--danger);" title="Close"></button>
</div>
<div id="configs-list" style="display:flex; flex-direction:column; gap:10px;"></div>
</div>
</div>
<div id="editor-modal" class="modal-overlay" style="background:rgba(0,0,0,0.8); z-index: 3000;">
<div class="modal-content" style="width:95%; max-width:900px; border: 1px solid var(--accent);">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<h3 style="margin:0; color:var(--accent);"><span data-i18n="modEditTitle">📝 INI File Editor:</span> <span id="editor-title" style="color:var(--text-main);"></span></h3>
<button onclick="closeEditorModal()" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--danger);" title="Close"></button>
</div>
<div style="background:rgba(239, 68, 68, 0.1); border-left:4px solid var(--danger); padding:10px; margin-bottom:15px; font-size:0.85rem; font-weight:bold; border-radius: 8px;" data-i18n="warnEdit">
⚠️ WARNING: This editor directly manipulates remote node parameters.
</div>
<textarea id="config-textarea" spellcheck="false" style="width:100%; height:55vh; background:#0f172a; color:#10b981; font-family:'JetBrains Mono', monospace; font-size:0.95rem; padding:15px; border-radius:12px; border:1px solid #334155; resize:none; box-sizing:border-box; outline:none;"></textarea>
<div style="display:flex; justify-content:space-between; margin-top:20px; align-items:center;">
<span id="editor-status" style="font-size:0.9rem; font-weight:bold;"></span>
<button onclick="saveConfig()" class="btn-cmd" style="background:var(--danger); max-width: 250px;" data-i18n="btnSave">💾 SAVE & SEND</button>
</div>
</div>
</div>
<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>
<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="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;">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>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.min.js"></script>
<script>
// --- 1. TRANSLATION SYSTEM (i18n) ---
const i18n = {
en: {
themeLight: "☀️ LIGHT", themeDark: "🌙 DARK",
lastTransits: "Lastheard", 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", 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", 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",
titleSwitch: "SWITCH PROFILE", confSwitch: "Are you sure you want to switch to", titleBoot: "SYSTEM REBOOT", confBoot: "Are you sure you want to REBOOT node ",
ttReqCfg: "Request immediate data update", ttGlobal: "Force profile switch on network", ttAdmin: "User and system management", ttPass: "Change your password", ttLogout: "Log out",
ttProfA: "Switch to Profile A", ttProfB: "Switch to Profile B", ttTgOn: "Enable Telegram notifications", ttTgOff: "Disable Telegram notifications", ttSvc: "Manage system daemons", ttFile: "Edit .ini configuration files", ttHat: "Send physical reset to modem", ttBoot: "Reboot node operating system",
ttSwitchTo: "Switch to profile: ", ttSvcStart: "Start service", ttSvcRestart: "Restart service", ttSvcEdit: "Edit configuration", ttSvcStop: "Stop service", ttLang: "Change language", ttTheme: "Change visual theme"
},
it: {
themeLight: "☀️ CHIARO", themeDark: "🌙 SCURO",
lastTransits: "Ultimi ascolti", 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",
titleSwitch: "CAMBIO PROFILO", confSwitch: "Sei sicuro di voler passare al", titleBoot: "RIAVVIO SISTEMA", confBoot: "Sei sicuro di voler RIAVVIARE il nodo ",
ttReqCfg: "Richiedi aggiornamento dati", ttGlobal: "Forza cambio profilo globale", ttAdmin: "Gestione utenti e sistema", ttPass: "Cambia la tua password", ttLogout: "Disconnetti",
ttProfA: "Passa al Profilo A", ttProfB: "Passa al Profilo B", ttTgOn: "Abilita notifiche Telegram", ttTgOff: "Disabilita notifiche Telegram", ttSvc: "Gestisci demoni di sistema", ttFile: "Modifica file .ini da remoto", ttHat: "Reset fisico scheda modem", ttBoot: "Riavvia sistema operativo",
ttSwitchTo: "Passa al profilo: ", ttSvcStart: "Avvia servizio", ttSvcRestart: "Riavvia servizio", ttSvcEdit: "Modifica configurazione", ttSvcStop: "Ferma servizio", ttLang: "Cambia lingua", ttTheme: "Cambia tema visivo"
}
};
let currentLang = localStorage.getItem('lang') || 'en';
function t(key) { return i18n[currentLang][key] || key; }
function toggleLang() { currentLang = currentLang === 'it' ? 'en' : 'it'; localStorage.setItem('lang', currentLang); location.reload(); }
function applyTranslations() {
document.getElementById('lang-btn').innerText = currentLang === 'it' ? "🇬🇧 ENG" : "🇮🇹 ITA";
// Translate innerHTML
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (i18n[currentLang][key]) el.innerHTML = i18n[currentLang][key];
});
// Translate tooltips (titles)
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
if (i18n[currentLang][key]) el.title = i18n[currentLang][key];
});
// Translate placeholders
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;
let currentResetHatId = null;
let confirmActionCallback = null;
function toggleTheme() {
const isDark = document.body.classList.toggle('dark-mode');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
document.getElementById('theme-btn').innerText = isDark ? t('themeLight') : t('themeDark');
}
// --- 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, customTitle, customMsg, customColor) {
const title = customTitle || t('titleAction');
const msg = customMsg || `${t('confOp')}<b>${clientId.toUpperCase()}</b>?`;
const color = customColor || "var(--primary)";
customConfirm(title, msg, color, 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) {
const data = globalHealthData[id.toLowerCase()];
let profileName = mode === 'A' ? "PROFILE A" : "PROFILE B";
if (data && data.profiles && data.profiles[mode]) {
profileName = data.profiles[mode];
}
const title = `🔄 ${t('titleSwitch')}`;
const msg = `${t('confSwitch')} <b>${profileName}</b> -> <b>${id.toUpperCase()}</b>?`;
const color = mode === 'A' ? "var(--accent)" : "#eab308";
sendCommand(id, mode, title, msg, color);
}
function confirmReboot(id) {
const title = `🔄 ${t('titleBoot')}`;
const msg = `${t('confBoot')}<b>${id.toUpperCase()}</b>?`;
sendCommand(id, 'REBOOT', title, msg, "var(--danger)");
}
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: 'RESET_HAT' })
});
const data = await res.json();
if(!data.success) { customAlert(t('titleError'), data.error, true); }
refreshStates();
} catch (e) { console.error(e); }
}
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}<b>${clientId.toUpperCase()}</b>?`, "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() {
applyTranslations();
try {
const response = await fetch('/api/clients');
clients = await response.json();
const grid = document.getElementById('client-grid');
const authContainer = document.getElementById('auth-container');
if (localStorage.getItem('theme') === 'dark') {
document.body.classList.add('dark-mode');
document.getElementById('theme-btn').innerText = t('themeLight');
} else { document.getElementById('theme-btn').innerText = t('themeDark'); }
const role = sessionStorage.getItem('user_role');
const allowed = sessionStorage.getItem('allowed_nodes') || "";
if (isAuthenticated) {
let adminBtn = role === 'admin' ? `
<button onclick="sendGlobalUpdate()" class="auth-btn" style="background:#f59e0b; color:white;" data-i18n-title="ttReqCfg" title="${t('ttReqCfg')}">${t('btnReqCfg')}</button>
<button onclick="triggerGlobalEmergency()" class="auth-btn" style="background:#ef4444; color:white;" data-i18n-title="ttGlobal" title="${t('ttGlobal')}">${t('btnGlobal')}</button>
<button onclick="openAdmin()" class="auth-btn" style="background:var(--accent); color:white;" data-i18n-title="ttAdmin" title="${t('ttAdmin')}">${t('btnAdmin')}</button>` : '';
authContainer.innerHTML = `${adminBtn}
<span style="font-weight:800; font-size:0.9rem; margin: 0 10px;">👤 ${sessionStorage.getItem('user_name').toUpperCase()}</span>
<button onclick="changeMyPassword()" class="auth-btn" style="background:#64748b; color:white;" data-i18n-title="ttPass" title="${t('ttPass')}">${t('btnPass')}</button>
<button onclick="logout()" class="auth-btn" style="background:var(--text-main); color:var(--card-bg);" data-i18n-title="ttLogout" title="${t('ttLogout')}">${t('btnLogout')}</button>`;
} else {
authContainer.innerHTML = `<button onclick="openLoginModal()" class="auth-btn" style="background:var(--primary); color:white; padding: 8px 25px;">LOGIN</button>`;
}
grid.innerHTML = clients.map(c => {
let canControl = (role === 'admin' || allowed === 'all' || allowed.includes(c.id));
let showReboot = (role === 'admin');
return `
<div class="card" id="card-${c.id}">
<div class="card-header">
<span class="client-name" title="${c.name}">${c.name}</span>
<span class="badge-id">ID: ${c.id.toUpperCase()}</span>
</div>
<div class="health-bar" id="health-${c.id}" style="display: none;">
<span>⚡ <span id="cpu-${c.id}">--</span>%</span>
<span>🌡️ <span id="temp-${c.id}">--</span>°C</span>
<span>🧠 <span id="ram-${c.id}">--</span>%</span>
<span>💾 <span id="disk-${c.id}">--</span>%</span>
</div>
<div class="status-display" id="status-${c.id}">Offline</div>
<div id="ts-container-${c.id}" class="ts-container">
<div class="dmr-info" id="dmr-ts1-${c.id}">TS1: ...</div>
<div class="dmr-info" id="dmr-ts2-${c.id}">TS2: ...</div>
</div>
<div class="dmr-info" id="dmr-alt-${c.id}" style="display: none; text-align: center; font-weight: 800;"></div>
<div class="terminal-log" id="sys-log-${c.id}">${t('waitNet')}</div>
<div id="svc-warn-${c.id}" class="blink" style="display: none; text-align: center; margin-bottom: 15px; border-radius: 12px; padding: 10px;">
<span style="font-size: 0.85rem;" data-i18n="warnDaemon">${t('warnDaemon')}</span>
</div>
<div class="actions" style="${(isAuthenticated && canControl) ? 'display:flex;' : 'display:none'}">
<button id="btn-profA-${c.id}" class="btn-cmd" style="background: var(--accent);" title="${t('ttProfA')}" onclick="confirmSwitch('${c.id}', 'A')">PROFILE A</button>
<button id="btn-profB-${c.id}" class="btn-cmd" style="background: #eab308;" title="${t('ttProfB')}" onclick="confirmSwitch('${c.id}', 'B')">PROFILE B</button>
<div style="width: 100%; display: flex; gap: 10px;">
<button class="btn-cmd" style="background: var(--success);" title="${t('ttTgOn')}" onclick="sendTgCommand('${c.id}', 'TG:ON')">🔔 Telegram ON</button>
<button class="btn-cmd" style="background: var(--text-muted);" title="${t('ttTgOff')}" onclick="sendTgCommand('${c.id}', 'TG:OFF')">🔇 Telegram OFF</button>
</div>
${showReboot ? `
<button id="btn-svc-${c.id}" class="btn-cmd" style="background: #334155;" title="${t('ttSvc')}" onclick="openServicesModal('${c.id}')">${t('btnSvc')}</button>
<button class="btn-cmd" style="background: #8e44ad;" title="${t('ttFile')}" onclick="openConfigsModal('${c.id}')">${t('btnFile')}</button>
<button class="btn-cmd" style="background: #ea580c;" title="${t('ttHat')}" onclick="confirmHatReset('${c.id}')">${t('btnHat')}</button>
<button class="btn-cmd btn-reboot" style="background: var(--danger);" title="${t('ttBoot')}" onclick="confirmReboot('${c.id}')">${t('btnBoot')}</button>
` : ''}
</div>
</div>`;
}).join('');
refreshStates(); refreshLogs();
// setInterval rimosso! Da oggi si va in tempo reale!
} catch (e) { console.error(e); }
}
// --- LOGIN MODAL LOGIC ---
function openLoginModal() {
document.getElementById('login-modal').style.display = 'flex';
setTimeout(() => document.getElementById('modal-username').focus(), 100);
}
function closeLoginModal() {
document.getElementById('login-modal').style.display = 'none';
document.getElementById('modal-username').value = ''; document.getElementById('modal-password').value = '';
}
function handleLoginEnter(e) { if (e.key === 'Enter') performLogin(); }
async function performLogin() {
const user = document.getElementById('modal-username').value; const pass = document.getElementById('modal-password').value;
if (!user || !pass) return;
const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user, pass }) });
const data = await res.json();
if (res.ok) {
sessionStorage.setItem('is_admin', 'true'); sessionStorage.setItem('user_name', user); sessionStorage.setItem('user_role', data.role); sessionStorage.setItem('allowed_nodes', data.allowed_nodes); location.reload();
} else {
customAlert(t('titleError'), t('msgLoginFail'), true);
}
}
function logout() { sessionStorage.clear(); location.reload(); }
async function refreshStates() {
try {
const res = await fetch('/api/states');
const data = await res.json();
clients.forEach(c => {
const statusDiv = document.getElementById(`status-${c.id}`);
const cardDiv = document.getElementById(`card-${c.id}`);
if (!statusDiv || !cardDiv) return;
let stateValue = data.states[c.id.toLowerCase()] || data.states[c.id] || "OFFLINE";
stateValue = String(stateValue).trim().toUpperCase();
statusDiv.innerText = stateValue;
const isOnline = !stateValue.includes("OFF") && stateValue !== "";
let telemetryObj = data.telemetry[c.id.toLowerCase()] || data.telemetry[c.id] || { ts1: "...", ts2: "...", alt: "", idle: true };
if (typeof telemetryObj === 'string') telemetryObj = { ts1: telemetryObj, ts2: "...", alt: "", idle: true };
const tsContainer = document.getElementById(`ts-container-${c.id}`);
const ts1Div = document.getElementById(`dmr-ts1-${c.id}`);
const ts2Div = document.getElementById(`dmr-ts2-${c.id}`);
const altDiv = document.getElementById(`dmr-alt-${c.id}`);
let isTx = false;
let activeModeColor = "var(--success)";
let isIdle = telemetryObj.idle === true;
if (telemetryObj.alt && telemetryObj.alt !== "") {
if (tsContainer) tsContainer.style.display = "none";
if (altDiv) {
altDiv.style.display = "block"; altDiv.innerText = telemetryObj.alt;
let altText = telemetryObj.alt.toUpperCase();
if (altText.includes("NXDN")) activeModeColor = "#10b981"; else if (altText.includes("YSF")) activeModeColor = "#8b5cf6"; else if (altText.includes("D-STAR")) activeModeColor = "#06b6d4"; else if (altText.includes("P25")) activeModeColor = "#f59e0b";
isTx = altText.includes("🟢") || altText.includes("🟣") || altText.includes("🔵") || altText.includes("🟠");
altDiv.style.setProperty('color', activeModeColor, 'important');
altDiv.style.setProperty('border-left', `4px solid ${activeModeColor}`, 'important');
if (isTx) { altDiv.classList.add('blink'); } else { altDiv.classList.remove('blink'); }
}
} else {
if (altDiv) altDiv.style.display = "none";
if (tsContainer) tsContainer.style.display = "flex";
let netObj = data.networks && data.networks[c.id.toLowerCase()] ? data.networks[c.id.toLowerCase()] : {ts1: "", ts2: ""};
activeModeColor = "var(--primary)";
if (ts1Div && ts2Div) {
[ts1Div, ts2Div].forEach((div, idx) => {
const val = idx === 0 ? telemetryObj.ts1 : telemetryObj.ts2;
const netName = idx === 0 ? netObj.ts1 : netObj.ts2;
const baseLabel = `TS${idx + 1}`;
const fullLabel = netName ? `${baseLabel} [${netName}]` : baseLabel;
div.innerText = `${fullLabel}: ${val}`;
if (val.includes("🎙️")) {
isTx = true;
div.classList.add('tx-active');
} else {
div.classList.remove('tx-active');
div.style.setProperty('color', 'var(--text-main)', 'important');
div.style.setProperty('border-left-color', 'var(--primary)', 'important');
div.style.setProperty('background', 'rgba(59, 130, 246, 0.1)', 'important');
}
});
}
}
let healthObj = data.health && data.health[c.id.toLowerCase()];
const healthContainer = document.getElementById(`health-${c.id}`);
const cpuSpan = document.getElementById(`cpu-${c.id}`); const tempSpan = document.getElementById(`temp-${c.id}`); const ramSpan = document.getElementById(`ram-${c.id}`); const diskSpan = document.getElementById(`disk-${c.id}`);
if (healthObj && isOnline) {
healthContainer.style.display = 'flex';
let cpu = healthObj.cpu; cpuSpan.innerText = cpu; cpuSpan.style.color = cpu < 50 ? 'var(--success)' : (cpu < 80 ? '#f59e0b' : 'var(--danger)');
// FIXED TEMPERATURE VARIABLE NAME HERE
let tempVal = healthObj.temp;
tempSpan.innerText = tempVal;
tempSpan.style.color = tempVal < 55 ? 'var(--success)' : (tempVal < 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 : "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) {
if (btnA.innerText !== profA) btnA.innerText = profA;
btnA.title = `${t('ttSwitchTo')}${profA}`;
}
if (btnB) {
if (btnB.innerText !== profB) btnB.innerText = profB;
btnB.title = `${t('ttSwitchTo')}${profB}`;
}
} else { if (healthContainer) healthContainer.style.display = 'none'; }
globalHealthData[c.id.toLowerCase()] = healthObj;
let hasOfflineService = false;
if (healthObj && healthObj.processes) {
for (const [svcName, svcStatus] of Object.entries(healthObj.processes)) { if (svcStatus.toLowerCase() !== 'online') { hasOfflineService = true; break; } }
}
const warnBadge = document.getElementById(`svc-warn-${c.id}`); const btnSvc = document.getElementById(`btn-svc-${c.id}`);
if (warnBadge) warnBadge.style.display = hasOfflineService ? 'block' : 'none';
if (btnSvc) {
if (hasOfflineService) { btnSvc.style.background = 'var(--danger)'; btnSvc.classList.add('blink'); btnSvc.innerHTML = t('btnSvcKo'); }
else { btnSvc.style.background = '#334155'; btnSvc.classList.remove('blink'); btnSvc.innerHTML = t('btnSvc'); }
}
if (isOnline) {
cardDiv.classList.add('online'); statusDiv.classList.remove('status-offline');
cardDiv.style.opacity = "1"; cardDiv.style.filter = "none";
let targetBorderColor = activeModeColor;
if (!isTx && isIdle) { targetBorderColor = "var(--border-color)"; }
cardDiv.style.setProperty('border-top-color', targetBorderColor, 'important');
if(isTx) {
// Creiamo un alone luminoso che sia SEMPRE dello stesso colore del modo attivo
let shadowRGB = '59, 130, 246'; // Blu di default (per DMR e var(--primary))
if (activeModeColor === '#10b981') shadowRGB = '16, 185, 129'; // Verde (NXDN)
else if (activeModeColor === '#8b5cf6') shadowRGB = '139, 92, 246'; // Viola (YSF)
else if (activeModeColor === '#06b6d4') shadowRGB = '6, 182, 212'; // Ciano (D-STAR)
else if (activeModeColor === '#f59e0b') shadowRGB = '245, 158, 11'; // Arancione (P25)
cardDiv.style.boxShadow = `0 0 20px rgba(${shadowRGB}, 0.5)`;
} else {
cardDiv.style.boxShadow = 'var(--glass-shadow)';
}
} else {
cardDiv.classList.remove('online'); statusDiv.classList.add('status-offline');
cardDiv.style.opacity = "0.7"; cardDiv.style.filter = "grayscale(80%)";
cardDiv.style.setProperty('border-top-color', '#64748b', 'important');
cardDiv.style.boxShadow = 'var(--glass-shadow)';
}
});
document.getElementById('last-update').innerText = "Sync: " + new Date().toLocaleTimeString();
} catch (e) { console.error(e); }
}
async function refreshLogs() {
try {
const res = await fetch('/api/logs'); const logs = await res.json();
const tbody = document.getElementById('log-body'); let tableHTML = ""; let networkLogs = {};
logs.forEach(row => {
const time = row[0] ? row[0].split(' ')[1] : "--:--"; const clientId = row[1]; const protocol = row[2] || "DMR"; const source = row[3] || "---"; const target = row[4] || "---"; const rawSlot = row[5];
const slotDisplay = protocol === "DMR" ? `TS${rawSlot}` : "--";
let protoColor = "#3b82f6"; if (protocol === "NXDN") protoColor = "#10b981"; else if (protocol === "YSF") protoColor = "#8b5cf6"; else if (protocol === "D-STAR") protoColor = "#06b6d4"; else if (protocol === "P25") protoColor = "#f59e0b";
if (source.includes("🌐")) {
if (!networkLogs[clientId]) networkLogs[clientId] = "";
networkLogs[clientId] += `<div style="border-bottom:1px solid rgba(255,255,255,0.1); padding:4px 0;"><span style="color:#64748b;">[${time}]</span> <span style="color:${protoColor}; font-weight:bold;">[${protocol}]</span> <span style="color:#cbd5e1;">${source.replace("🌐", "").trim()}</span></div>`;
} else {
tableHTML += `<tr><td>${time}</td><td><b style="color:var(--text-main);">${clientId.toUpperCase()}</b></td><td><span style="background:${protoColor}; color:white; padding:3px 8px; border-radius:8px; font-size:0.7rem; font-weight:800;">${protocol}</span></td><td style="font-family:'JetBrains Mono', monospace; font-weight:bold; text-align:center;">${slotDisplay}</td><td style="color:var(--accent); font-weight:800;">${source}</td><td style="font-family:'JetBrains Mono', monospace;">${target.trim()}</td><td>${row[6]}s</td><td>${row[7]}%</td></tr>`;
}
});
tbody.innerHTML = tableHTML;
clients.forEach(c => { const localDiv = document.getElementById(`sys-log-${c.id}`); if (localDiv && networkLogs[c.id]) { localDiv.innerHTML = networkLogs[c.id]; } });
} catch (e) { console.error(e); }
}
// --- 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(); }
async function loadUsers() {
const res = await fetch('/api/users'); const users = await res.json();
document.getElementById('users-table-body').innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td style="font-weight:bold;">${u.username}</td>
<td><span style="background:rgba(128,128,128,0.2); padding:2px 8px; border-radius:6px; font-size:0.8rem;">${u.role.toUpperCase()}</span></td>
<td>${u.allowed_nodes}</td>
<td style="text-align:center;">
<button onclick="startEditUser(${u.id}, '${u.username}', '${u.role}', '${u.allowed_nodes}')" class="btn-cmd" style="background:var(--accent); padding:6px; width:auto; display:inline-block; margin-right:5px;" title="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('');
}
function startEditUser(id, username, role, nodes) {
editingUserId = id;
document.getElementById('new-user').value = username;
document.getElementById('new-user').disabled = true;
document.getElementById('new-pass').value = "";
document.getElementById('new-pass').placeholder = t('phNewPass');
document.getElementById('new-role').value = role;
document.getElementById('new-nodes').value = nodes;
const btnSubmit = document.getElementById('btn-user-submit');
btnSubmit.innerHTML = t('titleSave');
btnSubmit.style.background = "var(--accent)";
document.getElementById('btn-user-cancel').style.display = "block";
}
function cancelEdit() {
editingUserId = null;
document.getElementById('new-user').value = "";
document.getElementById('new-user').disabled = false;
document.getElementById('new-pass').value = "";
document.getElementById('new-pass').placeholder = t('phPass');
document.getElementById('new-role').value = "operator";
document.getElementById('new-nodes').value = "";
const btnSubmit = document.getElementById('btn-user-submit');
btnSubmit.innerHTML = t('btnAdd');
btnSubmit.style.background = "var(--success)";
document.getElementById('btn-user-cancel').style.display = "none";
}
async function submitUser() {
const username = document.getElementById('new-user').value;
const password = document.getElementById('new-pass').value;
const role = document.getElementById('new-role').value;
let allowed = document.getElementById('new-nodes').value;
if (!username) return customAlert(t('titleError'), t('msgMissUser'), true);
const payload = { username, role, allowed_nodes: allowed || 'all' };
if (password) payload.password = password;
if (editingUserId) {
const res = await fetch(`/api/users/${editingUserId}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) });
const data = await res.json();
if (data.success) { cancelEdit(); loadUsers(); } else customAlert(t('titleError'), data.error, true);
} else {
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 customAlert(t('titleError'), data.error, true);
}
}
function deleteUser(id) {
customConfirm(t('titleDelete'), t('msgDelUser'), "var(--danger)", async () => {
await fetch(`/api/users/${id}`, {method: 'DELETE'});
loadUsers();
cancelEdit();
});
}
// --- EMERGENCY & SETTINGS ---
function triggerGlobalEmergency() {
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;
}
}
document.getElementById('btn-global-A').innerText = nameA;
document.getElementById('btn-global-B').innerText = nameB;
document.getElementById('override-desc').innerText = t('msgOvrSel');
document.getElementById('override-modal').style.display = 'flex';
}
function sendGlobalAction(action) {
document.getElementById('override-modal').style.display = 'none';
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) {
customAlert(t('titleSuccess'), t('msgOvrOk'));
} else {
customAlert(t('titleError'), d.error, true);
}
refreshStates();
} catch(e) { console.error(e); }
});
}
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) 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()];
const listDiv = document.getElementById('configs-list');
let listaFile = data ? (data.config_files || data.files || []) : [];
if (listaFile.length === 0) {
listDiv.innerHTML = `<div style="text-align:center; padding:20px;"><p style="color:var(--text-muted); margin-bottom:15px;">${t('waitData')}</p><button onclick="sendGlobalUpdate(); closeConfigsModal();" class="btn-cmd" style="background:var(--accent);">${t('forceUpdate')}</button></div>`;
} else {
listDiv.innerHTML = listaFile.map(filename => `
<div style="display:flex; justify-content:space-between; align-items:center; padding:12px; background:rgba(128,128,128,0.1); border-radius:12px; border:1px solid var(--border-color); margin-bottom:8px;">
<span style="font-family:'JetBrains Mono',monospace; font-weight:bold;">${filename.toUpperCase()}.ini</span>
<button onclick="closeConfigsModal(); openEditorModal('${clientId}', '${filename}')" class="btn-cmd" style="background:#8e44ad; max-width:120px;" title="${t('ttFile')}">${t('btnEdit')}</button>
</div>`).join('');
}
document.getElementById('configs-modal').style.display = 'flex';
}
function closeConfigsModal() { document.getElementById('configs-modal').style.display = 'none'; }
function openServicesModal(clientId) { document.getElementById('svc-modal-title').innerText = clientId.toUpperCase(); document.getElementById('services-modal').style.display = 'flex'; renderServicesList(clientId); }
function closeServicesModal() { document.getElementById('services-modal').style.display = 'none'; }
function renderServicesList(clientId) {
const data = globalHealthData[clientId.toLowerCase()]; const listDiv = document.getElementById('services-list');
if (!data || !data.processes || Object.keys(data.processes).length === 0) { listDiv.innerHTML = "<p style='text-align:center; color:var(--text-muted);'>No service data available.</p>"; return; }
let html = "";
for (const [name, status] of Object.entries(data.processes)) {
const isOnline = status.toLowerCase() === 'online'; const statusColor = isOnline ? 'var(--success)' : 'var(--danger)'; const statusAnim = isOnline ? '' : 'animation: pulse-glow 1.5s infinite;';
html += `
<div style="display:flex; justify-content:space-between; align-items:center; padding:15px; border-bottom:1px solid var(--border-color); background:rgba(128,128,128,0.05); border-radius:12px; margin-bottom:8px; border-left: 5px solid ${statusColor};">
<div>
<strong style="font-size:1.1rem; display:block; font-family:'JetBrains Mono',monospace;">${name}</strong>
<span style="color:${statusColor}; font-size:0.8rem; font-weight:800; ${statusAnim}">${status.toUpperCase()}</span>
</div>
<div style="display:flex; gap:8px;">
${!isOnline ? `<button onclick="controlService('${clientId}', '${name}', 'start')" class="btn-cmd" style="background:var(--success);" title="${t('ttSvcStart')}">▶</button>` : ''}
<button onclick="controlService('${clientId}', '${name}', 'restart')" class="btn-cmd" style="background:#f59e0b;" title="${t('ttSvcRestart')}">🔄</button>
<button onclick="openEditorModal('${clientId}', '${name}')" class="btn-cmd" style="background:#8e44ad;" title="${t('ttSvcEdit')}">📝</button>
${isOnline ? `<button onclick="controlService('${clientId}', '${name}', 'stop')" class="btn-cmd" style="background:var(--danger);" title="${t('ttSvcStop')}">🛑</button>` : ''}
</div>
</div>`;
}
listDiv.innerHTML = html;
}
function controlService(clientId, service, action) {
customConfirm(t('titleAction'), `${t('confOp')}<b>${service}</b>?`, "var(--accent)", async () => {
try {
const res = await fetch('/api/service_control', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ clientId, service, action }) });
const data = await res.json();
if(!data.success) customAlert(t('titleError'), "Error: " + data.error, true);
} catch(e) { console.error(e); }
});
}
let currentEditClient = ""; let currentEditService = "";
async function openEditorModal(clientId, service) {
currentEditClient = clientId; currentEditService = service;
document.getElementById('editor-title').innerText = `${service.toUpperCase()} @ ${clientId.toUpperCase()}`;
document.getElementById('config-textarea').value = "Connecting to server..."; document.getElementById('editor-status').innerText = "";
document.getElementById('editor-modal').style.display = 'flex';
try { const res = await fetch(`/api/config_file/${clientId}/${service}`); const data = await res.json(); if (data.success) document.getElementById('config-textarea').value = data.data.raw_text || "Empty file"; else document.getElementById('config-textarea').value = "ERROR:\n" + data.error; } catch(e) { document.getElementById('config-textarea').value = "Connection error."; }
}
function closeEditorModal() { document.getElementById('editor-modal').style.display = 'none'; }
function saveConfig() {
const textValue = document.getElementById('config-textarea').value; const statusSpan = document.getElementById('editor-status');
customConfirm(t('titleSave'), `${t('confOvr')}<b>${currentEditClient.toUpperCase()}</b>?`, "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";
}
});
}
// --- FUNZIONE PER ISCRIVERSI ALLE PUSH ---
async function subscribeToPush() {
if (!('serviceWorker' in navigator)) return;
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
customAlert("Error", "Push notifications permission denied.", true);
return;
}
try {
const reg = await navigator.serviceWorker.ready;
const res = await fetch('/api/vapid_public_key');
const { public_key } = await res.json();
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlB64ToUint8Array(public_key)
});
await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
customAlert("Success", "Push notifications enabled successfully!");
document.getElementById('push-btn').style.color = 'var(--success)';
} catch (e) {
console.error(e);
customAlert("Error", "Failed to enable notifications.", true);
}
}
// --- FUNZIONE PER CONTROLLARE IL COLORE DEL BOTTONE ---
async function checkPushStatus() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
try {
const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.getSubscription();
const btn = document.getElementById('push-btn');
if (subscription && btn) {
btn.style.color = 'var(--success)';
}
} catch(e) {
console.error("Errore nel controllo stato Push:", e);
}
}
// Helper per convertire la chiave VAPID
function urlB64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i);
return outputArray;
}
initUI();
checkPushStatus(); // Ora funzionerà perfettamente!
// --- MOTORE WEBSOCKET REAL-TIME ---
const socket = io();
socket.on('connect', () => {
console.log("🟢 Connesso al server via WebSocket in tempo reale!");
// Facciamo un aggiornamento di sicurezza appena il tunnel si apre
refreshStates();
refreshLogs();
});
socket.on('dati_aggiornati', function() {
console.log("⚡ Rilevato nuovo traffico! Scatto istantaneo dell'interfaccia...");
// Il server ha appena urlato che ci sono novità: ricarichiamo le card e i log!
refreshStates();
refreshLogs();
});
</script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then(reg => {
console.log('ServiceWorker registrato con successo!', reg.scope);
}).catch(err => {
console.log('Registrazione ServiceWorker fallita: ', err);
});
});
}
</script>
</body>
</html>