alert-dashboard/index.html

233 lines
8.9 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1024, height=600, initial-scale=1">
<title>Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #000; color: #fff; font-family: -apple-system, 'Segoe UI', sans-serif; overflow: hidden; height: 100vh; display: flex; }
#left { width: 40%; display: flex; align-items: center; justify-content: center; flex-direction: column; }
#right { width: 60%; display: flex; flex-direction: column; padding: 12px 16px 12px 0; }
/* Clock */
#clock-container { position: relative; }
canvas#clock { width: 280px; height: 280px; }
#digital-time { text-align: center; color: #6af; font-size: 18px; margin-top: 8px; font-variant-numeric: tabular-nums; }
/* Calendar */
#calendar { margin-bottom: 12px; }
#cal-header { text-align: center; color: #6af; font-size: 16px; font-weight: 600; margin-bottom: 6px; }
#cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; text-align: center; font-size: 13px; }
#cal-grid .day-name { color: #556; font-size: 11px; text-transform: uppercase; }
#cal-grid .day { padding: 4px 0; border-radius: 4px; color: #888; }
#cal-grid .day.current-month { color: #ccc; }
#cal-grid .day.today { background: #16f; color: #fff; font-weight: 700; }
/* Alerts */
#alerts-section { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
#alerts-header { color: #6af; font-size: 14px; font-weight: 600; margin-bottom: 6px; display: flex; align-items: center; gap: 8px; }
#alerts-header .dot { width: 8px; height: 8px; border-radius: 50%; background: #2a2; animation: pulse 2s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
#alert-list { flex: 1; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #333 #000; }
.alert-item { padding: 8px 10px; border-left: 3px solid #333; margin-bottom: 4px; background: #0a0a0a; border-radius: 0 4px 4px 0; animation: slideIn .3s ease; }
.alert-item.info { border-left-color: #38f; }
.alert-item.warning { border-left-color: #fa0; }
.alert-item.critical { border-left-color: #f33; }
.alert-msg { font-size: 13px; color: #ddd; }
.alert-time { font-size: 10px; color: #556; margin-top: 2px; }
@keyframes slideIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
.alert-item.critical .alert-msg { color: #f77; }
</style>
</head>
<body>
<div id="left">
<div id="clock-container"><canvas id="clock" width="560" height="560"></canvas></div>
<div id="digital-time"></div>
</div>
<div id="right">
<div id="calendar">
<div id="cal-header"></div>
<div id="cal-grid"></div>
</div>
<div id="alerts-section">
<div id="alerts-header"><span class="dot"></span> ALERTS</div>
<div id="alert-list"></div>
</div>
</div>
<script>
// === CLOCK ===
const canvas = document.getElementById('clock');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height, CX = W/2, CY = H/2, R = W/2 - 20;
function drawClock() {
const now = new Date();
ctx.clearRect(0, 0, W, H);
// Face
ctx.beginPath(); ctx.arc(CX, CY, R, 0, Math.PI*2);
ctx.strokeStyle = '#6af'; ctx.lineWidth = 3; ctx.stroke();
// Hour marks
for (let i = 0; i < 12; i++) {
const a = (i * Math.PI / 6) - Math.PI/2;
const len = i % 3 === 0 ? 25 : 12;
ctx.beginPath();
ctx.moveTo(CX + Math.cos(a)*(R-len), CY + Math.sin(a)*(R-len));
ctx.lineTo(CX + Math.cos(a)*(R-4), CY + Math.sin(a)*(R-4));
ctx.strokeStyle = i % 3 === 0 ? '#fff' : '#555';
ctx.lineWidth = i % 3 === 0 ? 3 : 1.5;
ctx.stroke();
}
// Minute marks
for (let i = 0; i < 60; i++) {
if (i % 5 === 0) continue;
const a = (i * Math.PI / 30) - Math.PI/2;
ctx.beginPath();
ctx.moveTo(CX + Math.cos(a)*(R-6), CY + Math.sin(a)*(R-6));
ctx.lineTo(CX + Math.cos(a)*(R-3), CY + Math.sin(a)*(R-3));
ctx.strokeStyle = '#333'; ctx.lineWidth = 1; ctx.stroke();
}
const h = now.getHours(), m = now.getMinutes(), s = now.getSeconds();
// Hour hand
const ha = ((h % 12) + m/60) * Math.PI/6 - Math.PI/2;
ctx.beginPath(); ctx.moveTo(CX, CY);
ctx.lineTo(CX + Math.cos(ha)*R*0.5, CY + Math.sin(ha)*R*0.5);
ctx.strokeStyle = '#fff'; ctx.lineWidth = 5; ctx.lineCap = 'round'; ctx.stroke();
// Minute hand
const ma = (m + s/60) * Math.PI/30 - Math.PI/2;
ctx.beginPath(); ctx.moveTo(CX, CY);
ctx.lineTo(CX + Math.cos(ma)*R*0.75, CY + Math.sin(ma)*R*0.75);
ctx.strokeStyle = '#fff'; ctx.lineWidth = 3; ctx.lineCap = 'round'; ctx.stroke();
// Second hand
const sa = s * Math.PI/30 - Math.PI/2;
ctx.beginPath(); ctx.moveTo(CX, CY);
ctx.lineTo(CX + Math.cos(sa)*R*0.8, CY + Math.sin(sa)*R*0.8);
ctx.strokeStyle = '#6af'; ctx.lineWidth = 1.5; ctx.stroke();
// Center dot
ctx.beginPath(); ctx.arc(CX, CY, 5, 0, Math.PI*2);
ctx.fillStyle = '#6af'; ctx.fill();
// Digital
document.getElementById('digital-time').textContent =
now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
requestAnimationFrame(drawClock);
}
drawClock();
// === CALENDAR ===
function renderCalendar() {
const now = new Date();
const y = now.getFullYear(), mo = now.getMonth();
document.getElementById('cal-header').textContent =
now.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
const grid = document.getElementById('cal-grid');
grid.innerHTML = '';
['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].forEach(d => {
const el = document.createElement('div');
el.className = 'day-name'; el.textContent = d; grid.appendChild(el);
});
const first = new Date(y, mo, 1).getDay();
const days = new Date(y, mo+1, 0).getDate();
const today = now.getDate();
// Previous month padding
const prevDays = new Date(y, mo, 0).getDate();
for (let i = first - 1; i >= 0; i--) {
const el = document.createElement('div');
el.className = 'day'; el.textContent = prevDays - i; grid.appendChild(el);
}
for (let d = 1; d <= days; d++) {
const el = document.createElement('div');
el.className = 'day current-month' + (d === today ? ' today' : '');
el.textContent = d; grid.appendChild(el);
}
// Fill remaining
const total = first + days;
for (let i = 1; i <= (7 - total % 7) % 7; i++) {
const el = document.createElement('div');
el.className = 'day'; el.textContent = i; grid.appendChild(el);
}
}
renderCalendar();
setInterval(renderCalendar, 60000);
// === NOTIFICATION SOUND ===
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
function pling() {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain); gain.connect(audioCtx.destination);
osc.type = 'sine'; osc.frequency.setValueAtTime(880, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1760, audioCtx.currentTime + 0.05);
gain.gain.setValueAtTime(0.15, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.4);
osc.start(); osc.stop(audioCtx.currentTime + 0.4);
}
// === ALERTS ===
const alertList = document.getElementById('alert-list');
function formatTime(ts) {
const d = new Date(ts);
const now = new Date();
const diffMs = now - d;
if (diffMs < 60000) return 'just now';
if (diffMs < 3600000) return Math.floor(diffMs/60000) + 'm ago';
if (diffMs < 86400000) return Math.floor(diffMs/3600000) + 'h ago';
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' +
d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
}
function renderAlert(alert, prepend = false) {
const div = document.createElement('div');
div.className = 'alert-item ' + (alert.priority || 'info');
div.innerHTML = `<div class="alert-msg">${escHtml(alert.message)}</div><div class="alert-time">${formatTime(alert.timestamp)}</div>`;
if (prepend) alertList.prepend(div);
else alertList.appendChild(div);
// Keep max 10 visible
while (alertList.children.length > 10) alertList.lastChild.remove();
}
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// Load existing
fetch('/api/alerts').then(r => r.json()).then(alerts => {
alerts.slice(0, 10).forEach(a => renderAlert(a));
});
// Update relative times every 30s
setInterval(() => {
document.querySelectorAll('.alert-item').forEach(el => {
const msg = el.querySelector('.alert-msg').textContent;
// Re-render times would need stored timestamp; skip for simplicity
});
}, 30000);
// SSE
const evtSource = new EventSource('/api/alerts/stream');
evtSource.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'connected') return;
renderAlert(data, true);
pling();
};
evtSource.onerror = () => { setTimeout(() => location.reload(), 5000); };
// Resume audio context on interaction (autoplay policy)
document.addEventListener('click', () => { if (audioCtx.state === 'suspended') audioCtx.resume(); }, { once: true });
</script>
</body>
</html>