chu-iptv/apps/admin-web/monitoring.html

313 lines
17 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CHU-IPTV — Monitoring</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<style>
:root {
--accent: #00d4aa; --accent-dim: rgba(0,212,170,0.12);
--bg: #0a0e1a; --bg-card: #111827; --bg-elevated: #1a2240;
--text: #f0f4ff; --text-dim: #8892b0;
--danger: #ff4466; --warning: #ffaa33; --success: #00d4aa; --info: #4488ff;
--radius: 14px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
/* NAV */
.top-nav { display: flex; align-items: center; justify-content: space-between; padding: 16px 32px; border-bottom: 1px solid rgba(255,255,255,0.05); background: var(--bg-card); }
.nav-left { display: flex; align-items: center; gap: 24px; }
.nav-brand { font-size: 20px; font-weight: 800; color: var(--accent); }
.nav-links { display: flex; gap: 4px; }
.nav-links a { padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--text-dim); text-decoration: none; transition: all 0.2s; }
.nav-links a:hover { background: rgba(255,255,255,0.04); color: var(--text); }
.nav-links a.active { background: var(--accent-dim); color: var(--accent); }
.nav-right { display: flex; align-items: center; gap: 12px; }
.nav-status { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-dim); }
.nav-status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--success); animation: blink 2s infinite; }
@keyframes blink { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
/* SUMMARY */
.summary-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; padding: 24px 32px; }
.summary-card { background: var(--bg-card); border: 1px solid rgba(255,255,255,0.05); border-radius: var(--radius); padding: 20px; text-align: center; }
.sc-value { font-size: 36px; font-weight: 800; margin-bottom: 4px; }
.sc-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; }
.sc-value.online { color: var(--success); }
.sc-value.offline { color: var(--danger); }
.sc-value.playing { color: var(--info); }
.sc-value.errors { color: var(--warning); }
/* TV GRID */
.section-title { padding: 0 32px; margin-top: 8px; font-size: 14px; font-weight: 700; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; }
.tv-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; padding: 16px 32px; }
.tv-card { background: var(--bg-card); border: 1px solid rgba(255,255,255,0.05); border-radius: var(--radius); padding: 16px; transition: all 0.2s; position: relative; overflow: hidden; }
.tv-card:hover { border-color: rgba(255,255,255,0.12); transform: translateY(-2px); }
.tv-card.online { border-left: 3px solid var(--success); }
.tv-card.offline { border-left: 3px solid var(--danger); opacity: 0.7; }
.tv-card.error { border-left: 3px solid var(--warning); }
.tv-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.tv-room { font-size: 16px; font-weight: 700; }
.tv-service { font-size: 10px; color: var(--text-dim); }
.tv-status-badge { padding: 3px 8px; border-radius: 6px; font-size: 10px; font-weight: 600; }
.badge-online { background: rgba(0,212,170,0.12); color: var(--success); }
.badge-offline { background: rgba(255,68,102,0.12); color: var(--danger); }
.badge-error { background: rgba(255,170,51,0.12); color: var(--warning); }
.tv-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.tv-metric { display: flex; flex-direction: column; }
.tm-label { font-size: 9px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
.tm-value { font-size: 13px; font-weight: 500; }
.tv-actions { display: flex; gap: 6px; margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.05); }
.tv-actions button { flex: 1; padding: 6px; border-radius: 6px; border: none; font-size: 10px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
.tv-actions .btn-reboot { background: rgba(255,68,102,0.12); color: var(--danger); }
.tv-actions .btn-tune { background: rgba(68,136,255,0.12); color: var(--info); }
.tv-actions .btn-reboot:hover, .tv-actions .btn-tune:hover { opacity: 0.7; }
/* ALERTS */
.alerts-section { padding: 24px 32px; }
.alerts-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.alerts-header h3 { font-size: 14px; font-weight: 700; }
.alert-count { background: var(--danger); color: #fff; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; }
.alerts-list { max-height: 300px; overflow-y: auto; }
.alert-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: var(--bg-card); border: 1px solid rgba(255,255,255,0.05); border-radius: 10px; margin-bottom: 8px; }
.alert-item.critical { border-left: 3px solid var(--danger); }
.alert-item.warning { border-left: 3px solid var(--warning); }
.alert-item.info { border-left: 3px solid var(--info); }
.alert-item.acked { opacity: 0.5; }
.ai-icon { font-size: 18px; }
.ai-content { flex: 1; }
.ai-msg { font-size: 13px; font-weight: 500; }
.ai-time { font-size: 10px; color: var(--text-dim); }
.ai-ack { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: var(--text-dim); font-size: 10px; cursor: pointer; }
.ai-ack:hover { background: rgba(255,255,255,0.05); }
/* METRICS */
.metrics-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; padding: 0 32px 32px; }
.metric-card { background: var(--bg-card); border: 1px solid rgba(255,255,255,0.05); border-radius: var(--radius); padding: 20px; }
.metric-card h4 { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; }
.metric-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.03); font-size: 13px; }
.metric-row:last-child { border: none; }
.metric-row .mr-label { color: var(--text-dim); }
.metric-row .mr-value { font-weight: 600; }
/* UPTIME BAR */
.uptime-bar { height: 6px; background: rgba(255,255,255,0.06); border-radius: 3px; margin-top: 8px; overflow: hidden; }
.uptime-fill { height: 100%; background: var(--success); border-radius: 3px; transition: width 0.5s; }
/* REFRESH */
.refresh-info { text-align: center; padding: 12px; font-size: 11px; color: var(--text-dim); }
</style>
</head>
<body>
<nav class="top-nav">
<div class="nav-left">
<div class="nav-brand">CHU-IPTV</div>
<div class="nav-links">
<a href="/">Chambres</a>
<a href="/monitoring.html" class="active">Monitoring</a>
</div>
</div>
<div class="nav-right">
<div class="nav-status"><div class="dot"></div> Temps réel actif</div>
</div>
</nav>
<div class="summary-grid" id="summaryGrid">
<div class="summary-card"><div class="sc-value" id="sumTotal">-</div><div class="sc-label">Total TV</div></div>
<div class="summary-card"><div class="sc-value online" id="sumOnline">-</div><div class="sc-label">En ligne</div></div>
<div class="summary-card"><div class="sc-value offline" id="sumOffline">-</div><div class="sc-label">Hors ligne</div></div>
<div class="summary-card"><div class="sc-value playing" id="sumPlaying">-</div><div class="sc-label">En lecture</div></div>
<div class="summary-card"><div class="sc-value errors" id="sumErrors">-</div><div class="sc-label">Erreurs</div></div>
</div>
<div class="section-title">Parc de téléviseurs</div>
<div class="tv-grid" id="tvGrid"></div>
<div class="alerts-section">
<div class="alerts-header">
<h3>Alertes récentes</h3>
<span class="alert-count" id="alertCount">0</span>
</div>
<div class="alerts-list" id="alertsList"></div>
</div>
<div class="section-title" style="margin-bottom:12px;">Métriques système</div>
<div class="metrics-grid" id="metricsGrid"></div>
<div class="refresh-info">Rafraîchissement automatique toutes les 10 secondes • Alertes en temps réel via WebSocket</div>
<script>
const API_URL = 'https://chu-iptv-api.cosmolan.fr';
const staffToken = localStorage.getItem('chu-iptv-staff-token');
let socket = null;
if (!staffToken) { window.location.href = '/'; }
// ═══ FETCH DATA ═══
async function fetchDashboard() {
try {
const [dashRes, alertsRes, metricsRes] = await Promise.all([
fetch(`${API_URL}/api/monitoring/dashboard`, { headers: { Authorization: `Bearer ${staffToken}` } }),
fetch(`${API_URL}/api/monitoring/alerts?limit=20`, { headers: { Authorization: `Bearer ${staffToken}` } }),
fetch(`${API_URL}/api/monitoring/metrics`, { headers: { Authorization: `Bearer ${staffToken}` } }),
]);
if (dashRes.ok) {
const data = await dashRes.json();
renderSummary(data.summary);
renderTVGrid(data.tvStatus);
}
if (alertsRes.ok) {
const data = await alertsRes.json();
renderAlerts(data.alerts, data.unacknowledged);
}
if (metricsRes.ok) {
const data = await metricsRes.json();
renderMetrics(data);
}
} catch (e) { console.error('Fetch error:', e); }
}
// ═══ RENDER ═══
function renderSummary(s) {
document.getElementById('sumTotal').textContent = s.total;
document.getElementById('sumOnline').textContent = s.online;
document.getElementById('sumOffline').textContent = s.offline;
document.getElementById('sumPlaying').textContent = s.playing;
document.getElementById('sumErrors').textContent = s.errors;
}
function renderTVGrid(tvList) {
const grid = document.getElementById('tvGrid');
grid.innerHTML = tvList.map(tv => {
const statusClass = tv.online ? (tv.playerState === 'error' ? 'error' : 'online') : 'offline';
const badgeClass = tv.online ? (tv.playerState === 'error' ? 'badge-error' : 'badge-online') : 'badge-offline';
const badgeText = tv.online ? (tv.playerState === 'error' ? 'Erreur' : 'En ligne') : 'Hors ligne';
const uptimeStr = tv.uptime > 3600 ? `${Math.floor(tv.uptime/3600)}h${Math.floor((tv.uptime%3600)/60)}m` : `${Math.floor(tv.uptime/60)}m`;
const lastSeenStr = tv.lastSeen ? new Date(tv.lastSeen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '-';
const playerIcons = { playing: '▶️', paused: '⏸️', buffering: '⏳', error: '⚠️', idle: '⏹️', unknown: '❓' };
return `
<div class="tv-card ${statusClass}">
<div class="tv-header">
<div>
<div class="tv-room">${tv.roomNumber}</div>
<div class="tv-service">${tv.service}</div>
</div>
<span class="tv-status-badge ${badgeClass}">${badgeText}</span>
</div>
<div class="tv-metrics">
<div class="tv-metric"><span class="tm-label">État</span><span class="tm-value">${playerIcons[tv.playerState] || '❓'} ${tv.playerState}</span></div>
<div class="tv-metric"><span class="tm-label">Chaîne</span><span class="tm-value">${tv.currentChannel || '-'}</span></div>
<div class="tv-metric"><span class="tm-label">Uptime</span><span class="tm-value">${uptimeStr}</span></div>
<div class="tv-metric"><span class="tm-label">Dernier signal</span><span class="tm-value">${lastSeenStr}</span></div>
<div class="tv-metric"><span class="tm-label">Réseau</span><span class="tm-value">${tv.networkType}</span></div>
<div class="tv-metric"><span class="tm-label">Signal</span><span class="tm-value">${tv.signalStrength}%</span></div>
</div>
<div class="uptime-bar"><div class="uptime-fill" style="width:${tv.online ? tv.signalStrength : 0}%"></div></div>
<div class="tv-actions">
<button class="btn-tune" onclick="forceTune('${tv.roomId}')">📺 Forcer chaîne</button>
<button class="btn-reboot" onclick="rebootTV('${tv.roomId}', '${tv.roomNumber}')">🔄 Redémarrer</button>
</div>
</div>`;
}).join('');
}
function renderAlerts(alertList, unackCount) {
document.getElementById('alertCount').textContent = unackCount;
const container = document.getElementById('alertsList');
if (alertList.length === 0) {
container.innerHTML = '<p style="font-size:13px;color:var(--text-dim);padding:16px;">Aucune alerte récente</p>';
return;
}
const icons = { tv_offline: '📺', stream_error: '⚠️', high_cpu: '🔥', high_temp: '🌡️', network_issue: '🌐' };
container.innerHTML = alertList.map(a => `
<div class="alert-item ${a.severity}${a.acknowledged ? ' acked' : ''}">
<div class="ai-icon">${icons[a.type] || '⚠️'}</div>
<div class="ai-content">
<div class="ai-msg">${a.message}</div>
<div class="ai-time">${new Date(a.timestamp).toLocaleString('fr-FR')}</div>
</div>
${!a.acknowledged ? `<button class="ai-ack" onclick="ackAlert('${a.id}')">Acquitter</button>` : '<span style="font-size:10px;color:var(--success);">✓</span>'}
</div>
`).join('');
}
function renderMetrics(m) {
const container = document.getElementById('metricsGrid');
const memMB = Math.round((m.memoryUsage?.heapUsed || 0) / 1024 / 1024);
const uptimeH = Math.floor((m.uptime || 0) / 3600);
const uptimeM = Math.floor(((m.uptime || 0) % 3600) / 60);
const channelEntries = Object.entries(m.channelUsage || {}).sort((a, b) => b[1] - a[1]).slice(0, 5);
container.innerHTML = `
<div class="metric-card">
<h4>Activité patient</h4>
<div class="metric-row"><span class="mr-label">Demandes totales</span><span class="mr-value">${m.requests?.total || 0}</span></div>
<div class="metric-row"><span class="mr-label">En attente</span><span class="mr-value" style="color:var(--warning)">${m.requests?.pending || 0}</span></div>
<div class="metric-row"><span class="mr-label">Messages chat</span><span class="mr-value">${m.messages?.total || 0}</span></div>
<div class="metric-row"><span class="mr-label">Sessions actives</span><span class="mr-value" style="color:var(--success)">${m.sessions?.active || 0}</span></div>
</div>
<div class="metric-card">
<h4>Serveur API</h4>
<div class="metric-row"><span class="mr-label">Uptime</span><span class="mr-value">${uptimeH}h ${uptimeM}m</span></div>
<div class="metric-row"><span class="mr-label">Mémoire heap</span><span class="mr-value">${memMB} MB</span></div>
<div class="metric-row"><span class="mr-label">Version</span><span class="mr-value">1.0.0</span></div>
</div>
<div class="metric-card">
<h4>Chaînes populaires</h4>
${channelEntries.length > 0 ? channelEntries.map(([ch, count]) => `
<div class="metric-row"><span class="mr-label">Chaîne ${ch}</span><span class="mr-value">${count} TV</span></div>
`).join('') : '<div class="metric-row"><span class="mr-label">Aucune donnée</span><span class="mr-value">-</span></div>'}
</div>
`;
}
// ═══ ACTIONS ═══
async function ackAlert(id) {
await fetch(`${API_URL}/api/monitoring/alerts/${id}/ack`, { method: 'POST', headers: { Authorization: `Bearer ${staffToken}` } });
fetchDashboard();
}
function forceTune(roomId) {
const num = prompt('Numéro de chaîne :');
if (!num) return;
socket?.emit('tv:tune', { roomId, channelNumber: parseInt(num) });
}
function rebootTV(roomId, roomNumber) {
if (!confirm(`Redémarrer la TV de la chambre ${roomNumber} ?`)) return;
fetch(`${API_URL}/api/monitoring/reboot/${roomId}`, { method: 'POST', headers: { Authorization: `Bearer ${staffToken}` } });
}
// ═══ WEBSOCKET ═══
function connectSocket() {
socket = io(API_URL, { auth: { token: staffToken }, reconnection: true });
socket.on('connect', () => { socket.emit('join:admin'); });
socket.on('monitoring:alert', (alert) => {
fetchDashboard();
// Notification sonore
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain); gain.connect(ctx.destination);
osc.frequency.value = 600; gain.gain.value = 0.2;
osc.start();
setTimeout(() => { osc.frequency.value = 400; }, 200);
setTimeout(() => { osc.stop(); ctx.close(); }, 400);
} catch {}
});
socket.on('monitoring:heartbeat', () => { /* Will refresh on next interval */ });
}
// ═══ INIT ═══
fetchDashboard();
connectSocket();
setInterval(fetchDashboard, 10000); // Refresh toutes les 10s
</script>
</body>
</html>