feat(realtime): replace device polling with SSE push for all shared data
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,6 +6,7 @@ coverage/
|
|||||||
*.log
|
*.log
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
CLAUDE.MD
|
||||||
|
|
||||||
# local SQLite database
|
# local SQLite database
|
||||||
ghostgrid.db
|
ghostgrid.db
|
||||||
|
|||||||
@ -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]
|
| +-- PUT /{id} # Update status; cancel>teardown trigger [auth]
|
||||||
| +-- DELETE /{id} # Delete booking [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
|
+-- /logs
|
||||||
| +-- GET / # All logs, newest first [auth]
|
| +-- GET / # All logs, newest first [auth]
|
||||||
| +-- POST / # Manual log entry [auth]
|
| +-- POST / # Manual log entry [auth]
|
||||||
@ -462,6 +468,7 @@ Step 2 for each device:
|
|||||||
- on change: write a 'status' log
|
- on change: write a 'status' log
|
||||||
Summary log per cycle: "<online> online, <offline> offline, <unknown> unknown"
|
Summary log per cycle: "<online> online, <offline> offline, <unknown> unknown"
|
||||||
HTTP hints: 401/403/404 mapped to actionable messages (checkmkHttpHint)
|
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
|
### 6.2 Ansible Semaphore — Playbook Automation
|
||||||
@ -482,6 +489,7 @@ triggerSemaphoreTask(templateId, extraVars):
|
|||||||
extraVars = { booking_id, lab_name, user_id, start_time, end_time }
|
extraVars = { booking_id, lab_name, user_id, start_time, end_time }
|
||||||
> store returned job id on booking; log success/failure
|
> store returned job id on booking; log success/failure
|
||||||
(a booking with no template id is marked triggered > not retried)
|
(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' }
|
Manual: POST /api/semaphore/trigger/{bookingId} body { type: 'setup'|'teardown' }
|
||||||
GET /api/semaphore/templates (proxy for UI dropdowns)
|
GET /api/semaphore/templates (proxy for UI dropdowns)
|
||||||
@ -584,12 +592,14 @@ src/
|
|||||||
| selectedBookingForDetails, inventoryHighlightDevice, checkmk{Enabled,BaseUrl}
|
| selectedBookingForDetails, inventoryHighlightDevice, checkmk{Enabled,BaseUrl}
|
||||||
+-- Effects:
|
+-- Effects:
|
||||||
| +-- Startup token verify + OAuth ?token=/?auth_error= handling
|
| +-- Startup token verify + OAuth ?token=/?auth_error= handling
|
||||||
| +-- Load data on login
|
| +-- Load data on login (one Promise.all; initial seed before SSE connects)
|
||||||
| +-- Poll GET /api/devices every 30s (surface CheckMK-driven status changes)
|
| +-- 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)
|
| +-- Booking reminder check every 60s (fires once per upcoming booking ≤30min away)
|
||||||
+-- Handlers: handleAdd/Update/Delete* for bookings, devices, labs, links, users +
|
+-- Handlers: handleAdd/Update/Delete* for bookings, devices, labs, links, users +
|
||||||
handleAddLogManually — call API via authFetch, update local state,
|
handleAddLogManually — call API via authFetch, update local state
|
||||||
most then refetch /api/logs
|
(SSE pushes the authoritative state to all tabs within ~1s)
|
||||||
|
|
||||||
(* persisted to localStorage)
|
(* persisted to localStorage)
|
||||||
```
|
```
|
||||||
|
|||||||
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) {
|
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;
|
const row = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as { role: string } | undefined;
|
||||||
if (!row || row.role.toLowerCase() !== 'admin') {
|
if (!row || row.role.toLowerCase() !== 'admin') {
|
||||||
@ -415,6 +463,7 @@ async function startServer() {
|
|||||||
if (changes.length > 0) {
|
if (changes.length > 0) {
|
||||||
addLog('system', `User ${updated.email} updated: ${changes.join(', ')}.`, { userId: req.user!.userId });
|
addLog('system', `User ${updated.email} updated: ${changes.join(', ')}.`, { userId: req.user!.userId });
|
||||||
}
|
}
|
||||||
|
broadcastUsers();
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
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);
|
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;
|
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 });
|
addLog('system', `User ${updated.email} role changed to ${safeRole}.`, { userId: req.user!.userId });
|
||||||
|
broadcastUsers();
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
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;
|
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.' });
|
if (count <= 1) return res.status(400).json({ error: 'Cannot delete the last user.' });
|
||||||
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||||
|
broadcastUsers();
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -483,6 +534,7 @@ async function startServer() {
|
|||||||
{ deviceId: id, userId: req.user!.userId });
|
{ deviceId: id, userId: req.user!.userId });
|
||||||
|
|
||||||
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
|
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
|
||||||
|
broadcastDevices();
|
||||||
res.status(201).json(device);
|
res.status(201).json(device);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -505,6 +557,7 @@ async function startServer() {
|
|||||||
{ deviceId: id, userId: req.user!.userId });
|
{ deviceId: id, userId: req.user!.userId });
|
||||||
|
|
||||||
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
|
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
|
||||||
|
broadcastDevices();
|
||||||
res.json(device);
|
res.json(device);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
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.`,
|
`Permanently removed the host device "${dev.hostname || id}" from the inventory records.`,
|
||||||
{ userId: req.user!.userId });
|
{ userId: req.user!.userId });
|
||||||
|
|
||||||
|
broadcastDevices();
|
||||||
|
broadcastLabs();
|
||||||
res.json({ success: true, message: 'Device deleted successfully and cleaned from topologies.' });
|
res.json({ success: true, message: 'Device deleted successfully and cleaned from topologies.' });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -580,6 +635,7 @@ async function startServer() {
|
|||||||
{ userId: req.user!.userId });
|
{ userId: req.user!.userId });
|
||||||
|
|
||||||
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
|
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 });
|
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) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -611,6 +667,7 @@ async function startServer() {
|
|||||||
{ userId: req.user!.userId });
|
{ userId: req.user!.userId });
|
||||||
|
|
||||||
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
|
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 });
|
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) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -638,6 +695,9 @@ async function startServer() {
|
|||||||
`Withdrew the lab testing template "${lab.name || id}".`,
|
`Withdrew the lab testing template "${lab.name || id}".`,
|
||||||
{ userId: req.user!.userId });
|
{ userId: req.user!.userId });
|
||||||
|
|
||||||
|
broadcastLabs();
|
||||||
|
broadcastBookings();
|
||||||
|
|
||||||
res.json({ success: true, message: 'Lab template deleted and future reservations cancelled.' });
|
res.json({ success: true, message: 'Lab template deleted and future reservations cancelled.' });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
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) => {
|
app.post('/api/bookings', requireAuth, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { labId, userId, startDateTime, endDateTime, notes, status, operatorName } = req.body;
|
const { labId, userId, startDateTime, endDateTime, notes, status, operatorName } = req.body;
|
||||||
@ -687,6 +785,8 @@ async function startServer() {
|
|||||||
{ userId });
|
{ userId });
|
||||||
|
|
||||||
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
|
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
|
||||||
|
broadcastBookings();
|
||||||
|
broadcastLogs();
|
||||||
res.status(201).json({
|
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 },
|
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.`
|
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;
|
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
|
||||||
|
broadcastBookings();
|
||||||
|
broadcastLogs();
|
||||||
res.json({
|
res.json({
|
||||||
id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime,
|
id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime,
|
||||||
endDateTime: r.endDateTime, notes: r.notes || '', status: r.status,
|
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'}".`,
|
`Permanently deleted the reservation records for lab "${lab?.name || 'Unknown'}".`,
|
||||||
{ userId: req.user!.userId });
|
{ userId: req.user!.userId });
|
||||||
|
|
||||||
|
broadcastBookings();
|
||||||
|
broadcastLogs();
|
||||||
res.json({ success: true, message: 'Reservation and its logs have been resolved correctly.' });
|
res.json({ success: true, message: 'Reservation and its logs have been resolved correctly.' });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
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 id = addLog(type, message, { deviceId: deviceId || null, userId: userId || null });
|
||||||
|
|
||||||
const log = db.prepare('SELECT * FROM logs WHERE id = ?').get(id) as LogEntry;
|
const log = db.prepare('SELECT * FROM logs WHERE id = ?').get(id) as LogEntry;
|
||||||
|
broadcastLogs();
|
||||||
res.status(201).json(log);
|
res.status(201).json(log);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
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);
|
.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;
|
const link = db.prepare('SELECT * FROM links WHERE id = ?').get(id) as QuickLink;
|
||||||
|
broadcastLinks();
|
||||||
res.status(201).json(link);
|
res.status(201).json(link);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -831,6 +937,7 @@ async function startServer() {
|
|||||||
.run(title, url, description || '', category || '', color || 'emerald', id);
|
.run(title, url, description || '', category || '', color || 'emerald', id);
|
||||||
|
|
||||||
const link = db.prepare('SELECT * FROM links WHERE id = ?').get(id) as QuickLink;
|
const link = db.prepare('SELECT * FROM links WHERE id = ?').get(id) as QuickLink;
|
||||||
|
broadcastLinks();
|
||||||
res.json(link);
|
res.json(link);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
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.' });
|
if (!existing) return res.status(404).json({ error: 'Link not found.' });
|
||||||
|
|
||||||
db.prepare('DELETE FROM links WHERE id = ?').run(id);
|
db.prepare('DELETE FROM links WHERE id = ?').run(id);
|
||||||
|
broadcastLinks();
|
||||||
res.json({ success: true, message: 'Link removed from the shared dashboard.' });
|
res.json({ success: true, message: 'Link removed from the shared dashboard.' });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -1034,6 +1142,9 @@ async function startServer() {
|
|||||||
addLog('system',
|
addLog('system',
|
||||||
`CheckMK sync completed — ${counts.online} online, ${counts.offline} offline, ${counts.unknown} unknown (${rows.length} devices total).`,
|
`CheckMK sync completed — ${counts.online} online, ${counts.offline} offline, ${counts.unknown} unknown (${rows.length} devices total).`,
|
||||||
{ timestamp: now });
|
{ timestamp: now });
|
||||||
|
|
||||||
|
broadcastDevices();
|
||||||
|
broadcastLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scheduleSync() {
|
async function scheduleSync() {
|
||||||
@ -1143,6 +1254,9 @@ async function startServer() {
|
|||||||
db.prepare('UPDATE bookings SET semaphoreTeardownTriggered = 1, semaphoreTeardownJobId = ? WHERE id = ?')
|
db.prepare('UPDATE bookings SET semaphoreTeardownTriggered = 1, semaphoreTeardownJobId = ? WHERE id = ?')
|
||||||
.run(jobId !== null ? String(jobId) : '', row.id);
|
.run(jobId !== null ? String(jobId) : '', row.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
broadcastBookings();
|
||||||
|
broadcastLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scheduleSemaphoreCheck() {
|
async function scheduleSemaphoreCheck() {
|
||||||
|
|||||||
48
src/App.tsx
48
src/App.tsx
@ -155,21 +155,44 @@ export default function App() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
|
|
||||||
// Cyclic device-status check: poll the inventory every 30s so CheckMK-driven
|
// SSE connection: real-time push for all shared data.
|
||||||
// status changes (online/offline) surface without a manual reload. The backend
|
// EventSource does not support Authorization headers, so the JWT is passed
|
||||||
// is the source of truth - it syncs each device's status from the CheckMK API.
|
// as a query parameter. The server sends a full snapshot on every (re)connect.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
const refreshDevices = async () => {
|
const token = getToken();
|
||||||
try {
|
if (!token) return;
|
||||||
const res = await authFetch('/api/devices');
|
|
||||||
if (res.ok) setDevices(await res.json());
|
const evtSource = new EventSource(`/api/events?token=${encodeURIComponent(token)}`);
|
||||||
} catch {
|
|
||||||
// transient network/server hiccup - keep last known state, retry next tick
|
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]);
|
}, [currentUser]);
|
||||||
|
|
||||||
// Upcoming-booking reminder - checks every 60s, fires once per booking
|
// Upcoming-booking reminder - checks every 60s, fires once per booking
|
||||||
@ -588,6 +611,7 @@ export default function App() {
|
|||||||
labs={labs}
|
labs={labs}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
|
users={users}
|
||||||
cmkEnabled={cmkEnabled}
|
cmkEnabled={cmkEnabled}
|
||||||
onAddBooking={handleAddBooking}
|
onAddBooking={handleAddBooking}
|
||||||
onCancelBooking={handleCancelBooking}
|
onCancelBooking={handleCancelBooking}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ interface BookingCalendarProps {
|
|||||||
labs: LabTemplate[];
|
labs: LabTemplate[];
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
|
users: User[];
|
||||||
cmkEnabled: boolean;
|
cmkEnabled: boolean;
|
||||||
onAddBooking: (booking: Omit<Booking, 'id' | 'notified' | 'emailSent'>) => void;
|
onAddBooking: (booking: Omit<Booking, 'id' | 'notified' | 'emailSent'>) => void;
|
||||||
onCancelBooking: (id: string) => 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 ──────────────────────────────────────────────────────────────
|
// ── 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({
|
export default function BookingCalendar({
|
||||||
bookings,
|
bookings,
|
||||||
labs,
|
labs,
|
||||||
devices,
|
devices,
|
||||||
currentUser,
|
currentUser,
|
||||||
|
users,
|
||||||
cmkEnabled,
|
cmkEnabled,
|
||||||
onAddBooking,
|
onAddBooking,
|
||||||
onCancelBooking,
|
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'}`}
|
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}`}
|
className={`w-full h-full cursor-pointer transition-all overflow-hidden flex flex-col justify-center ${radius} ${borderCls}`}
|
||||||
>
|
>
|
||||||
{isFirst && (
|
{isFirst && (() => {
|
||||||
<span className="text-[8px] font-semibold leading-tight truncate px-1 text-white/90">
|
const booker = users.find(u => u.id === cur.userId);
|
||||||
{lab?.name ?? 'Device'}
|
const name = booker?.name ?? '';
|
||||||
</span>
|
const label = (isFirst && isLast)
|
||||||
)}
|
? initials(name)
|
||||||
|
: name.split(' ')[0];
|
||||||
|
return (
|
||||||
|
<span className="text-[8px] font-semibold leading-tight truncate px-1 text-white/90">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user