Fully dashboard: alerts left, camera feed, long-press done, auto-purge, brighter timestamps

This commit is contained in:
James 2026-02-14 02:25:37 -05:00
parent b22a05f3af
commit 42b0d611f6
2 changed files with 299 additions and 94 deletions

View File

@ -3,177 +3,250 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1024, height=600, initial-scale=1">
<title>Dashboard</title>
<title>Watchboard</title>
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;600;700&display=swap" rel="stylesheet">
<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; }
body { background: #0a0a0a; color: #e0ddd5; font-family: 'Sora', sans-serif; overflow: hidden; height: 100vh; display: flex; }
/* Layout */
#left { width: 58%; display: flex; flex-direction: column; padding: 16px 0 16px 20px; gap: 12px; }
#right { width: 42%; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 16px; gap: 12px; }
/* Camera */
#cam-wrap { width: 70%; border-radius: 8px; overflow: hidden; border: 1px solid #222; flex-shrink: 0; }
#pulse-cam { width: 100%; display: block; background: #111; }
/* 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; }
#clock-wrap { position: relative; }
canvas#clock { width: 180px; height: 180px; }
#digital-time { text-align: center; color: #c8b273; font-size: 22px; font-weight: 300; letter-spacing: 3px; margin-top: 6px; font-variant-numeric: tabular-nums; }
#digital-date { text-align: center; color: #666; font-size: 13px; font-weight: 300; letter-spacing: 1px; margin-top: 2px; }
/* 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; }
#calendar { width: 100%; max-width: 280px; }
#cal-nav { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
#cal-nav button { background: none; border: 1px solid #333; color: #c8b273; font-family: 'Sora'; font-size: 16px; width: 28px; height: 28px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; }
#cal-nav button:hover { background: #1a1a1a; border-color: #c8b273; }
#cal-title { color: #c8b273; font-size: 15px; font-weight: 600; letter-spacing: 1px; }
#cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; text-align: center; }
#cal-grid .day-name { color: #555; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; padding: 4px 0; }
#cal-grid .day { padding: 5px 0; font-size: 13px; font-weight: 300; color: #444; border-radius: 50%; aspect-ratio: 1; display: flex; align-items: center; justify-content: center; }
#cal-grid .day.current-month { color: #999; }
#cal-grid .day.today { background: #c8b273; color: #0a0a0a; font-weight: 700; }
#cal-grid .day.sunday { color: #664; }
#cal-grid .day.current-month.sunday { color: #c8b273; opacity: 0.7; }
#cal-grid .day.today.sunday { color: #0a0a0a; opacity: 1; }
/* 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; }
#alerts-header { color: #c8b273; font-size: 12px; font-weight: 600; letter-spacing: 3px; text-transform: uppercase; margin-bottom: 10px; display: flex; align-items: center; gap: 10px; }
#alerts-header .dot { width: 6px; height: 6px; border-radius: 50%; background: #c8b273; animation: pulse 3s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .2; } }
#alert-list { flex: 1; overflow-y: auto; scrollbar-width: none; }
#alert-list::-webkit-scrollbar { display: none; }
.alert-item { padding: 12px 40px 12px 16px; border-left: 2px solid #333; margin-bottom: 6px; background: #111; border-radius: 0 6px 6px 0; animation: slideIn .4s ease; position: relative; transition: all .3s; }
.alert-item.info { border-left-color: #c8b273; }
.alert-item.warning { border-left-color: #d4a050; }
.alert-item.critical { border-left-color: #c45; }
.alert-msg { font-size: 16px; font-weight: 400; color: #ddd; line-height: 1.4; }
.alert-time { font-size: 11px; color: #888; margin-top: 4px; font-weight: 400; }
@keyframes slideIn { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
.alert-item.critical .alert-msg { color: #e88; }
.alert-item.warning .alert-msg { color: #d4a050; }
.alert-remove { position: absolute; top: 10px; right: 12px; background: none; border: none; color: #333; font-size: 18px; cursor: pointer; padding: 4px 8px; line-height: 1; font-family: 'Sora'; }
.alert-remove:hover { color: #c45; }
.alert-item.done { opacity: 0.35; border-left-color: #2a2a2a !important; }
.alert-item.done .alert-msg { text-decoration: line-through; }
/* Divider line */
#right::before { content: ''; position: absolute; left: 0; top: 10%; height: 80%; width: 1px; background: linear-gradient(transparent, #333, transparent); }
#right { position: relative; }
</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 id="alerts-section">
<div id="alerts-header"><span class="dot"></span> STATUS</div>
<div id="alert-list"></div>
</div>
<div id="cam-wrap"><img id="pulse-cam" alt="Pulse-Ox"></div>
</div>
<div id="right">
<div id="clock-wrap"><canvas id="clock" width="440" height="440"></canvas></div>
<div id="digital-time"></div>
<div id="digital-date"></div>
<div id="calendar">
<div id="cal-header"></div>
<div id="cal-nav">
<button id="cal-prev"></button>
<div id="cal-title"></div>
<button id="cal-next"></button>
</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 ===
// === CLOCK (Braun-inspired) ===
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;
const W = canvas.width, H = canvas.height, CX = W/2, CY = H/2, R = W/2 - 30;
const GOLD = '#c8b273';
const DIM = '#333';
const FACE = '#0a0a0a';
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();
// Outer ring
ctx.beginPath(); ctx.arc(CX, CY, R + 12, 0, Math.PI*2);
ctx.strokeStyle = '#222'; ctx.lineWidth = 1; ctx.stroke();
// Hour marks
for (let i = 0; i < 12; i++) {
// Face circle
ctx.beginPath(); ctx.arc(CX, CY, R, 0, Math.PI*2);
ctx.strokeStyle = GOLD; ctx.lineWidth = 2; ctx.stroke();
// Hour numbers
ctx.font = '600 28px Sora';
ctx.fillStyle = '#bbb';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let i = 1; 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();
const nr = R - 32;
ctx.fillText(i.toString(), CX + Math.cos(a)*nr, CY + Math.sin(a)*nr);
}
// Minute marks
// Minute ticks
for (let i = 0; i < 60; i++) {
if (i % 5 === 0) continue;
const a = (i * Math.PI / 30) - Math.PI/2;
const outer = R - 6;
const inner = i % 5 === 0 ? R - 18 : R - 12;
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();
ctx.moveTo(CX + Math.cos(a)*inner, CY + Math.sin(a)*inner);
ctx.lineTo(CX + Math.cos(a)*outer, CY + Math.sin(a)*outer);
ctx.strokeStyle = i % 5 === 0 ? GOLD : '#444';
ctx.lineWidth = i % 5 === 0 ? 2 : 1;
ctx.stroke();
}
const h = now.getHours(), m = now.getMinutes(), s = now.getSeconds();
// Hour hand
// Hour hand — thick, short
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();
ctx.lineTo(CX + Math.cos(ha)*R*0.48, CY + Math.sin(ha)*R*0.48);
ctx.strokeStyle = '#ddd'; ctx.lineWidth = 6; ctx.lineCap = 'round'; ctx.stroke();
// Minute hand
// Minute hand — thinner, longer
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();
ctx.lineTo(CX + Math.cos(ma)*R*0.72, CY + Math.sin(ma)*R*0.72);
ctx.strokeStyle = '#ddd'; ctx.lineWidth = 3.5; ctx.lineCap = 'round'; ctx.stroke();
// Second hand
// Second hand — Braun yellow/gold, thin, with counterweight
const sa = s * Math.PI/30 - Math.PI/2;
// Counterweight
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();
ctx.lineTo(CX - Math.cos(sa)*R*0.15, CY - Math.sin(sa)*R*0.15);
ctx.strokeStyle = GOLD; ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.stroke();
// Main
ctx.beginPath(); ctx.moveTo(CX, CY);
ctx.lineTo(CX + Math.cos(sa)*R*0.78, CY + Math.sin(sa)*R*0.78);
ctx.strokeStyle = GOLD; ctx.lineWidth = 1.5; ctx.stroke();
// Center dot
ctx.beginPath(); ctx.arc(CX, CY, 5, 0, Math.PI*2);
ctx.fillStyle = '#6af'; ctx.fill();
ctx.fillStyle = GOLD; ctx.fill();
// Digital
// Digital time
document.getElementById('digital-time').textContent =
now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
document.getElementById('digital-date').textContent =
now.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
requestAnimationFrame(drawClock);
}
drawClock();
// === CALENDAR ===
let calYear, calMonth;
(function initCal() {
const now = new Date();
calYear = now.getFullYear();
calMonth = now.getMonth();
renderCalendar();
})();
document.getElementById('cal-prev').addEventListener('click', () => { calMonth--; if (calMonth < 0) { calMonth = 11; calYear--; } renderCalendar(); });
document.getElementById('cal-next').addEventListener('click', () => { calMonth++; if (calMonth > 11) { calMonth = 0; calYear++; } renderCalendar(); });
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 isCurrentMonth = calYear === now.getFullYear() && calMonth === now.getMonth();
document.getElementById('cal-title').textContent =
new Date(calYear, calMonth).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
const grid = document.getElementById('cal-grid');
grid.innerHTML = '';
['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].forEach(d => {
['S','M','T','W','T','F','S'].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 first = new Date(calYear, calMonth, 1).getDay();
const days = new Date(calYear, calMonth + 1, 0).getDate();
const today = now.getDate();
// Previous month padding
const prevDays = new Date(y, mo, 0).getDate();
const prevDays = new Date(calYear, calMonth, 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' : '');
const dow = new Date(calYear, calMonth, d).getDay();
let cls = 'day current-month';
if (isCurrentMonth && d === today) cls += ' today';
if (dow === 0) cls += ' sunday';
el.className = cls;
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);
// Reset to current month at midnight
setInterval(() => {
const now = new Date();
if (now.getHours() === 0 && now.getMinutes() === 0) {
calYear = now.getFullYear(); calMonth = now.getMonth(); 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);
// Warm two-tone chime
[880, 1320].forEach((freq, i) => {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain); gain.connect(audioCtx.destination);
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, audioCtx.currentTime + i * 0.08);
gain.gain.setValueAtTime(0.1, audioCtx.currentTime + i * 0.08);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + i * 0.08 + 0.5);
osc.start(audioCtx.currentTime + i * 0.08);
osc.stop(audioCtx.currentTime + i * 0.08 + 0.5);
});
}
// === ALERTS ===
@ -192,41 +265,104 @@ function formatTime(ts) {
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>`;
div.className = 'alert-item ' + (alert.priority || 'info') + (alert.done ? ' done' : '');
div.dataset.id = alert.id;
div.innerHTML = `<button class="alert-remove" onclick="removeAlert('${alert.id}', this.parentElement)">&times;</button><div class="alert-msg">${escHtml(alert.message)}</div><div class="alert-time">${formatTime(alert.timestamp)}</div>`;
// Long-press (300ms) to toggle done
let holdTimer = null;
const startHold = (e) => {
holdTimer = setTimeout(() => {
holdTimer = null;
const isDone = div.classList.toggle('done');
// Persist done state to server
fetch('/api/alerts/' + alert.id + '/done', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ done: isDone })
}).catch(() => {});
pling();
}, 300);
};
const cancelHold = () => { if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; } };
div.addEventListener('touchstart', startHold, { passive: true });
div.addEventListener('touchend', cancelHold);
div.addEventListener('touchmove', cancelHold);
div.addEventListener('mousedown', startHold);
div.addEventListener('mouseup', cancelHold);
div.addEventListener('mouseleave', cancelHold);
if (prepend) alertList.prepend(div);
else alertList.appendChild(div);
// Keep max 10 visible
while (alertList.children.length > 10) alertList.lastChild.remove();
while (alertList.children.length > 15) 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));
});
function removeAlert(id, el) {
el.style.opacity = '0'; el.style.transform = 'translateX(30px)'; el.style.transition = 'all .3s';
fetch('/api/alerts/' + id, { method: 'DELETE' });
setTimeout(() => el.remove(), 300);
}
// 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
const id = el.dataset.id;
// Would need stored timestamps; refresh page every hour instead
});
}, 30000);
// Auto-refresh page every 2 hours to keep times fresh
setTimeout(() => location.reload(), 7200000);
// Load existing
fetch('/api/alerts').then(r => r.json()).then(alerts => {
alerts.slice(0, 15).forEach(a => renderAlert(a));
});
// SSE
const evtSource = new EventSource('/api/alerts/stream');
evtSource.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'connected') return;
if (data.type === 'remove') {
const el = document.querySelector(`.alert-item[data-id="${data.id}"]`);
if (el) { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }
return;
}
renderAlert(data, true);
pling();
};
evtSource.onerror = () => { setTimeout(() => location.reload(), 5000); };
// Resume audio context on interaction (autoplay policy)
// Resume audio on first interaction
document.addEventListener('click', () => { if (audioCtx.state === 'suspended') audioCtx.resume(); }, { once: true });
// === PULSE-OX CAMERA (7pm - 8am only) ===
const camImg = document.getElementById('pulse-cam');
const camWrap = document.getElementById('cam-wrap');
function isCamTime() {
const h = new Date().getHours();
return h >= 19 || h < 8;
}
// Double-buffer: load in hidden img, swap on load (no flicker)
const bufImg = new Image();
bufImg.onload = () => { camImg.src = bufImg.src; };
function refreshCam() {
if (isCamTime()) {
camWrap.style.display = '';
bufImg.src = '/api/cam/pulse-ox?' + Date.now();
} else {
camWrap.style.display = 'none';
}
}
refreshCam();
setInterval(refreshCam, 1000);
</script>
</body>
</html>

View File

@ -17,6 +17,12 @@ function saveAlerts(alerts) {
fs.writeFileSync(ALERTS_FILE, JSON.stringify(alerts, null, 2));
}
const http = require('http');
// Camera proxy config
const HA_URL = 'http://192.168.1.252:8123';
const HA_TOKEN = process.env.HA_TOKEN || '';
// SSE clients
const sseClients = new Set();
@ -52,6 +58,56 @@ app.post('/api/alerts', (req, res) => {
res.status(201).json(alert);
});
// Camera proxy - snapshot from HA
app.get('/api/cam/pulse-ox', (req, res) => {
const haReq = http.get(`${HA_URL}/api/camera_proxy/camera.pulse_ox_live_view`, {
headers: { 'Authorization': `Bearer ${HA_TOKEN}` }
}, (haRes) => {
res.set('Content-Type', haRes.headers['content-type'] || 'image/jpeg');
res.set('Cache-Control', 'no-store');
haRes.pipe(res);
});
haReq.on('error', () => res.status(502).end());
haReq.setTimeout(5000, () => { haReq.destroy(); res.status(504).end(); });
});
// Camera proxy - MJPEG stream from HA
app.get('/api/cam/pulse-ox/stream', (req, res) => {
const haReq = http.get(`${HA_URL}/api/camera_proxy_stream/camera.pulse_ox_live_view`, {
headers: { 'Authorization': `Bearer ${HA_TOKEN}` }
}, (haRes) => {
res.set('Content-Type', haRes.headers['content-type'] || 'multipart/x-mixed-replace; boundary=frame');
res.set('Cache-Control', 'no-store');
haRes.pipe(res);
req.on('close', () => haRes.destroy());
});
haReq.on('error', () => res.status(502).end());
});
// Toggle done
app.patch('/api/alerts/:id/done', (req, res) => {
const alerts = loadAlerts();
const alert = alerts.find(a => a.id === req.params.id);
if (!alert) return res.status(404).json({ error: 'not found' });
alert.done = !!req.body.done;
saveAlerts(alerts);
res.json(alert);
});
// Delete alert
app.delete('/api/alerts/:id', (req, res) => {
const alerts = loadAlerts();
const idx = alerts.findIndex(a => a.id === req.params.id);
if (idx === -1) return res.status(404).json({ error: 'not found' });
alerts.splice(idx, 1);
saveAlerts(alerts);
// Notify SSE clients of removal
for (const client of sseClients) {
client.write(`data: ${JSON.stringify({ type: 'remove', id: req.params.id })}\n\n`);
}
res.json({ status: 'deleted' });
});
// SSE stream
app.get('/api/alerts/stream', (req, res) => {
res.writeHead(200, {
@ -64,6 +120,19 @@ app.get('/api/alerts/stream', (req, res) => {
req.on('close', () => sseClients.delete(res));
});
// Auto-purge done items older than 2 hours
setInterval(() => {
const alerts = loadAlerts();
const cutoff = Date.now() - 2 * 3600000;
const filtered = alerts.filter(a => !(a.done && new Date(a.timestamp).getTime() < cutoff));
if (filtered.length < alerts.length) {
saveAlerts(filtered);
for (const client of sseClients) {
client.write(`data: ${JSON.stringify({ type: 'refresh' })}\n\n`);
}
}
}, 300000); // Check every 5 min
app.listen(PORT, '0.0.0.0', () => {
console.log(`Alert dashboard running on http://0.0.0.0:${PORT}`);
});