feat(realtime): replace device polling with SSE push for all shared data
This commit is contained in:
114
server.ts
114
server.ts
@ -48,6 +48,54 @@ function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// SSE: real-time push infrastructure
|
||||
// ------------------------------------------------------------------
|
||||
const sseClients = new Set<Response>();
|
||||
|
||||
function broadcast(eventName: string, data: unknown): void {
|
||||
if (sseClients.size === 0) return;
|
||||
const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
for (const client of sseClients) {
|
||||
try { client.write(payload); }
|
||||
catch { sseClients.delete(client); }
|
||||
}
|
||||
}
|
||||
|
||||
function getBookingsData(): Booking[] {
|
||||
const rows = db.prepare('SELECT * FROM bookings').all() as any[];
|
||||
return rows.map(r => ({
|
||||
id: r.id, labId: r.labId, userId: r.userId,
|
||||
startDateTime: r.startDateTime, endDateTime: r.endDateTime,
|
||||
notes: r.notes || '', status: r.status as Booking['status'],
|
||||
notified: r.notified === 1, emailSent: r.emailSent === 1,
|
||||
semaphoreSetupTriggered: r.semaphoreSetupTriggered === 1,
|
||||
semaphoreTeardownTriggered: r.semaphoreTeardownTriggered === 1,
|
||||
semaphoreSetupJobId: r.semaphoreSetupJobId || '',
|
||||
semaphoreTeardownJobId: r.semaphoreTeardownJobId || '',
|
||||
}));
|
||||
}
|
||||
|
||||
function getLabsData(): LabTemplate[] {
|
||||
const rows = db.prepare('SELECT * FROM labs').all() as any[];
|
||||
return rows.map(r => ({
|
||||
id: r.id, name: r.name, description: r.description,
|
||||
contactPerson: r.contactPerson, location: r.location,
|
||||
deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology),
|
||||
semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '',
|
||||
semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '',
|
||||
scope: (r.scope === 'personal' ? 'personal' : 'global') as 'global' | 'personal',
|
||||
ownerId: r.ownerId ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
function broadcastBookings() { broadcast('bookings-update', getBookingsData()); }
|
||||
function broadcastDevices() { broadcast('devices-update', db.prepare('SELECT * FROM devices').all()); }
|
||||
function broadcastLabs() { broadcast('labs-update', getLabsData()); }
|
||||
function broadcastLogs() { broadcast('logs-update', db.prepare('SELECT * FROM logs ORDER BY timestamp DESC').all()); }
|
||||
function broadcastLinks() { broadcast('links-update', db.prepare('SELECT * FROM links ORDER BY category ASC, title ASC').all()); }
|
||||
function broadcastUsers() { broadcast('users-update', db.prepare('SELECT id, name, role, email FROM users').all()); }
|
||||
|
||||
function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||
const row = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as { role: string } | undefined;
|
||||
if (!row || row.role.toLowerCase() !== 'admin') {
|
||||
@ -415,6 +463,7 @@ async function startServer() {
|
||||
if (changes.length > 0) {
|
||||
addLog('system', `User ${updated.email} updated: ${changes.join(', ')}.`, { userId: req.user!.userId });
|
||||
}
|
||||
broadcastUsers();
|
||||
res.json(updated);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -432,6 +481,7 @@ async function startServer() {
|
||||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(safeRole, id);
|
||||
const updated = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User;
|
||||
addLog('system', `User ${updated.email} role changed to ${safeRole}.`, { userId: req.user!.userId });
|
||||
broadcastUsers();
|
||||
res.json(updated);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -447,6 +497,7 @@ async function startServer() {
|
||||
const count = (db.prepare('SELECT COUNT(*) as n FROM users').get() as { n: number }).n;
|
||||
if (count <= 1) return res.status(400).json({ error: 'Cannot delete the last user.' });
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
broadcastUsers();
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -483,6 +534,7 @@ async function startServer() {
|
||||
{ deviceId: id, userId: req.user!.userId });
|
||||
|
||||
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
|
||||
broadcastDevices();
|
||||
res.status(201).json(device);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -505,6 +557,7 @@ async function startServer() {
|
||||
{ deviceId: id, userId: req.user!.userId });
|
||||
|
||||
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
|
||||
broadcastDevices();
|
||||
res.json(device);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -535,6 +588,8 @@ async function startServer() {
|
||||
`Permanently removed the host device "${dev.hostname || id}" from the inventory records.`,
|
||||
{ userId: req.user!.userId });
|
||||
|
||||
broadcastDevices();
|
||||
broadcastLabs();
|
||||
res.json({ success: true, message: 'Device deleted successfully and cleaned from topologies.' });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -580,6 +635,7 @@ async function startServer() {
|
||||
{ userId: req.user!.userId });
|
||||
|
||||
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
|
||||
broadcastLabs();
|
||||
res.status(201).json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', scope: r.scope, ownerId: r.ownerId });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -611,6 +667,7 @@ async function startServer() {
|
||||
{ userId: req.user!.userId });
|
||||
|
||||
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
|
||||
broadcastLabs();
|
||||
res.json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', scope: r.scope, ownerId: r.ownerId });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -638,6 +695,9 @@ async function startServer() {
|
||||
`Withdrew the lab testing template "${lab.name || id}".`,
|
||||
{ userId: req.user!.userId });
|
||||
|
||||
broadcastLabs();
|
||||
broadcastBookings();
|
||||
|
||||
res.json({ success: true, message: 'Lab template deleted and future reservations cancelled.' });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -666,6 +726,44 @@ async function startServer() {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/events', (req: Request, res: Response) => {
|
||||
const token = req.query.token as string | undefined;
|
||||
if (!token) { res.status(401).json({ error: 'Authentication required.' }); return; }
|
||||
try {
|
||||
jwt.verify(token, JWT_SECRET) as JwtPayload;
|
||||
} catch {
|
||||
res.set('Content-Type', 'text/event-stream');
|
||||
res.flushHeaders();
|
||||
res.write('event: auth-error\ndata: {"error":"token_expired"}\n\n');
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.flushHeaders();
|
||||
|
||||
res.write(`event: bookings-update\ndata: ${JSON.stringify(getBookingsData())}\n\n`);
|
||||
res.write(`event: devices-update\ndata: ${JSON.stringify(db.prepare('SELECT * FROM devices').all())}\n\n`);
|
||||
res.write(`event: labs-update\ndata: ${JSON.stringify(getLabsData())}\n\n`);
|
||||
res.write(`event: logs-update\ndata: ${JSON.stringify(db.prepare('SELECT * FROM logs ORDER BY timestamp DESC').all())}\n\n`);
|
||||
res.write(`event: links-update\ndata: ${JSON.stringify(db.prepare('SELECT * FROM links ORDER BY category ASC, title ASC').all())}\n\n`);
|
||||
res.write(`event: users-update\ndata: ${JSON.stringify(db.prepare('SELECT id, name, role, email FROM users').all())}\n\n`);
|
||||
|
||||
sseClients.add(res);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
try { res.write(': heartbeat\n\n'); }
|
||||
catch { clearInterval(heartbeat); sseClients.delete(res); }
|
||||
}, 30_000);
|
||||
|
||||
req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); });
|
||||
});
|
||||
|
||||
app.post('/api/bookings', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { labId, userId, startDateTime, endDateTime, notes, status, operatorName } = req.body;
|
||||
@ -687,6 +785,8 @@ async function startServer() {
|
||||
{ userId });
|
||||
|
||||
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
|
||||
broadcastBookings();
|
||||
broadcastLogs();
|
||||
res.status(201).json({
|
||||
booking: { id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime, endDateTime: r.endDateTime, notes: r.notes || '', status: r.status, notified: r.notified === 1, emailSent: r.emailSent === 1 },
|
||||
alertGenerated: `Reservation successfully approved: Lab "${lab?.name || 'Scenario'}" is reserved for your exclusive usage during this window.`
|
||||
@ -728,6 +828,8 @@ async function startServer() {
|
||||
}
|
||||
|
||||
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
|
||||
broadcastBookings();
|
||||
broadcastLogs();
|
||||
res.json({
|
||||
id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime,
|
||||
endDateTime: r.endDateTime, notes: r.notes || '', status: r.status,
|
||||
@ -755,6 +857,8 @@ async function startServer() {
|
||||
`Permanently deleted the reservation records for lab "${lab?.name || 'Unknown'}".`,
|
||||
{ userId: req.user!.userId });
|
||||
|
||||
broadcastBookings();
|
||||
broadcastLogs();
|
||||
res.json({ success: true, message: 'Reservation and its logs have been resolved correctly.' });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -783,6 +887,7 @@ async function startServer() {
|
||||
const id = addLog(type, message, { deviceId: deviceId || null, userId: userId || null });
|
||||
|
||||
const log = db.prepare('SELECT * FROM logs WHERE id = ?').get(id) as LogEntry;
|
||||
broadcastLogs();
|
||||
res.status(201).json(log);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -814,6 +919,7 @@ async function startServer() {
|
||||
.run(id, title, url, description || '', category || '', color || 'emerald', req.user!.userId, createdAt);
|
||||
|
||||
const link = db.prepare('SELECT * FROM links WHERE id = ?').get(id) as QuickLink;
|
||||
broadcastLinks();
|
||||
res.status(201).json(link);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -831,6 +937,7 @@ async function startServer() {
|
||||
.run(title, url, description || '', category || '', color || 'emerald', id);
|
||||
|
||||
const link = db.prepare('SELECT * FROM links WHERE id = ?').get(id) as QuickLink;
|
||||
broadcastLinks();
|
||||
res.json(link);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -844,6 +951,7 @@ async function startServer() {
|
||||
if (!existing) return res.status(404).json({ error: 'Link not found.' });
|
||||
|
||||
db.prepare('DELETE FROM links WHERE id = ?').run(id);
|
||||
broadcastLinks();
|
||||
res.json({ success: true, message: 'Link removed from the shared dashboard.' });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -1034,6 +1142,9 @@ async function startServer() {
|
||||
addLog('system',
|
||||
`CheckMK sync completed — ${counts.online} online, ${counts.offline} offline, ${counts.unknown} unknown (${rows.length} devices total).`,
|
||||
{ timestamp: now });
|
||||
|
||||
broadcastDevices();
|
||||
broadcastLogs();
|
||||
}
|
||||
|
||||
async function scheduleSync() {
|
||||
@ -1143,6 +1254,9 @@ async function startServer() {
|
||||
db.prepare('UPDATE bookings SET semaphoreTeardownTriggered = 1, semaphoreTeardownJobId = ? WHERE id = ?')
|
||||
.run(jobId !== null ? String(jobId) : '', row.id);
|
||||
}
|
||||
|
||||
broadcastBookings();
|
||||
broadcastLogs();
|
||||
}
|
||||
|
||||
async function scheduleSemaphoreCheck() {
|
||||
|
||||
Reference in New Issue
Block a user