313 lines
17 KiB
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>
|