Eigenständiges token-geschütztes Verwaltungs-WebGUI (Admin WebGUI) hinzugefügt

- Unabhängige Kestrel-Instanz auf Port 5001 (vollständig getrennt vom Analyse-Webserver)
- Admin API Endpunkte ohne Swagger-Exposition
- Token-Gatekeeper mit sofortigem Login-Overlay bei unautorisiertem Zugriff
- Live-Terminal via SSE-Log-Streaming und Log-Download-Übersicht
- Steuerung von Workern, Analyse-Webserver, Wartungsaktionen und Einstellungen
- Nahtlose Integration in Avalonia Desktop-Shell und Linux Headless-Modus
- Unit-Tests für Admin-Filter, Optionen und Log-Puffer
This commit is contained in:
Richard
2026-09-07 21:09:03 +02:00
parent 301249f3b8
commit 1131d257cf
19 changed files with 3566 additions and 16 deletions
@@ -0,0 +1,734 @@
/**
* Predictalytics Administration WebGUI JavaScript
*/
const STORAGE_KEY = 'predictalytics_admin_token';
let currentToken = null;
let statusPollInterval = null;
let terminalEventSource = null;
let allLogs = [];
let maxLogEntries = 600;
// ─── INITIALISIERUNG & AUTHENTIFIZIERUNG ───
document.addEventListener('DOMContentLoaded', () => {
setupNavigation();
// Prüfe, ob bereits ein Token in sessionStorage hinterlegt ist
const savedToken = sessionStorage.getItem(STORAGE_KEY) || localStorage.getItem(STORAGE_KEY);
if (savedToken) {
verifyAndUnlock(savedToken);
} else {
showLogin();
}
});
function showLogin(errorMessage = null) {
document.getElementById('login-overlay').style.display = 'flex';
document.getElementById('app-container').style.display = 'none';
if (statusPollInterval) clearInterval(statusPollInterval);
if (terminalEventSource) {
terminalEventSource.close();
terminalEventSource = null;
}
const errBox = document.getElementById('login-error');
if (errorMessage) {
errBox.textContent = errorMessage;
errBox.style.display = 'block';
} else {
errBox.style.display = 'none';
}
const tokenInput = document.getElementById('admin-token-input');
tokenInput.value = '';
tokenInput.focus();
}
async function handleLogin(e) {
e.preventDefault();
const tokenInput = document.getElementById('admin-token-input');
const token = tokenInput.value.trim();
if (!token) return;
const btn = document.getElementById('login-btn');
btn.disabled = true;
btn.textContent = 'Prüfe...';
try {
const response = await fetch('/api/admin/auth/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Admin-Token': token
},
body: JSON.stringify({ token })
});
if (response.ok) {
currentToken = token;
sessionStorage.setItem(STORAGE_KEY, token);
unlockApp();
} else {
const data = await response.json().catch(() => ({}));
showLogin(data.message || 'Ungültiger Admin-Token. Zugriff verweigert.');
}
} catch (err) {
showLogin('Verbindungsfehler zum Admin-Server: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Entsperren & Anmelden';
}
}
async function verifyAndUnlock(token) {
try {
const response = await fetch('/api/admin/auth/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Admin-Token': token
},
body: JSON.stringify({ token })
});
if (response.ok) {
currentToken = token;
unlockApp();
} else {
sessionStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY);
showLogin('Sitzung abgelaufen oder Token ungültig.');
}
} catch (err) {
showLogin('Server nicht erreichbar.');
}
}
function unlockApp() {
document.getElementById('login-overlay').style.display = 'none';
document.getElementById('app-container').style.display = 'flex';
// Status sofort laden und Polling starten
refreshStatus();
statusPollInterval = setInterval(refreshStatus, 3000);
// Initiales Terminal-Streaming starten
connectTerminalStream();
// Log-Dateien & Settings vorbereiten
loadLogFiles();
loadSettings();
loadLicenseInfo();
}
function handleLogout() {
currentToken = null;
sessionStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY);
showLogin('Erfolgreich abgemeldet.');
}
// ─── API HELPER ───
async function api(endpoint, options = {}) {
if (!options.headers) options.headers = {};
if (currentToken) {
options.headers['X-Admin-Token'] = currentToken;
}
try {
const res = await fetch(endpoint, options);
if (res.status === 401) {
handleLogout();
throw new Error('Unauthorized');
}
return res;
} catch (err) {
console.error('API Error (' + endpoint + '):', err);
throw err;
}
}
// ─── STATUS & STEUERUNG ───
let lastStatus = {};
async function refreshStatus() {
try {
const res = await api('/api/admin/status');
if (!res.ok) return;
const data = await res.json();
lastStatus = data;
// Topbar Status-Pills
const workersPill = document.getElementById('status-workers-pill');
const workersText = document.getElementById('status-workers-text');
const btnWorkers = document.getElementById('btn-toggle-workers');
const dashBtnWorkers = document.getElementById('dash-btn-workers');
const dashWorkersState = document.getElementById('dash-workers-state');
if (data.workersRunning) {
workersPill.className = 'status-pill status-running';
workersText.textContent = 'Workers: Aktiv';
btnWorkers.textContent = '⏹ Stop Server';
dashBtnWorkers.textContent = 'Worker stoppen';
dashBtnWorkers.className = 'btn btn-sm btn-secondary';
dashWorkersState.textContent = 'Läuft';
dashWorkersState.style.color = '#10b981';
} else {
workersPill.className = 'status-pill status-stopped';
workersText.textContent = 'Workers: Gestoppt';
btnWorkers.textContent = '▶ Start Server';
dashBtnWorkers.textContent = 'Worker starten';
dashBtnWorkers.className = 'btn btn-sm btn-primary';
dashWorkersState.textContent = 'Gestoppt';
dashWorkersState.style.color = '#94a3b8';
}
// Webserver Status
const webPill = document.getElementById('status-web-pill');
const webText = document.getElementById('status-web-text');
const btnWeb = document.getElementById('btn-toggle-webserver');
const dashBtnWeb = document.getElementById('dash-btn-web');
const dashWebState = document.getElementById('dash-web-state');
const dashWebUrl = document.getElementById('dash-web-url');
const dashLinkWeb = document.getElementById('dash-link-web');
if (data.webServerRunning) {
webPill.className = 'status-pill status-running';
webText.textContent = 'Webserver :' + data.webServerPort;
btnWeb.textContent = '⏹ Stop Webserver';
dashBtnWeb.textContent = 'Webserver stoppen';
dashBtnWeb.className = 'btn btn-sm btn-secondary';
dashWebState.textContent = 'Läuft :' + data.webServerPort;
dashWebState.style.color = '#10b981';
dashWebUrl.textContent = data.webServerUrl;
dashLinkWeb.href = data.webServerUrl;
dashLinkWeb.style.display = 'inline-flex';
} else {
webPill.className = 'status-pill status-stopped';
webText.textContent = 'Webserver: Gestoppt';
btnWeb.textContent = '▶ Start Webserver';
dashBtnWeb.textContent = 'Webserver starten';
dashBtnWeb.className = 'btn btn-sm btn-primary';
dashWebState.textContent = 'Gestoppt';
dashWebState.style.color = '#94a3b8';
dashWebUrl.textContent = 'Port: ' + data.webServerPort;
dashLinkWeb.style.display = 'none';
}
// DB Size
const dbText = document.getElementById('status-dbsize-text');
const dashDbText = document.getElementById('dash-db-size');
const dbFormatted = data.dbSizeMb != null ? Number(data.dbSizeMb).toFixed(2) + ' MB' : '—';
dbText.textContent = dbFormatted;
dashDbText.textContent = dbFormatted;
// Busy Indicator
const busyIndicator = document.getElementById('busy-indicator');
const busyText = document.getElementById('busy-operation-text');
if (data.isBusy) {
busyIndicator.style.display = 'flex';
busyText.textContent = data.busyOperation || 'Wird ausgeführt...';
} else {
busyIndicator.style.display = 'none';
}
// Version & Uptime
document.getElementById('header-version').textContent = 'v' + data.appVersion;
document.getElementById('sys-memory').textContent = data.memoryMb + ' MB';
document.getElementById('dash-memory').textContent = data.memoryMb + ' MB';
document.getElementById('sys-uptime').textContent = data.uptimeText;
document.getElementById('dash-uptime').textContent = 'Uptime: ' + data.uptimeText;
} catch (e) {
console.warn('Status poll failed:', e);
}
}
async function toggleWorkers() {
const isRunning = lastStatus.workersRunning;
const action = isRunning ? 'stop' : 'start';
try {
const res = await api('/api/admin/workers/' + action, { method: 'POST' });
const data = await res.json();
showToast(data.message || 'Worker-Status geändert.', res.ok ? 'success' : 'error');
refreshStatus();
} catch (e) {
showToast('Aktion fehlgeschlagen: ' + e.message, 'error');
}
}
async function toggleWebserver() {
const isRunning = lastStatus.webServerRunning;
const action = isRunning ? 'stop' : 'start';
try {
const res = await api('/api/admin/webserver/' + action, { method: 'POST' });
const data = await res.json();
showToast(data.message || 'Webserver-Status geändert.', res.ok ? 'success' : 'error');
refreshStatus();
} catch (e) {
showToast('Aktion fehlgeschlagen: ' + e.message, 'error');
}
}
// ─── WARTUNGSAKTIONEN ───
async function triggerMarketSync() {
if (lastStatus.workersRunning) {
showToast('Markt-Sync kann nicht laufen, während die Worker aktiv sind. Bitte zuerst Worker stoppen.', 'warning');
return;
}
showToast('Manueller Markt-Sync gestartet...', 'success');
refreshStatus();
try {
const res = await api('/api/admin/maintenance/sync-markets', { method: 'POST' });
const data = await res.json();
if (res.ok) {
showToast(data.message || 'Markt-Sync erfolgreich abgeschlossen.', 'success');
} else {
showToast(data.error || 'Markt-Sync fehlgeschlagen.', 'error');
}
} catch (e) {
showToast('Fehler: ' + e.message, 'error');
} finally {
refreshStatus();
}
}
async function triggerDbUpdate() {
if (lastStatus.workersRunning) {
showToast('Datenbank-Update kann nicht laufen, während die Worker aktiv sind.', 'warning');
return;
}
showToast('Datenbank-Update gestartet...', 'success');
refreshStatus();
try {
const res = await api('/api/admin/maintenance/update-database', { method: 'POST' });
const data = await res.json();
if (res.ok) {
showToast(data.message || 'Datenbank erfolgreich aktualisiert.', 'success');
} else {
showToast(data.error || 'Datenbank-Update fehlgeschlagen.', 'error');
}
} catch (e) {
showToast('Fehler: ' + e.message, 'error');
} finally {
refreshStatus();
}
}
function openRecalculateModal() {
if (lastStatus.workersRunning) {
showToast('Neuberechnung kann nicht laufen, während die Worker aktiv sind. Bitte zuerst Worker stoppen.', 'warning');
return;
}
document.getElementById('confirm-modal').style.display = 'flex';
}
function closeRecalculateModal() {
document.getElementById('confirm-modal').style.display = 'none';
}
async function executeRecalculateAll() {
closeRecalculateModal();
showToast('Neuberechnung aller Trader eingeleitet...', 'warning');
refreshStatus();
try {
const res = await api('/api/admin/maintenance/recalculate-traders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirm: true })
});
const data = await res.json();
if (res.ok) {
showToast('Neuberechnung erfolgreich eingeleitet: ' + (data.summary ? data.summary.split('\n')[0] : 'Fertig'), 'success');
} else {
showToast(data.error || 'Neuberechnung fehlgeschlagen.', 'error');
}
} catch (e) {
showToast('Fehler: ' + e.message, 'error');
} finally {
refreshStatus();
}
}
// ─── LIVE-TERMINAL (SSE) ───
function connectTerminalStream() {
if (terminalEventSource) {
terminalEventSource.close();
}
const url = '/api/admin/logs/stream?token=' + encodeURIComponent(currentToken);
terminalEventSource = new EventSource(url);
terminalEventSource.onmessage = (e) => {
try {
const entry = JSON.parse(e.data);
if (entry && entry.Message) {
appendLogLine(entry);
}
} catch (err) {
// Ignoriere Ping / Non-JSON
}
};
terminalEventSource.onerror = (e) => {
console.warn('Terminal EventSource error / disconnected.');
};
}
function reconnectTerminalStream() {
appendLogLine({
LevelText: 'INF',
Message: 'Verbinde neu mit Live-Log-Stream...',
Timestamp: new Date().toISOString()
});
connectTerminalStream();
}
function appendLogLine(entry) {
allLogs.push(entry);
if (allLogs.length > maxLogEntries) {
allLogs.shift();
}
renderLogLine(entry);
}
function renderLogLine(entry) {
const filter = document.getElementById('term-level-filter').value;
if (!matchesFilter(entry.LevelText, filter)) return;
const termBody = document.getElementById('terminal-body');
const div = document.createElement('div');
const lvl = (entry.LevelText || 'INF').toLowerCase();
div.className = 'log-line log-' + lvl;
const ts = entry.Timestamp ? new Date(entry.Timestamp).toLocaleTimeString('de-DE') : '';
let text = `[${ts}] [${entry.LevelText || 'INF'}] ${entry.Message}`;
if (entry.Exception) {
text += `\n ⚠ ${entry.Exception}`;
}
div.textContent = text;
termBody.appendChild(div);
// Auf 500 Zeilen begrenzen
while (termBody.children.length > 500) {
termBody.removeChild(termBody.firstChild);
}
const autoscroll = document.getElementById('term-autoscroll').checked;
if (autoscroll) {
termBody.scrollTop = termBody.scrollHeight;
}
}
function filterTerminalLogs() {
const termBody = document.getElementById('terminal-body');
termBody.innerHTML = '';
const filter = document.getElementById('term-level-filter').value;
allLogs.forEach(entry => {
if (matchesFilter(entry.LevelText, filter)) {
renderLogLine(entry);
}
});
}
function matchesFilter(levelText, filter) {
if (filter === 'ALL') return true;
if (filter === 'INF') return ['INF', 'WRN', 'ERR', 'FTL'].includes(levelText);
if (filter === 'WRN') return ['WRN', 'ERR', 'FTL'].includes(levelText);
if (filter === 'ERR') return ['ERR', 'FTL'].includes(levelText);
return true;
}
function clearTerminal() {
allLogs = [];
document.getElementById('terminal-body').innerHTML = '<div class="log-line log-inf"><span>Terminal geleert.</span></div>';
}
// ─── LOG-DATEIEN ───
async function loadLogFiles() {
const tbody = document.getElementById('logfiles-table-body');
tbody.innerHTML = '<tr><td colspan="5" class="loading-cell">Lade Log-Dateien...</td></tr>';
try {
const res = await api('/api/admin/logs/files');
if (!res.ok) throw new Error('Fehler beim Laden');
const files = await res.json();
if (!files || files.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="loading-cell">Keine Log-Dateien gefunden.</td></tr>';
return;
}
tbody.innerHTML = '';
files.forEach(f => {
const tr = document.createElement('tr');
const dateStr = f.modifiedUtc ? new Date(f.modifiedUtc).toLocaleString('de-DE') : '—';
const dlUrl = `/api/admin/logs/files/download?file=${encodeURIComponent(f.relativePath)}&token=${encodeURIComponent(currentToken)}`;
tr.innerHTML = `
<td><span class="badge" style="background:#1e293b;padding:2px 8px;border-radius:4px;">${escapeHtml(f.category)}</span></td>
<td class="font-mono"><strong>${escapeHtml(f.name)}</strong></td>
<td class="num">${f.sizeKb} KB</td>
<td>${dateStr}</td>
<td class="actions-col">
<a href="${dlUrl}" class="btn btn-sm btn-secondary" download="${escapeHtml(f.name)}">⬇️ Herunterladen</a>
</td>
`;
tbody.appendChild(tr);
});
} catch (e) {
tbody.innerHTML = '<tr><td colspan="5" class="loading-cell" style="color:#ef4444;">Log-Dateien konnten nicht geladen werden.</td></tr>';
}
}
// ─── EINSTELLUNGEN ───
async function loadSettings() {
try {
const res = await api('/api/admin/settings');
if (!res.ok) return;
const data = await res.json();
// Admin
if (data.adminServer) {
document.getElementById('cfg-admin-port').value = data.adminServer.port || 5001;
document.getElementById('cfg-admin-host').value = data.adminServer.host || 'localhost';
document.getElementById('cfg-admin-token').value = data.adminServer.token || '';
}
// Webserver
if (data.webserver) {
document.getElementById('cfg-web-port').value = data.webserver.port || 5000;
document.getElementById('cfg-web-host').value = data.webserver.host || 'localhost';
document.getElementById('cfg-web-token').value = data.webserver.apiControlToken || '';
document.getElementById('cfg-web-dbdebug').checked = !!data.webserver.dbConnectionDebug;
}
// Datenbank
if (data.database) {
document.getElementById('cfg-db-server').value = data.database.server || '';
document.getElementById('cfg-db-name').value = data.database.database || '';
document.getElementById('cfg-db-user').value = data.database.user || '';
document.getElementById('cfg-db-password').value = data.database.password || '';
if (data.database.sslMode) {
document.getElementById('cfg-db-ssl').value = data.database.sslMode;
}
}
// Deployment Center
if (data.deploymentCenter) {
document.getElementById('cfg-dc-url').value = data.deploymentCenter.serverUrl || '';
document.getElementById('cfg-dc-token').value = data.deploymentCenter.token || '';
document.getElementById('cfg-dc-heartbeat').checked = !!data.deploymentCenter.heartbeatEnabled;
document.getElementById('cfg-dc-source').value = data.deploymentCenter.source || '';
document.getElementById('cfg-dc-instance').value = data.deploymentCenter.instance || '';
document.getElementById('cfg-dc-interval').value = data.deploymentCenter.intervalSeconds || 60;
document.getElementById('cfg-dc-errorreporting').checked = !!data.deploymentCenter.errorReportingEnabled;
document.getElementById('cfg-dc-updatecheck').checked = !!data.deploymentCenter.updateCheckEnabled;
if (data.deploymentCenter.updateChannel) {
document.getElementById('cfg-dc-channel').value = data.deploymentCenter.updateChannel;
}
}
// Egress
if (data.egress) {
document.getElementById('cfg-egress-channels').value = data.egress.channelsText || '';
}
} catch (e) {
console.warn('Failed to load settings:', e);
}
}
async function saveSettings() {
const payload = {
adminServer: {
port: parseInt(document.getElementById('cfg-admin-port').value, 10),
host: document.getElementById('cfg-admin-host').value.trim(),
token: document.getElementById('cfg-admin-token').value.trim()
},
webserver: {
port: parseInt(document.getElementById('cfg-web-port').value, 10),
host: document.getElementById('cfg-web-host').value.trim(),
apiControlToken: document.getElementById('cfg-web-token').value.trim(),
dbConnectionDebug: document.getElementById('cfg-web-dbdebug').checked
},
database: {
server: document.getElementById('cfg-db-server').value.trim(),
database: document.getElementById('cfg-db-name').value.trim(),
user: document.getElementById('cfg-db-user').value.trim(),
password: document.getElementById('cfg-db-password').value.trim(),
sslMode: document.getElementById('cfg-db-ssl').value
},
deploymentCenter: {
token: document.getElementById('cfg-dc-token').value.trim(),
heartbeatEnabled: document.getElementById('cfg-dc-heartbeat').checked,
source: document.getElementById('cfg-dc-source').value.trim(),
instance: document.getElementById('cfg-dc-instance').value.trim(),
intervalSeconds: parseInt(document.getElementById('cfg-dc-interval').value, 10),
errorReportingEnabled: document.getElementById('cfg-dc-errorreporting').checked,
updateCheckEnabled: document.getElementById('cfg-dc-updatecheck').checked,
updateChannel: document.getElementById('cfg-dc-channel').value
},
egress: {
channelsText: document.getElementById('cfg-egress-channels').value
}
};
try {
const res = await api('/api/admin/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (res.ok) {
showToast(data.message || 'Einstellungen gespeichert.', 'success');
// Wenn der Admin-Token geändert wurde, Session aktualisieren
if (payload.adminServer.token && payload.adminServer.token !== currentToken) {
currentToken = payload.adminServer.token;
sessionStorage.setItem(STORAGE_KEY, currentToken);
}
} else {
showToast(data.error || 'Fehler beim Speichern der Einstellungen.', 'error');
}
} catch (e) {
showToast('Fehler: ' + e.message, 'error');
}
}
async function generateNewAdminToken() {
try {
const res = await api('/api/admin/settings/generate-token', { method: 'POST' });
if (res.ok) {
const data = await res.json();
document.getElementById('cfg-admin-token').value = data.token;
showToast('Neuer sicherer Token generiert. Zum Übernehmen auf "Speichern" klicken.', 'warning');
}
} catch (e) {
showToast('Token-Generierung fehlgeschlagen.', 'error');
}
}
// ─── LIZENZ & UPDATES ───
async function loadLicenseInfo() {
try {
const res = await api('/api/admin/license');
if (!res.ok) return;
const data = await res.json();
document.getElementById('lic-product').textContent = data.productSlug;
document.getElementById('lic-version').textContent = `v${data.version} (${data.gitCommit || 'head'})`;
document.getElementById('lic-hwid').textContent = data.hardwareId || '—';
document.getElementById('lic-hwidsource').textContent = 'Quelle: ' + (data.hwidSource || '—');
document.getElementById('lic-status').textContent = data.status;
document.getElementById('lic-status').style.color = data.isUsable ? '#10b981' : '#ef4444';
document.getElementById('lic-message').textContent = data.message || 'Gültig';
} catch (e) {
console.warn('Failed to load license info:', e);
}
}
async function checkForUpdates() {
showToast('Prüfe auf Updates...', 'info');
try {
const res = await api('/api/admin/updates/check', { method: 'POST' });
if (!res.ok) throw new Error('Prüfung fehlgeschlagen');
const data = await res.json();
const banner = document.getElementById('update-banner');
const title = document.getElementById('update-banner-title');
const body = document.getElementById('update-banner-body');
if (data.updateAvailable) {
banner.style.display = 'block';
title.textContent = `⬆️ Neues Release v${data.latestVersion} verfügbar! (Installiert: v${data.currentVersion})`;
let html = '';
if (data.isCritical) {
html += '<div class="card-badge badge-warning" style="margin-bottom:8px;">⚠️ WICHTIG: Dieses Update ist als kritisch markiert.</div>';
}
if (data.changelog) {
html += `<pre class="terminal-body" style="height:auto;max-height:200px;margin-top:8px;">${escapeHtml(data.changelog)}</pre>`;
}
body.innerHTML = html;
showToast(`Update verfügbar: v${data.latestVersion}`, 'warning');
} else {
banner.style.display = 'none';
showToast(data.message || `Predictalytics v${data.currentVersion} ist aktuell.`, 'success');
}
} catch (e) {
showToast('Update-Prüfung fehlgeschlagen: ' + e.message, 'error');
}
}
// ─── NAVIGATION & TABS ───
function setupNavigation() {
const links = document.querySelectorAll('.nav-link');
links.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const tabName = link.getAttribute('data-tab');
switchTab(tabName);
});
});
}
function switchTab(tabName) {
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
const targetLink = document.querySelector(`.nav-link[data-tab="${tabName}"]`);
const targetPanel = document.getElementById('panel-' + tabName);
if (targetLink) targetLink.classList.add('active');
if (targetPanel) targetPanel.classList.add('active');
// Wenn spezifische Tabs aktiviert werden, Daten nachladen
if (tabName === 'logfiles') loadLogFiles();
if (tabName === 'settings') loadSettings();
if (tabName === 'license') loadLicenseInfo();
}
// ─── TOAST BENACHRICHTIGUNGEN ───
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateY(10px)';
toast.style.transition = 'all 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}