diff --git a/.gitignore b/.gitignore index 68a5f38..0ac307e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ coverage/ *.log .env* !.env.example +CLAUDE.MD # local SQLite database ghostgrid.db diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a09f015..9ac48b7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -382,6 +382,12 @@ All `/api/*` routes return JSON. Every route except the public auth/config endpo | +-- PUT /{id} # Update status; cancel>teardown trigger [auth] | +-- DELETE /{id} # Delete booking [auth] | ++-- /events +| +-- GET / # SSE stream; token via ?token= query param [auth] +| | # Sends full snapshot on connect, then pushes +| | # bookings/devices/labs/logs/links/users-update +| | # events after every mutation or background job +| +-- /logs | +-- GET / # All logs, newest first [auth] | +-- POST / # Manual log entry [auth] @@ -462,6 +468,7 @@ Step 2 for each device: - on change: write a 'status' log Summary log per cycle: " online, offline, unknown" HTTP hints: 401/403/404 mapped to actionable messages (checkmkHttpHint) +After each cycle: broadcastDevices() + broadcastLogs() > SSE push to all clients ``` ### 6.2 Ansible Semaphore — Playbook Automation @@ -482,6 +489,7 @@ triggerSemaphoreTask(templateId, extraVars): extraVars = { booking_id, lab_name, user_id, start_time, end_time } > store returned job id on booking; log success/failure (a booking with no template id is marked triggered > not retried) +After each cycle: broadcastBookings() + broadcastLogs() > SSE push to all clients Manual: POST /api/semaphore/trigger/{bookingId} body { type: 'setup'|'teardown' } GET /api/semaphore/templates (proxy for UI dropdowns) @@ -584,12 +592,14 @@ src/ | selectedBookingForDetails, inventoryHighlightDevice, checkmk{Enabled,BaseUrl} +-- Effects: | +-- Startup token verify + OAuth ?token=/?auth_error= handling -| +-- Load data on login -| +-- Poll GET /api/devices every 30s (surface CheckMK-driven status changes) +| +-- Load data on login (one Promise.all; initial seed before SSE connects) +| +-- SSE connection to GET /api/events — receives full snapshot on (re)connect, +| | then live pushes for bookings/devices/labs/logs/links/users on any mutation +| | or background job; auth-error event triggers logout on token expiry | +-- Booking reminder check every 60s (fires once per upcoming booking ≤30min away) +-- Handlers: handleAdd/Update/Delete* for bookings, devices, labs, links, users + - handleAddLogManually — call API via authFetch, update local state, - most then refetch /api/logs + handleAddLogManually — call API via authFetch, update local state + (SSE pushes the authoritative state to all tabs within ~1s) (* persisted to localStorage) ``` diff --git a/server.ts b/server.ts index c196de2..a11a62e 100644 --- a/server.ts +++ b/server.ts @@ -48,6 +48,54 @@ function requireAuth(req: Request, res: Response, next: NextFunction) { } } +// ------------------------------------------------------------------ +// SSE: real-time push infrastructure +// ------------------------------------------------------------------ +const sseClients = new Set(); + +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() { diff --git a/src/App.tsx b/src/App.tsx index 16f4e0d..7c985ad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -155,21 +155,44 @@ export default function App() { loadData(); }, [currentUser]); - // Cyclic device-status check: poll the inventory every 30s so CheckMK-driven - // status changes (online/offline) surface without a manual reload. The backend - // is the source of truth - it syncs each device's status from the CheckMK API. + // SSE connection: real-time push for all shared data. + // EventSource does not support Authorization headers, so the JWT is passed + // as a query parameter. The server sends a full snapshot on every (re)connect. useEffect(() => { if (!currentUser) return; - const refreshDevices = async () => { - try { - const res = await authFetch('/api/devices'); - if (res.ok) setDevices(await res.json()); - } catch { - // transient network/server hiccup - keep last known state, retry next tick - } + const token = getToken(); + if (!token) return; + + const evtSource = new EventSource(`/api/events?token=${encodeURIComponent(token)}`); + + evtSource.addEventListener('bookings-update', (e: MessageEvent) => { + try { setBookings(JSON.parse(e.data) as Booking[]); } catch {} + }); + evtSource.addEventListener('devices-update', (e: MessageEvent) => { + try { setDevices(JSON.parse(e.data) as Device[]); } catch {} + }); + evtSource.addEventListener('labs-update', (e: MessageEvent) => { + try { setLabs(JSON.parse(e.data) as LabTemplate[]); } catch {} + }); + evtSource.addEventListener('logs-update', (e: MessageEvent) => { + try { setLogs(JSON.parse(e.data) as LogEntry[]); } catch {} + }); + evtSource.addEventListener('links-update', (e: MessageEvent) => { + try { setLinks(JSON.parse(e.data) as QuickLink[]); } catch {} + }); + evtSource.addEventListener('users-update', (e: MessageEvent) => { + try { setUsers(JSON.parse(e.data) as User[]); } catch {} + }); + evtSource.addEventListener('auth-error', () => { + evtSource.close(); + clearSession(); + setCurrentUser(null); + }); + evtSource.onerror = () => { + console.debug('[SSE] Connection error, retrying...'); }; - const id = setInterval(refreshDevices, 30_000); - return () => clearInterval(id); + + return () => evtSource.close(); }, [currentUser]); // Upcoming-booking reminder - checks every 60s, fires once per booking @@ -588,6 +611,7 @@ export default function App() { labs={labs} devices={devices} currentUser={currentUser} + users={users} cmkEnabled={cmkEnabled} onAddBooking={handleAddBooking} onCancelBooking={handleCancelBooking} diff --git a/src/components/BookingCalendar.tsx b/src/components/BookingCalendar.tsx index 904e7a4..89f3314 100644 --- a/src/components/BookingCalendar.tsx +++ b/src/components/BookingCalendar.tsx @@ -17,6 +17,7 @@ interface BookingCalendarProps { labs: LabTemplate[]; devices: Device[]; currentUser: User; + users: User[]; cmkEnabled: boolean; onAddBooking: (booking: Omit) => void; onCancelBooking: (id: string) => void; @@ -68,11 +69,18 @@ const TIME_OPTIONS = ['08:00', '09:00', '10:00', '11:00', '12:00', '13:00', '14: // ── component ────────────────────────────────────────────────────────────── +function initials(name: string): string { + const parts = name.trim().split(/\s+/); + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} + export default function BookingCalendar({ bookings, labs, devices, currentUser, + users, cmkEnabled, onAddBooking, onCancelBooking, @@ -477,11 +485,18 @@ export default function BookingCalendar({ title={`${slot.label} - ${TIME_SLOTS[sIdx + 1]?.label ?? '20:00'}\n${lab?.name ?? 'Device booking'}\n${isMe ? 'My booking' : 'Colleague'}`} className={`w-full h-full cursor-pointer transition-all overflow-hidden flex flex-col justify-center ${radius} ${borderCls}`} > - {isFirst && ( - - {lab?.name ?? 'Device'} - - )} + {isFirst && (() => { + const booker = users.find(u => u.id === cur.userId); + const name = booker?.name ?? ''; + const label = (isFirst && isLast) + ? initials(name) + : name.split(' ')[0]; + return ( + + {label} + + ); + })()} );