alert-dashboard/server.js

139 lines
4.1 KiB
JavaScript

const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();
const PORT = 9202;
const ALERTS_FILE = path.join(__dirname, 'alerts.json');
app.use(express.json());
// Load/save alerts
function loadAlerts() {
try { return JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8')); }
catch { return []; }
}
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();
// Serve dashboard
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'));
});
// List alerts
app.get('/api/alerts', (req, res) => {
res.json(loadAlerts().slice(-50).reverse());
});
// Push alert
app.post('/api/alerts', (req, res) => {
const { message, priority = 'info' } = req.body;
if (!message) return res.status(400).json({ error: 'message required' });
const alert = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
message,
priority,
timestamp: new Date().toISOString()
};
const alerts = loadAlerts();
alerts.push(alert);
// Keep last 100
if (alerts.length > 100) alerts.splice(0, alerts.length - 100);
saveAlerts(alerts);
// Notify SSE clients
for (const client of sseClients) {
client.write(`data: ${JSON.stringify(alert)}\n\n`);
}
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, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.write('data: {"type":"connected"}\n\n');
sseClients.add(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}`);
});