Primo rilascio: Fleet Control Console v1.0

This commit is contained in:
root
2026-04-18 14:00:34 +02:00
commit a599a3fef2
6 changed files with 1317 additions and 0 deletions
+699
View File
@@ -0,0 +1,699 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="icon" href="data:,">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fleet Control Console</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--primary: #3b82f6; --success: #10b981; --danger: #ef4444; --accent: #8b5cf6;
--bg-gradient: linear-gradient(135deg, #e0e7ff 0%, #ede9fe 100%);
--card-bg: rgba(255, 255, 255, 0.6);
--border-color: rgba(255, 255, 255, 0.8);
--text-main: #1e293b; --text-muted: #64748b;
--glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07);
--topbar-bg: rgba(255, 255, 255, 0.8);
}
body.dark-mode {
--bg-gradient: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #020617 100%);
--card-bg: rgba(30, 41, 59, 0.5);
--border-color: rgba(255, 255, 255, 0.08);
--text-main: #f8fafc; --text-muted: #94a3b8;
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.4);
--topbar-bg: rgba(15, 23, 42, 0.8);
}
* { box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: var(--bg-gradient); background-attachment: fixed; margin: 0; padding: 0; color: var(--text-main); transition: color 0.3s; min-height: 100vh; }
/* Top Bar Fluttuante */
#top-bar-container { position: sticky; top: 15px; z-index: 100; padding: 0 20px; display: flex; justify-content: center; }
#top-bar { background: var(--topbar-bg); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); padding: 12px 30px; display: flex; justify-content: space-between; align-items: center; border-radius: 50px; box-shadow: var(--glass-shadow); border: 1px solid var(--border-color); width: 100%; max-width: 1400px; }
.title-brand { font-size: 1.3rem; font-weight: 800; letter-spacing: 1px; background: linear-gradient(to right, var(--primary), var(--accent)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.theme-switch { background: rgba(128, 128, 128, 0.1); border: 1px solid var(--border-color); color: var(--text-main); padding: 8px 18px; border-radius: 20px; cursor: pointer; font-weight: 600; transition: all 0.2s ease; font-size: 0.85rem; }
.theme-switch:hover { background: rgba(128, 128, 128, 0.2); transform: translateY(-1px); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 30px; max-width: 1400px; margin: 40px auto; padding: 0 20px; }
/* Glass Cards */
.card { background: var(--card-bg); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border-radius: 20px; padding: 25px; box-shadow: var(--glass-shadow); border: 1px solid var(--border-color); border-top: 5px solid #64748b; transition: transform 0.3s ease, box-shadow 0.3s ease; display: flex; flex-direction: column; }
.card:hover { transform: translateY(-4px); box-shadow: 0 12px 40px 0 rgba(0,0,0,0.15); }
.card.online { border-top-color: var(--success); }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.client-name { font-size: 1.25rem; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
.badge-id { font-size: 0.7rem; font-weight: 800; background: rgba(128, 128, 128, 0.15); padding: 4px 10px; border-radius: 12px; letter-spacing: 0.5px; border: 1px solid var(--border-color); }
.health-bar { display: flex; justify-content: space-between; font-size: 0.75rem; font-weight: 600; background: rgba(0,0,0,0.05); padding: 8px 12px; border-radius: 12px; margin-bottom: 15px; border: 1px solid rgba(128,128,128,0.1); }
body.dark-mode .health-bar { background: rgba(0,0,0,0.2); }
.status-display { text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; font-weight: 700; padding: 10px; border-radius: 12px; background: rgba(0,0,0,0.05); border: 1px solid var(--border-color); margin-bottom: 15px; transition: all 0.3s; }
body.dark-mode .status-display { background: rgba(0,0,0,0.2); color: #10b981; }
.status-offline { color: var(--danger) !important; opacity: 0.8; }
/* TimeSlots */
.ts-container { display: flex; flex-direction: column; gap: 8px; margin-bottom: 15px; }
.dmr-info { font-size: 0.85rem; font-weight: 600; padding: 10px 15px; border-radius: 12px; background: rgba(59, 130, 246, 0.1); border-left: 4px solid var(--primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: all 0.2s; }
/* Terminal Log */
.terminal-log { background: #0f172a; color: #10b981; font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; border-radius: 12px; padding: 12px; height: 130px; overflow-y: auto; border: 1px solid rgba(255,255,255,0.1); line-height: 1.5; margin-bottom: 15px; box-shadow: inset 0 2px 10px rgba(0,0,0,0.5); }
/* Buttons */
.actions { display: none; gap: 10px; flex-wrap: wrap; margin-top: auto; }
.btn-cmd { flex: 1; padding: 10px 12px; border: none; border-radius: 12px; font-weight: 700; font-size: 0.8rem; cursor: pointer; color: white; transition: all 0.2s ease; box-shadow: 0 4px 6px rgba(0,0,0,0.1); display: flex; align-items: center; justify-content: center; gap: 5px; }
.btn-cmd:hover { transform: translateY(-2px); filter: brightness(1.1); box-shadow: 0 6px 12px rgba(0,0,0,0.15); }
.btn-cmd:active { transform: translateY(0); }
.table-container { background: var(--card-bg); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border-radius: 20px; border: 1px solid var(--border-color); overflow: hidden; max-height: 400px; overflow-y: auto; box-shadow: var(--glass-shadow); margin: 0 20px 40px 20px; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.9rem; }
thead { background: rgba(0,0,0,0.05); position: sticky; top: 0; z-index: 1; backdrop-filter: blur(5px); }
body.dark-mode document thead { background: rgba(255,255,255,0.05); }
th { padding: 15px; font-weight: 700; text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.5px; color: var(--text-muted); }
td { padding: 12px 15px; border-bottom: 1px solid var(--border-color); }
@keyframes pulse-glow { 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } 70% { box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); } 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); } }
.blink { animation: pulse-glow 1.5s infinite; color: var(--danger) !important; font-weight: 800 !important; background: rgba(239, 68, 68, 0.1) !important; border-left-color: var(--danger) !important; }
/* Modals (Vetro scuro) */
.modal-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); backdrop-filter:blur(5px); -webkit-backdrop-filter:blur(5px); z-index:1000; align-items:center; justify-content:center; }
.modal-content { background:var(--card-bg); backdrop-filter:blur(20px); border:1px solid var(--border-color); padding:30px; border-radius:24px; box-shadow:0 25px 50px -12px rgba(0,0,0,0.5); max-height: 90vh; overflow-y: auto; }
/* Auth Buttons Style */
.auth-btn { background: var(--text-main); color: var(--bg-gradient); border: none; padding: 8px 16px; border-radius: 20px; font-weight: 700; cursor: pointer; font-size: 0.85rem; transition: 0.2s; }
.auth-btn:hover { opacity: 0.8; }
input, select { background: rgba(128,128,128,0.1); border: 1px solid var(--border-color); color: var(--text-main); padding: 10px 15px; border-radius: 12px; font-family: inherit; outline: none; transition: 0.2s; }
input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); }
input:disabled { opacity: 0.6; cursor: not-allowed; }
/* Fix per il menu a tendina (selezioni) in Dark Mode */
option { background: #ffffff; color: #1e293b; }
body.dark-mode option { background: #0f172a; color: #f8fafc; }
</style>
</head>
<body>
<div id="top-bar-container">
<div id="top-bar">
<div class="title-brand">FLEET MANAGER</div>
<div style="display: flex; align-items: center; gap: 12px;">
<button class="theme-switch" id="lang-btn" onclick="toggleLang()">🇮🇹 ITA</button>
<button class="theme-switch" id="theme-btn" onclick="toggleTheme()">🌙 DARK</button>
<div id="auth-container" style="display:flex; align-items:center; gap:8px;"></div>
</div>
</div>
</div>
<div class="grid" id="client-grid"></div>
<div style="max-width: 1400px; margin: 0 auto;">
<h3 style="margin-left: 20px; font-weight: 800; display: flex; align-items: center; gap: 10px; color: var(--text-main);">
<span style="font-size: 1.5rem;">📡</span> <span data-i18n="lastTransits">Latest Radio Transits</span>
</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th data-i18n="thTime">Time</th>
<th data-i18n="thRep">Repeater</th>
<th data-i18n="thMode">Mode</th>
<th style="text-align: center;">Slot</th>
<th data-i18n="thCall">Callsign</th>
<th>Target / TG</th>
<th data-i18n="thDur">Duration</th>
<th>BER</th>
</tr>
</thead>
<tbody id="log-body"></tbody>
</table>
</div>
</div>
<footer>
<div style="text-align: center; padding: 20px; color: var(--text-muted); font-size: 0.85rem; font-weight: 600;">
&copy; 2026 <strong>IV3JDV @ ARIFVG</strong> | <span id="last-update">Sync: --:--</span>
</div>
</footer>
<div id="login-modal" class="modal-overlay">
<div class="modal-content" style="width:90%; max-width:400px;">
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-color); padding-bottom:15px; margin-bottom:20px;">
<h2 style="margin:0; color:var(--primary);" data-i18n="loginTitle">🔒 Login di Sistema</h2>
<button onclick="closeLoginModal()" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--danger);"></button>
</div>
<div style="display:flex; flex-direction:column; gap:15px;">
<input type="text" id="modal-username" placeholder="Username" style="width:100%; padding:12px; font-size:1rem;" onkeypress="handleLoginEnter(event)">
<input type="password" id="modal-password" placeholder="Password" style="width:100%; padding:12px; font-size:1rem;" onkeypress="handleLoginEnter(event)">
<button onclick="performLogin()" class="btn-cmd" style="background:var(--success); width:100%; padding:12px; margin-top:10px; font-size:1rem;">LOGIN</button>
</div>
</div>
</div>
<div id="admin-modal" class="modal-overlay">
<div class="modal-content" style="width:90%; max-width:900px;">
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-color); padding-bottom:15px; margin-bottom:20px;">
<h2 style="margin:0;" data-i18n="adminTitle">🛠️ User & System Management</h2>
<button onclick="closeAdmin()" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--danger);"></button>
</div>
<div style="display:flex; flex-wrap:wrap; gap:10px; margin-bottom:20px; background:rgba(0,0,0,0.05); padding:15px; border-radius:16px; align-items:center;">
<input type="text" id="new-user" placeholder="Username" style="flex:1; min-width:120px;">
<input type="password" id="new-pass" placeholder="Password" style="flex:1; min-width:120px;">
<select id="new-role" style="flex:0.5; min-width:100px;">
<option value="operator" data-i18n="roleOp">Operator</option>
<option value="admin">Admin</option>
</select>
<input type="text" id="new-nodes" placeholder="Nodes (eg: ir3uic,ir3q)" style="flex:2; min-width:150px;">
<button id="btn-user-submit" onclick="submitUser()" class="btn-cmd" style="background:var(--success); flex:0.5;">+ ADD</button>
<button id="btn-user-cancel" onclick="cancelEdit()" class="btn-cmd" style="background:var(--text-muted); flex:0.2; display:none;" title="Annulla Modifica"></button>
</div>
<div style="margin-bottom:20px; background:rgba(59, 130, 246, 0.05); padding:20px; border-radius:16px; border: 1px solid var(--primary);">
<h4 style="margin:0 0 15px 0; color:var(--primary);" data-i18n="adminDBSync">⚙️ Database Sync Configuration</h4>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:15px; margin-bottom:15px;">
<div><label style="font-size:0.8rem; font-weight:bold; display:block; margin-bottom:5px;">URL DB DMR (.dat):</label><input type="text" id="url-dmr-input" style="width:100%;"></div>
<div><label style="font-size:0.8rem; font-weight:bold; display:block; margin-bottom:5px;">URL DB NXDN (.csv):</label><input type="text" id="url-nxdn-input" style="width:100%;"></div>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; border-top:1px solid var(--border-color); padding-top:15px;">
<div style="display:flex; align-items:center; gap:10px;"><label style="font-size:0.8rem; font-weight:bold;" data-i18n="adminTime">Daily Update Time:</label><input type="time" id="update-time-input"></div>
<button onclick="saveSettings()" class="btn-cmd" style="background:var(--accent); max-width: 250px;" data-i18n="adminSave">SAVE CONFIGURATION</button>
</div>
</div>
<div style="overflow-x:auto;">
<table style="width:100%; border-collapse:collapse; text-align:left;">
<thead><tr><th>ID</th><th data-i18n="thUser">User</th><th data-i18n="thRole">Role</th><th data-i18n="thNodes">Nodes</th><th style="text-align:center;" data-i18n="thActs">Actions</th></tr></thead>
<tbody id="users-table-body"></tbody>
</table>
</div>
</div>
</div>
<div id="services-modal" class="modal-overlay">
<div class="modal-content" style="width:90%; max-width:550px;">
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-color); padding-bottom:15px; margin-bottom:15px;">
<h2 style="margin:0; color:var(--accent);"><span data-i18n="modSvcTitle">⚙️ System Daemons:</span> <span id="svc-modal-title" style="color:var(--text-main);"></span></h2>
<button onclick="closeServicesModal()" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--danger);"></button>
</div>
<div id="services-list" style="max-height: 400px; overflow-y: auto; padding-right:10px;"></div>
</div>
</div>
<div id="configs-modal" class="modal-overlay">
<div class="modal-content" style="width:90%; max-width:500px;">
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-color); padding-bottom:15px; margin-bottom:15px;">
<h2 style="margin:0; color:#8e44ad;" data-i18n="modFileTitle">📂 Configuration Files</h2>
<button onclick="closeConfigsModal()" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--danger);"></button>
</div>
<div id="configs-list" style="display:flex; flex-direction:column; gap:10px;"></div>
</div>
</div>
<div id="editor-modal" class="modal-overlay" style="background:rgba(0,0,0,0.8); z-index: 3000;">
<div class="modal-content" style="width:95%; max-width:900px; border: 1px solid var(--accent);">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<h3 style="margin:0; color:var(--accent);"><span data-i18n="modEditTitle">📝 INI File Editor:</span> <span id="editor-title" style="color:var(--text-main);"></span></h3>
<button onclick="closeEditorModal()" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--danger);"></button>
</div>
<div style="background:rgba(239, 68, 68, 0.1); border-left:4px solid var(--danger); padding:10px; margin-bottom:15px; font-size:0.85rem; font-weight:bold; border-radius: 8px;" data-i18n="warnEdit">
⚠️ WARNING: This editor directly manipulates remote node parameters.
</div>
<textarea id="config-textarea" spellcheck="false" style="width:100%; height:55vh; background:#0f172a; color:#10b981; font-family:'JetBrains Mono', monospace; font-size:0.95rem; padding:15px; border-radius:12px; border:1px solid #334155; resize:none; box-sizing:border-box; outline:none;"></textarea>
<div style="display:flex; justify-content:space-between; margin-top:20px; align-items:center;">
<span id="editor-status" style="font-size:0.9rem; font-weight:bold;"></span>
<button onclick="saveConfig()" class="btn-cmd" style="background:var(--danger); max-width: 250px;" data-i18n="btnSave">💾 SAVE & SEND</button>
</div>
</div>
</div>
<script>
// --- 1. TRANSLATION SYSTEM (i18n) ---
const i18n = {
it: {
themeLight: "☀️ LIGHT", themeDark: "🌙 DARK",
lastTransits: "Ultimi Transiti Radio (MMDVM)", loginTitle: "🔒 Login di Sistema",
thTime: "Ora", thRep: "Ripetitore", thMode: "Modo", thCall: "Nominativo", thDur: "Durata",
thUser: "Utente", thRole: "Ruolo", thNodes: "Nodi", thActs: "Azioni",
btnReqCfg: "🔄 RICHIEDI CONFIG", btnGlobal: "🚨 OVERRIDE GLOBALE", btnAdmin: "🛠️ ADMIN", btnPass: "🔑 PASS", btnLogout: "LOGOUT",
btnSvc: "⚙️ SVC", btnFile: "📂 FILE", btnBoot: "🔄 BOOT",
waitNet: "> Attesa rete...", waitData: "In attesa dei dati dal nodo...", forceUpdate: "🔄 FORZA AGGIORNAMENTO",
adminTitle: "🛠️ Gestione Utenti e Sistema", adminDBSync: "⚙️ Sincronizzazione Database", adminTime: "Orario Aggiornamento:", adminSave: "SALVA CONFIG", roleOp: "Operatore",
modSvcTitle: "⚙️ Demoni Sistema:", modFileTitle: "📂 File di Configurazione", modEditTitle: "📝 Editor INI:",
warnEdit: "⚠️ ATTENZIONE: Questo editor manipola direttamente i parametri del nodo remoto.",
btnEdit: "📝 MODIFICA", btnStart: "▶ START", btnRestart: "🔄 RESTART", btnStop: "🛑 STOP", btnSave: "💾 SALVA ED INVIA",
confOp: "Confermi l'operazione su ", confTgOn: "Vuoi ATTIVARE le notifiche Telegram per il nodo ", confTgOff: "Vuoi SILENZIARE le notifiche Telegram per il nodo ", confOvr: "Sei sicuro di voler sovrascrivere il file su ",
warnDaemon: "⚠️ SVC KO - CONTROLLA DEMONI ⚠️",
promptOvr: "OVERRIDE GLOBALE: Digita 'A' o 'B'", promptOvrConfirm: "Confermi l'invio a TUTTA la rete?"
},
en: {
themeLight: "☀️ LIGHT", themeDark: "🌙 DARK",
lastTransits: "Latest Radio Transits", loginTitle: "🔒 System Login",
thTime: "Time", thRep: "Repeater", thMode: "Mode", thCall: "Callsign", thDur: "Duration",
thUser: "User", thRole: "Role", thNodes: "Nodes", thActs: "Actions",
btnReqCfg: "🔄 REQ CONFIG", btnGlobal: "🚨 GLOBAL OVERRIDE", btnAdmin: "🛠️ ADMIN", btnPass: "🔑 PASS", btnLogout: "LOGOUT",
btnSvc: "⚙️ SVC", btnFile: "📂 FILE", btnBoot: "🔄 BOOT",
waitNet: "> Waiting network...", waitData: "Waiting for node data...", forceUpdate: "🔄 FORCE UPDATE",
adminTitle: "🛠️ User & System Management", adminDBSync: "⚙️ Database Sync Config", adminTime: "Daily Update Time:", adminSave: "SAVE CONFIG", roleOp: "Operator",
modSvcTitle: "⚙️ System Daemons:", modFileTitle: "📂 Config Files", modEditTitle: "📝 INI Editor:",
warnEdit: "⚠️ WARNING: This editor directly manipulates remote node parameters.",
btnEdit: "📝 EDIT", btnStart: "▶ START", btnRestart: "🔄 RESTART", btnStop: "🛑 STOP", btnSave: "💾 SAVE & SEND",
confOp: "Confirm operation on ", confTgOn: "ENABLE Telegram notifications for ", confTgOff: "MUTE Telegram notifications for ", confOvr: "Are you sure you want to overwrite file on ",
warnDaemon: "⚠️ SVC KO - CHECK DAEMONS ⚠️",
promptOvr: "GLOBAL OVERRIDE: Enter 'A' or 'B'", promptOvrConfirm: "Confirm sending to ENTIRE network?"
}
};
let currentLang = localStorage.getItem('lang') || 'en';
function t(key) { return i18n[currentLang][key] || key; }
function toggleLang() { currentLang = currentLang === 'it' ? 'en' : 'it'; localStorage.setItem('lang', currentLang); location.reload(); }
function applyTranslations() {
document.getElementById('lang-btn').innerText = currentLang === 'it' ? "🇬🇧 ENG" : "🇮🇹 ITA";
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (i18n[currentLang][key]) el.innerHTML = i18n[currentLang][key];
});
}
// --- GLOBAL VARIABLES & THEMES ---
let clients = [];
let isAuthenticated = sessionStorage.getItem('is_admin') === 'true';
let globalHealthData = {};
let editingUserId = null; // Memorizza l'ID dell'utente in fase di modifica
function toggleTheme() {
const isDark = document.body.classList.toggle('dark-mode');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
document.getElementById('theme-btn').innerText = isDark ? t('themeLight') : t('themeDark');
}
// --- API & COMMAND FUNCTIONS ---
async function sendCommand(clientId, type) {
if (!confirm(`${t('confOp')}${clientId.toUpperCase()}?`)) return;
try {
const res = await fetch('/api/command', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId, type }) });
const data = await res.json();
if(!data.success) { alert(data.error); }
refreshStates();
} catch (e) { console.error(e); }
}
function confirmSwitch(id, mode) { sendCommand(id, mode); }
function confirmReboot(id) { sendCommand(id, 'REBOOT'); }
async function sendTgCommand(clientId, comando) {
const msg = (comando === 'TG:ON') ? t('confTgOn') : t('confTgOff');
if (!confirm(`${msg}${clientId.toUpperCase()}?`)) return;
try {
const res = await fetch('/api/command', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: clientId, type: comando }) });
const data = await res.json();
if(!data.success) { alert(data.error); }
refreshStates();
} catch (e) { console.error(e); }
}
function sendGlobalUpdate() { fetch('/api/update_nodes', { method: 'POST' }).then(() => { alert("Request sent to nodes!"); }); }
// --- MAIN UI INIT ---
async function initUI() {
applyTranslations();
try {
const response = await fetch('/api/clients');
clients = await response.json();
const grid = document.getElementById('client-grid');
const authContainer = document.getElementById('auth-container');
if (localStorage.getItem('theme') === 'dark') {
document.body.classList.add('dark-mode');
document.getElementById('theme-btn').innerText = t('themeLight');
} else { document.getElementById('theme-btn').innerText = t('themeDark'); }
const role = sessionStorage.getItem('user_role');
const allowed = sessionStorage.getItem('allowed_nodes') || "";
if (isAuthenticated) {
let adminBtn = role === 'admin' ? `
<button onclick="sendGlobalUpdate()" class="auth-btn" style="background:#f59e0b; color:white;">${t('btnReqCfg')}</button>
<button onclick="triggerGlobalEmergency()" class="auth-btn" style="background:#ef4444; color:white;">${t('btnGlobal')}</button>
<button onclick="openAdmin()" class="auth-btn" style="background:var(--accent); color:white;">${t('btnAdmin')}</button>` : '';
authContainer.innerHTML = `${adminBtn}
<span style="font-weight:800; font-size:0.9rem; margin: 0 10px;">👤 ${sessionStorage.getItem('user_name').toUpperCase()}</span>
<button onclick="changeMyPassword()" class="auth-btn" style="background:#64748b; color:white;">${t('btnPass')}</button>
<button onclick="logout()" class="auth-btn" style="background:var(--text-main); color:var(--card-bg);">${t('btnLogout')}</button>`;
} else {
authContainer.innerHTML = `<button onclick="openLoginModal()" class="auth-btn" style="background:var(--primary); color:white; padding: 8px 25px;">LOGIN</button>`;
}
grid.innerHTML = clients.map(c => {
let canControl = (role === 'admin' || allowed === 'all' || allowed.includes(c.id));
let showReboot = (role === 'admin');
return `
<div class="card" id="card-${c.id}">
<div class="card-header">
<span class="client-name" title="${c.name}">${c.name}</span>
<span class="badge-id">ID: ${c.id.toUpperCase()}</span>
</div>
<div class="health-bar" id="health-${c.id}" style="display: none;">
<span>⚡ <span id="cpu-${c.id}">--</span>%</span>
<span>🌡️ <span id="temp-${c.id}">--</span>°C</span>
<span>🧠 <span id="ram-${c.id}">--</span>%</span>
<span>💾 <span id="disk-${c.id}">--</span>%</span>
</div>
<div class="status-display" id="status-${c.id}">Offline</div>
<div id="ts-container-${c.id}" class="ts-container">
<div class="dmr-info" id="dmr-ts1-${c.id}">TS1: ...</div>
<div class="dmr-info" id="dmr-ts2-${c.id}">TS2: ...</div>
</div>
<div class="dmr-info" id="dmr-alt-${c.id}" style="display: none; text-align: center; font-weight: 800;"></div>
<div class="terminal-log" id="sys-log-${c.id}">${t('waitNet')}</div>
<div id="svc-warn-${c.id}" class="blink" style="display: none; text-align: center; margin-bottom: 15px; border-radius: 12px; padding: 10px;">
<span style="font-size: 0.85rem;" data-i18n="warnDaemon">${t('warnDaemon')}</span>
</div>
<div class="actions" style="${(isAuthenticated && canControl) ? 'display:flex;' : 'display:none'}">
<button id="btn-profA-${c.id}" class="btn-cmd" style="background: var(--accent);" onclick="confirmSwitch('${c.id}', 'A')">PROFILO A</button>
<button id="btn-profB-${c.id}" class="btn-cmd" style="background: #eab308;" onclick="confirmSwitch('${c.id}', 'B')">PROFILO B</button>
<div style="width: 100%; display: flex; gap: 10px;">
<button class="btn-cmd" style="background: var(--success);" onclick="sendTgCommand('${c.id}', 'TG:ON')">🔔 Telegram ON</button>
<button class="btn-cmd" style="background: var(--text-muted);" onclick="sendTgCommand('${c.id}', 'TG:OFF')">🔇 Telegram OFF</button>
</div>
${showReboot ? `
<button id="btn-svc-${c.id}" class="btn-cmd" style="background: #334155;" onclick="openServicesModal('${c.id}')">${t('btnSvc')}</button>
<button class="btn-cmd" style="background: #8e44ad;" onclick="openConfigsModal('${c.id}')">${t('btnFile')}</button>
<button class="btn-cmd btn-reboot" style="background: var(--danger);" onclick="confirmReboot('${c.id}')">${t('btnBoot')}</button>
` : ''}
</div>
</div>`;
}).join('');
refreshStates(); refreshLogs();
setInterval(() => { refreshStates(); refreshLogs(); }, 3000);
} catch (e) { console.error(e); }
}
// --- LOGIN MODAL LOGIC ---
function openLoginModal() {
document.getElementById('login-modal').style.display = 'flex';
setTimeout(() => document.getElementById('modal-username').focus(), 100);
}
function closeLoginModal() {
document.getElementById('login-modal').style.display = 'none';
document.getElementById('modal-username').value = ''; document.getElementById('modal-password').value = '';
}
function handleLoginEnter(e) { if (e.key === 'Enter') performLogin(); }
async function performLogin() {
const user = document.getElementById('modal-username').value; const pass = document.getElementById('modal-password').value;
if (!user || !pass) return;
const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user, pass }) });
const data = await res.json();
if (res.ok) { sessionStorage.setItem('is_admin', 'true'); sessionStorage.setItem('user_name', user); sessionStorage.setItem('user_role', data.role); sessionStorage.setItem('allowed_nodes', data.allowed_nodes); location.reload(); } else { alert("Login Failed"); }
}
function logout() { sessionStorage.clear(); location.reload(); }
async function refreshStates() {
try {
const res = await fetch('/api/states');
const data = await res.json();
clients.forEach(c => {
const statusDiv = document.getElementById(`status-${c.id}`);
const cardDiv = document.getElementById(`card-${c.id}`);
if (!statusDiv || !cardDiv) return;
let stateValue = data.states[c.id.toLowerCase()] || data.states[c.id] || "OFFLINE";
stateValue = String(stateValue).trim().toUpperCase();
statusDiv.innerText = stateValue;
const isOnline = !stateValue.includes("OFF") && stateValue !== "";
let telemetryObj = data.telemetry[c.id.toLowerCase()] || data.telemetry[c.id] || { ts1: "...", ts2: "...", alt: "", idle: true };
if (typeof telemetryObj === 'string') telemetryObj = { ts1: telemetryObj, ts2: "...", alt: "", idle: true };
const tsContainer = document.getElementById(`ts-container-${c.id}`);
const ts1Div = document.getElementById(`dmr-ts1-${c.id}`);
const ts2Div = document.getElementById(`dmr-ts2-${c.id}`);
const altDiv = document.getElementById(`dmr-alt-${c.id}`);
let isTx = false;
let activeModeColor = "var(--success)";
let isIdle = telemetryObj.idle === true;
if (telemetryObj.alt && telemetryObj.alt !== "") {
if (tsContainer) tsContainer.style.display = "none";
if (altDiv) {
altDiv.style.display = "block"; altDiv.innerText = telemetryObj.alt;
let altText = telemetryObj.alt.toUpperCase();
if (altText.includes("NXDN")) activeModeColor = "#10b981"; else if (altText.includes("YSF")) activeModeColor = "#8b5cf6"; else if (altText.includes("D-STAR")) activeModeColor = "#06b6d4"; else if (altText.includes("P25")) activeModeColor = "#f59e0b";
isTx = altText.includes("🟢") || altText.includes("🟣") || altText.includes("🔵") || altText.includes("🟠");
altDiv.style.setProperty('color', activeModeColor, 'important');
altDiv.style.setProperty('border-left', `4px solid ${activeModeColor}`, 'important');
if (isTx) { altDiv.classList.add('blink'); } else { altDiv.classList.remove('blink'); }
}
} else {
if (altDiv) altDiv.style.display = "none";
if (tsContainer) tsContainer.style.display = "flex";
let netObj = data.networks && data.networks[c.id.toLowerCase()] ? data.networks[c.id.toLowerCase()] : {ts1: "", ts2: ""};
activeModeColor = "var(--success)";
if (ts1Div && ts2Div) {
[ts1Div, ts2Div].forEach((div, idx) => {
const val = idx === 0 ? telemetryObj.ts1 : telemetryObj.ts2;
const netName = idx === 0 ? netObj.ts1 : netObj.ts2;
const baseLabel = `TS${idx + 1}`;
const fullLabel = netName ? `${baseLabel} [${netName}]` : baseLabel;
div.innerText = `${fullLabel}: ${val}`;
if (val.includes("🎙️")) {
isTx = true;
div.classList.add('blink');
} else {
div.classList.remove('blink');
div.style.setProperty('color', 'var(--text-main)', 'important');
div.style.setProperty('border-left-color', 'var(--primary)', 'important');
div.style.setProperty('background', 'rgba(59, 130, 246, 0.1)', 'important');
}
});
}
}
let healthObj = data.health && data.health[c.id.toLowerCase()];
const healthContainer = document.getElementById(`health-${c.id}`);
const cpuSpan = document.getElementById(`cpu-${c.id}`); const tempSpan = document.getElementById(`temp-${c.id}`); const ramSpan = document.getElementById(`ram-${c.id}`); const diskSpan = document.getElementById(`disk-${c.id}`);
if (healthObj && isOnline) {
healthContainer.style.display = 'flex';
let cpu = healthObj.cpu; cpuSpan.innerText = cpu; cpuSpan.style.color = cpu < 50 ? 'var(--success)' : (cpu < 80 ? '#f59e0b' : 'var(--danger)');
let t = healthObj.temp; tempSpan.innerText = t; tempSpan.style.color = t < 55 ? 'var(--success)' : (t < 70 ? '#f59e0b' : 'var(--danger)');
ramSpan.innerText = healthObj.ram; let d = healthObj.disk; diskSpan.innerText = d; diskSpan.style.color = d < 85 ? 'inherit' : (d < 95 ? '#f59e0b' : 'var(--danger)');
let profA = (healthObj.profiles && healthObj.profiles.A) ? healthObj.profiles.A : "PROFILO A"; let profB = (healthObj.profiles && healthObj.profiles.B) ? healthObj.profiles.B : "PROFILO B";
const btnA = document.getElementById(`btn-profA-${c.id}`); const btnB = document.getElementById(`btn-profB-${c.id}`);
if (btnA && btnA.innerText !== profA) btnA.innerText = profA; if (btnB && btnB.innerText !== profB) btnB.innerText = profB;
} else { if (healthContainer) healthContainer.style.display = 'none'; }
globalHealthData[c.id.toLowerCase()] = healthObj;
let hasOfflineService = false;
if (healthObj && healthObj.processes) {
for (const [svcName, svcStatus] of Object.entries(healthObj.processes)) { if (svcStatus.toLowerCase() !== 'online') { hasOfflineService = true; break; } }
}
const warnBadge = document.getElementById(`svc-warn-${c.id}`); const btnSvc = document.getElementById(`btn-svc-${c.id}`);
if (warnBadge) warnBadge.style.display = hasOfflineService ? 'block' : 'none';
if (btnSvc) {
if (hasOfflineService) { btnSvc.style.background = 'var(--danger)'; btnSvc.classList.add('blink'); btnSvc.innerHTML = '⚠️ DEMONE KO'; }
else { btnSvc.style.background = '#334155'; btnSvc.classList.remove('blink'); btnSvc.innerHTML = t('btnSvc'); }
}
if (isOnline) {
cardDiv.classList.add('online'); statusDiv.classList.remove('status-offline');
cardDiv.style.opacity = "1"; cardDiv.style.filter = "none";
let targetBorderColor = activeModeColor;
if (isTx) { targetBorderColor = (telemetryObj.alt === "") ? "var(--danger)" : activeModeColor; } else if (isIdle) { targetBorderColor = "var(--border-color)"; }
cardDiv.style.setProperty('border-top-color', targetBorderColor, 'important');
if(isTx) cardDiv.style.boxShadow = `0 0 20px rgba(${targetBorderColor === 'var(--danger)' ? '239,68,68' : '16,185,129'}, 0.4)`;
} else {
cardDiv.classList.remove('online'); statusDiv.classList.add('status-offline');
cardDiv.style.opacity = "0.7"; cardDiv.style.filter = "grayscale(80%)";
cardDiv.style.setProperty('border-top-color', '#64748b', 'important');
}
});
document.getElementById('last-update').innerText = "Sync: " + new Date().toLocaleTimeString();
} catch (e) { console.error(e); }
}
async function refreshLogs() {
try {
const res = await fetch('/api/logs'); const logs = await res.json();
const tbody = document.getElementById('log-body'); let tableHTML = ""; let networkLogs = {};
logs.forEach(row => {
const time = row[0] ? row[0].split(' ')[1] : "--:--"; const clientId = row[1]; const protocol = row[2] || "DMR"; const source = row[3] || "---"; const target = row[4] || "---"; const rawSlot = row[5];
const slotDisplay = protocol === "DMR" ? `TS${rawSlot}` : "--";
let protoColor = "#3b82f6"; if (protocol === "NXDN") protoColor = "#10b981"; else if (protocol === "YSF") protoColor = "#8b5cf6"; else if (protocol === "D-STAR") protoColor = "#06b6d4"; else if (protocol === "P25") protoColor = "#f59e0b";
if (source.includes("🌐")) {
if (!networkLogs[clientId]) networkLogs[clientId] = "";
networkLogs[clientId] += `<div style="border-bottom:1px solid rgba(255,255,255,0.1); padding:4px 0;"><span style="color:#64748b;">[${time}]</span> <span style="color:${protoColor}; font-weight:bold;">[${protocol}]</span> <span style="color:#cbd5e1;">${source.replace("🌐", "").trim()}</span></div>`;
} else {
tableHTML += `<tr><td>${time}</td><td><b style="color:var(--text-main);">${clientId.toUpperCase()}</b></td><td><span style="background:${protoColor}; color:white; padding:3px 8px; border-radius:8px; font-size:0.7rem; font-weight:800;">${protocol}</span></td><td style="font-family:'JetBrains Mono', monospace; font-weight:bold; text-align:center;">${slotDisplay}</td><td style="color:var(--accent); font-weight:800;">${source}</td><td style="font-family:'JetBrains Mono', monospace;">${target.trim()}</td><td>${row[6]}s</td><td>${row[7]}%</td></tr>`;
}
});
tbody.innerHTML = tableHTML;
clients.forEach(c => { const localDiv = document.getElementById(`sys-log-${c.id}`); if (localDiv && networkLogs[c.id]) { localDiv.innerHTML = networkLogs[c.id]; } });
} catch (e) { console.error(e); }
}
// --- GESTIONE UTENTI (ADD & EDIT) ---
async function openAdmin() { document.getElementById('admin-modal').style.display = 'flex'; loadUsers(); loadSettings(); cancelEdit(); }
function closeAdmin() { document.getElementById('admin-modal').style.display = 'none'; cancelEdit(); }
async function loadUsers() {
const res = await fetch('/api/users'); const users = await res.json();
document.getElementById('users-table-body').innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td style="font-weight:bold;">${u.username}</td>
<td><span style="background:rgba(128,128,128,0.2); padding:2px 8px; border-radius:6px; font-size:0.8rem;">${u.role.toUpperCase()}</span></td>
<td>${u.allowed_nodes}</td>
<td style="text-align:center;">
<button onclick="startEditUser(${u.id}, '${u.username}', '${u.role}', '${u.allowed_nodes}')" class="btn-cmd" style="background:var(--accent); padding:6px; width:auto; display:inline-block; margin-right:5px;" title="Modifica Utente">✏️</button>
<button onclick="deleteUser(${u.id})" class="btn-cmd" style="background:var(--danger); padding:6px; width:auto; display:inline-block;" title="Elimina Utente">🗑️</button>
</td>
</tr>`).join('');
}
function startEditUser(id, username, role, nodes) {
editingUserId = id;
document.getElementById('new-user').value = username;
document.getElementById('new-user').disabled = true; // Impedisce di cambiare il nome
document.getElementById('new-pass').value = "";
document.getElementById('new-pass').placeholder = "Nuova pass (vuoto per non cambiare)";
document.getElementById('new-role').value = role;
document.getElementById('new-nodes').value = nodes;
document.getElementById('btn-user-submit').innerText = "💾 SALVA";
document.getElementById('btn-user-submit').style.background = "var(--accent)";
document.getElementById('btn-user-cancel').style.display = "block";
}
function cancelEdit() {
editingUserId = null;
document.getElementById('new-user').value = "";
document.getElementById('new-user').disabled = false;
document.getElementById('new-pass').value = "";
document.getElementById('new-pass').placeholder = "Password";
document.getElementById('new-role').value = "operator";
document.getElementById('new-nodes').value = "";
document.getElementById('btn-user-submit').innerText = "+ ADD";
document.getElementById('btn-user-submit').style.background = "var(--success)";
document.getElementById('btn-user-cancel').style.display = "none";
}
async function submitUser() {
const username = document.getElementById('new-user').value;
const password = document.getElementById('new-pass').value;
const role = document.getElementById('new-role').value;
let allowed = document.getElementById('new-nodes').value;
if (!username) return alert("Username mancante");
const payload = { username, role, allowed_nodes: allowed || 'all' };
if (password) payload.password = password;
if (editingUserId) {
// EDIT MODE
const res = await fetch(`/api/users/${editingUserId}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) });
const data = await res.json();
if (data.success) { cancelEdit(); loadUsers(); } else alert(data.error);
} else {
// ADD MODE
if (!password) return alert("Password obbligatoria per nuovo utente");
const res = await fetch('/api/users', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) });
const data = await res.json();
if (data.success) { cancelEdit(); loadUsers(); } else alert(data.error);
}
}
async function deleteUser(id) { if (confirm("Delete user?")) { await fetch(`/api/users/${id}`, {method: 'DELETE'}); loadUsers(); cancelEdit(); } }
// --- EMERGENCY & SETTINGS ---
async function triggerGlobalEmergency() { let action = prompt(`${t('promptOvr')}`); if (action && confirm(t('promptOvrConfirm'))) { const res = await fetch('/api/global_command', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({type: action.toUpperCase()}) }); const d = await res.json(); if(d.success) alert("Command sent!"); else alert(d.error); } }
async function changeMyPassword() { const p = prompt("New password:"); if (p) { const res = await fetch('/api/change_password', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username: sessionStorage.getItem('user_name'), new_password: p}) }); if ((await res.json()).success) { alert("Password updated!"); logout(); } } }
async function loadSettings() { try { const res = await fetch('/api/config'); const data = await res.json(); document.getElementById('update-time-input').value = data.update_schedule; document.getElementById('url-dmr-input').value = data.url_dmr; document.getElementById('url-nxdn-input').value = data.url_nxdn; } catch (e) { console.error(e); } }
async function saveSettings() { const payload = { update_schedule: document.getElementById('update-time-input').value, url_dmr: document.getElementById('url-dmr-input').value, url_nxdn: document.getElementById('url-nxdn-input').value }; try { const res = await fetch('/api/config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); const data = await res.json(); if (data.success) alert("Configuration saved!"); else alert("Error saving"); } catch (e) { console.error(e); } }
function openConfigsModal(clientId) {
const data = globalHealthData[clientId.toLowerCase()];
const listDiv = document.getElementById('configs-list');
let listaFile = data ? (data.config_files || data.files || []) : [];
if (listaFile.length === 0) {
listDiv.innerHTML = `<div style="text-align:center; padding:20px;"><p style="color:var(--text-muted); margin-bottom:15px;">${t('waitData')}</p><button onclick="sendGlobalUpdate(); closeConfigsModal();" class="btn-cmd" style="background:var(--accent);">${t('forceUpdate')}</button></div>`;
} else {
listDiv.innerHTML = listaFile.map(filename => `
<div style="display:flex; justify-content:space-between; align-items:center; padding:12px; background:rgba(128,128,128,0.1); border-radius:12px; border:1px solid var(--border-color); margin-bottom:8px;">
<span style="font-family:'JetBrains Mono',monospace; font-weight:bold;">${filename.toUpperCase()}.ini</span>
<button onclick="closeConfigsModal(); openEditorModal('${clientId}', '${filename}')" class="btn-cmd" style="background:#8e44ad; max-width:120px;">${t('btnEdit')}</button>
</div>`).join('');
}
document.getElementById('configs-modal').style.display = 'flex';
}
function closeConfigsModal() { document.getElementById('configs-modal').style.display = 'none'; }
function openServicesModal(clientId) { document.getElementById('svc-modal-title').innerText = clientId.toUpperCase(); document.getElementById('services-modal').style.display = 'flex'; renderServicesList(clientId); }
function closeServicesModal() { document.getElementById('services-modal').style.display = 'none'; }
function renderServicesList(clientId) {
const data = globalHealthData[clientId.toLowerCase()]; const listDiv = document.getElementById('services-list');
if (!data || !data.processes || Object.keys(data.processes).length === 0) { listDiv.innerHTML = "<p style='text-align:center; color:var(--text-muted);'>No service data available.</p>"; return; }
let html = "";
for (const [name, status] of Object.entries(data.processes)) {
const isOnline = status.toLowerCase() === 'online'; const statusColor = isOnline ? 'var(--success)' : 'var(--danger)'; const statusAnim = isOnline ? '' : 'animation: pulse-glow 1.5s infinite;';
html += `
<div style="display:flex; justify-content:space-between; align-items:center; padding:15px; border-bottom:1px solid var(--border-color); background:rgba(128,128,128,0.05); border-radius:12px; margin-bottom:8px; border-left: 5px solid ${statusColor};">
<div>
<strong style="font-size:1.1rem; display:block; font-family:'JetBrains Mono',monospace;">${name}</strong>
<span style="color:${statusColor}; font-size:0.8rem; font-weight:800; ${statusAnim}">${status.toUpperCase()}</span>
</div>
<div style="display:flex; gap:8px;">
${!isOnline ? `<button onclick="controlService('${clientId}', '${name}', 'restart')" class="btn-cmd" style="background:var(--success);">▶</button>` : ''}
<button onclick="controlService('${clientId}', '${name}', 'restart')" class="btn-cmd" style="background:#f59e0b;">🔄</button>
<button onclick="openEditorModal('${clientId}', '${name}')" class="btn-cmd" style="background:#8e44ad;">📝</button>
${isOnline ? `<button onclick="controlService('${clientId}', '${name}', 'stop')" class="btn-cmd" style="background:var(--danger);">🛑</button>` : ''}
</div>
</div>`;
}
listDiv.innerHTML = html;
}
async function controlService(clientId, service, action) { if (!confirm(`${t('confOp')}${service}?`)) return; try { const res = await fetch('/api/service_control', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ clientId, service, action }) }); const data = await res.json(); if(!data.success) alert("Error: " + data.error); } catch(e) { console.error(e); } }
let currentEditClient = ""; let currentEditService = "";
async function openEditorModal(clientId, service) {
currentEditClient = clientId; currentEditService = service;
document.getElementById('editor-title').innerText = `${service.toUpperCase()} @ ${clientId.toUpperCase()}`;
document.getElementById('config-textarea').value = "Connecting to server..."; document.getElementById('editor-status').innerText = "";
document.getElementById('editor-modal').style.display = 'flex';
try { const res = await fetch(`/api/config_file/${clientId}/${service}`); const data = await res.json(); if (data.success) document.getElementById('config-textarea').value = data.data.raw_text || "Empty file"; else document.getElementById('config-textarea').value = "ERROR:\n" + data.error; } catch(e) { document.getElementById('config-textarea').value = "Connection error."; }
}
function closeEditorModal() { document.getElementById('editor-modal').style.display = 'none'; }
async function saveConfig() {
const textValue = document.getElementById('config-textarea').value; const statusSpan = document.getElementById('editor-status');
if (!confirm(`${t('confOvr')}${currentEditClient.toUpperCase()}?`)) return;
statusSpan.innerText = "Sending..."; statusSpan.style.color = "var(--success)";
try {
const res = await fetch('/api/config_file', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ clientId: currentEditClient, service: currentEditService, config_data: { "raw_text": textValue } }) });
const data = await res.json();
if (data.success) { alert("File updated!"); closeEditorModal(); } else { alert("Server error: " + data.error); statusSpan.innerText = "❌ Error"; }
} catch(e) { alert("Network error."); statusSpan.innerText = "❌ Network Error"; }
}
initUI();
</script>
</body>
</html>