Replace per-device CheckMK URL field with a global, IP-based lookup. The sync job fetches all host configs from CheckMK once per cycle, matches each device by IP address, and updates its status accordingly. Devices not found in CheckMK are reset to 'unknown'. - Add checkmk_enabled / checkmk_api_user settings; toggle in Settings mirrors the Entra ID pattern (fields dim when disabled) - Sync job uses self-scheduling setTimeout so interval changes apply without a server restart; POST /api/checkmk/sync for manual triggers - Status changes and a per-cycle summary are written to the Logbook - Remove checkMkUrl from Device type, form, list view, and detail panel; status badge and CheckMK panel only render when CheckMK is enabled - Booking offline warning suppressed when CheckMK is disabled - Topology status dot color driven purely by device.status
782 lines
33 KiB
TypeScript
782 lines
33 KiB
TypeScript
import 'dotenv/config';
|
|
import express, { Request, Response, NextFunction } from 'express';
|
|
import path from 'path';
|
|
import { createServer as createViteServer } from 'vite';
|
|
import bcrypt from 'bcryptjs';
|
|
import jwt from 'jsonwebtoken';
|
|
import { ConfidentialClientApplication } from '@azure/msal-node';
|
|
import db, { getSetting, setSetting, getAllSettings } from './server-db';
|
|
import { Device, LabTemplate, Booking, LogEntry, User, QuickLink } from './src/types';
|
|
|
|
const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
|
|
const JWT_SECRET = process.env.JWT_SECRET || 'ghostgrid-dev-secret-change-in-production';
|
|
const JWT_EXPIRY = '24h';
|
|
|
|
interface JwtPayload {
|
|
userId: string;
|
|
email: string;
|
|
}
|
|
|
|
declare global {
|
|
namespace Express {
|
|
interface Request {
|
|
user?: JwtPayload;
|
|
}
|
|
}
|
|
}
|
|
|
|
function requireAuth(req: Request, res: Response, next: NextFunction) {
|
|
const authHeader = req.headers['authorization'];
|
|
const token = authHeader?.split(' ')[1];
|
|
if (!token) {
|
|
res.status(401).json({ error: 'Authentication required.' });
|
|
return;
|
|
}
|
|
try {
|
|
const payload = jwt.verify(token, JWT_SECRET) as JwtPayload;
|
|
req.user = payload;
|
|
next();
|
|
} catch {
|
|
res.status(401).json({ error: 'Invalid or expired token.' });
|
|
}
|
|
}
|
|
|
|
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') {
|
|
res.status(403).json({ error: 'Admin access required.' });
|
|
return;
|
|
}
|
|
next();
|
|
}
|
|
|
|
function getMsalClient(): ConfidentialClientApplication | null {
|
|
const clientId = getSetting('azure_client_id');
|
|
const tenantId = getSetting('azure_tenant_id');
|
|
const secret = getSetting('azure_client_secret');
|
|
if (getSetting('azure_enabled') !== 'true' || !clientId || !tenantId || !secret) return null;
|
|
return new ConfidentialClientApplication({
|
|
auth: { clientId, authority: `https://login.microsoftonline.com/${tenantId}`, clientSecret: secret },
|
|
});
|
|
}
|
|
|
|
const SECRET_KEYS = ['azure_client_secret', 'checkmk_api_secret'];
|
|
|
|
function maskSettings(raw: Record<string, string>): Record<string, string> {
|
|
const out = { ...raw };
|
|
for (const k of SECRET_KEYS) {
|
|
if (out[k]) out[k] = '__SET__';
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function startServer() {
|
|
const app = express();
|
|
const PORT = Number(process.env.PORT) || 3000;
|
|
|
|
app.use(express.json());
|
|
|
|
// -------------------------------------------------------------
|
|
// AUTH API
|
|
// -------------------------------------------------------------
|
|
app.post('/api/auth/register', (req, res) => {
|
|
try {
|
|
const { name, email, password } = req.body;
|
|
if (!name || !email || !password) {
|
|
return res.status(400).json({ error: 'Name, email and password are required.' });
|
|
}
|
|
if (password.length < 8) {
|
|
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
|
}
|
|
|
|
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
|
if (existing) {
|
|
return res.status(409).json({ error: 'An account with this email already exists.' });
|
|
}
|
|
|
|
const passwordHash = bcrypt.hashSync(password, 10);
|
|
const id = uid("u");
|
|
|
|
db.prepare('INSERT INTO users (id, name, role, email, password_hash) VALUES (?, ?, ?, ?, ?)')
|
|
.run(id, name, 'User', email, passwordHash);
|
|
|
|
const user = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User;
|
|
const token = jwt.sign({ userId: id, email }, JWT_SECRET, { expiresIn: JWT_EXPIRY });
|
|
|
|
res.status(201).json({ token, user });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/auth/login', (req, res) => {
|
|
try {
|
|
const { email, password } = req.body;
|
|
if (!email || !password) {
|
|
return res.status(400).json({ error: 'Email and password are required.' });
|
|
}
|
|
|
|
const row = db.prepare('SELECT * FROM users WHERE email = ?').get(email) as (User & { password_hash: string }) | undefined;
|
|
if (!row || !bcrypt.compareSync(password, row.password_hash)) {
|
|
return res.status(401).json({ error: 'Invalid email or password.' });
|
|
}
|
|
|
|
const token = jwt.sign({ userId: row.id, email: row.email }, JWT_SECRET, { expiresIn: JWT_EXPIRY });
|
|
const user: User = { id: row.id, name: row.name, role: row.role, email: row.email };
|
|
|
|
res.json({ token, user });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/auth/me', requireAuth, (req, res) => {
|
|
try {
|
|
const user = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(req.user!.userId) as User | undefined;
|
|
if (!user) {
|
|
return res.status(404).json({ error: 'User not found.' });
|
|
}
|
|
res.json(user);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Public: frontend checks this before rendering the Azure login button
|
|
app.get('/api/auth/config', (_req, res) => {
|
|
const enabled = getSetting('azure_enabled') === 'true';
|
|
const clientId = getSetting('azure_client_id');
|
|
const tenantId = getSetting('azure_tenant_id');
|
|
const secret = getSetting('azure_client_secret');
|
|
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`;
|
|
const effectiveRedirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
|
|
res.json({
|
|
azureEnabled: enabled && Boolean(clientId) && Boolean(tenantId) && Boolean(secret),
|
|
effectiveRedirectUri,
|
|
checkmkEnabled: getSetting('checkmk_enabled') === 'true',
|
|
});
|
|
});
|
|
|
|
// Start Azure OAuth flow
|
|
app.get('/api/auth/azure', async (_req, res) => {
|
|
const msalClient = getMsalClient();
|
|
if (!msalClient) {
|
|
return res.redirect('/?auth_error=Azure+login+not+configured');
|
|
}
|
|
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`;
|
|
const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
|
|
try {
|
|
const authCodeUrl = await msalClient.getAuthCodeUrl({
|
|
scopes: ['openid', 'profile', 'email'],
|
|
redirectUri,
|
|
});
|
|
res.redirect(authCodeUrl);
|
|
} catch (err: any) {
|
|
console.error('[Azure Auth] getAuthCodeUrl error:', err);
|
|
res.redirect('/?auth_error=Failed+to+start+Microsoft+login');
|
|
}
|
|
});
|
|
|
|
// Azure OAuth callback
|
|
app.get('/api/auth/azure/callback', async (req, res) => {
|
|
const { code, error, error_description } = req.query;
|
|
if (error) {
|
|
const msg = encodeURIComponent(String(error_description || error));
|
|
return res.redirect(`/?auth_error=${msg}`);
|
|
}
|
|
if (!code) {
|
|
return res.redirect('/?auth_error=No+authorization+code+received');
|
|
}
|
|
const msalClient = getMsalClient();
|
|
if (!msalClient) {
|
|
return res.redirect('/?auth_error=Azure+login+not+configured');
|
|
}
|
|
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`;
|
|
const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
|
|
try {
|
|
const result = await msalClient.acquireTokenByCode({
|
|
code: String(code),
|
|
scopes: ['openid', 'profile', 'email'],
|
|
redirectUri,
|
|
});
|
|
const email = (result.account?.username ?? '').toLowerCase();
|
|
const name = result.account?.name || email;
|
|
if (!email) {
|
|
return res.redirect('/?auth_error=No+email+returned+by+Microsoft');
|
|
}
|
|
const allowedGroup = getSetting('azure_allowed_group');
|
|
if (allowedGroup) {
|
|
const claims = result.idTokenClaims as { groups?: string[] } | undefined;
|
|
if (!claims?.groups?.includes(allowedGroup)) {
|
|
return res.redirect('/?auth_error=Not+a+member+of+the+required+group');
|
|
}
|
|
}
|
|
let user = db.prepare('SELECT id, name, role, email FROM users WHERE email = ?').get(email) as User | undefined;
|
|
if (!user) {
|
|
const id = uid("u");
|
|
db.prepare('INSERT INTO users (id, name, role, email, password_hash) VALUES (?, ?, ?, ?, ?)')
|
|
.run(id, name, 'User', email, '');
|
|
user = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User;
|
|
}
|
|
const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { expiresIn: JWT_EXPIRY });
|
|
res.redirect(`/?token=${encodeURIComponent(token)}`);
|
|
} catch (err: any) {
|
|
console.error('[Azure Auth] acquireTokenByCode error:', err);
|
|
res.redirect('/?auth_error=Authentication+failed');
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------
|
|
// RESTFUL API: Settings (admin only)
|
|
// -------------------------------------------------------------
|
|
app.get('/api/settings', requireAuth, (_req, res) => {
|
|
try {
|
|
res.json(maskSettings(getAllSettings()));
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.put('/api/settings', requireAuth, (req, res) => {
|
|
try {
|
|
const allowed = ['azure_enabled', 'azure_client_id', 'azure_tenant_id', 'azure_client_secret',
|
|
'azure_redirect_uri', 'checkmk_enabled', 'checkmk_api_url', 'checkmk_api_user', 'checkmk_api_secret', 'checkmk_sync_interval_ms'];
|
|
const updates = req.body as Record<string, string>;
|
|
for (const key of allowed) {
|
|
if (key in updates && updates[key] !== '__SET__') {
|
|
setSetting(key, String(updates[key]));
|
|
}
|
|
}
|
|
res.json(maskSettings(getAllSettings()));
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------
|
|
// RESTFUL API: Users
|
|
// -------------------------------------------------------------
|
|
app.get('/api/users', requireAuth, (_req, res) => {
|
|
try {
|
|
const users = db.prepare('SELECT id, name, role, email FROM users').all() as User[];
|
|
res.json(users);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.put('/api/users/:id', requireAuth, (req, res) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const { name, email } = req.body as { name?: string; email?: string };
|
|
if (!name && !email) return res.status(400).json({ error: 'Nothing to update.' });
|
|
const existing = db.prepare('SELECT id, name, email FROM users WHERE id = ?').get(id) as User | undefined;
|
|
if (!existing) return res.status(404).json({ error: 'User not found.' });
|
|
if (email && email !== existing.email) {
|
|
const dupe = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, id);
|
|
if (dupe) return res.status(409).json({ error: 'Email already in use.' });
|
|
}
|
|
db.prepare('UPDATE users SET name = COALESCE(?, name), email = COALESCE(?, email) WHERE id = ?')
|
|
.run(name ?? null, email ?? null, id);
|
|
const updated = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User;
|
|
res.json(updated);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/users/:id', requireAuth, (req, res) => {
|
|
try {
|
|
const id = req.params.id;
|
|
if (id === req.user!.userId) return res.status(400).json({ error: 'You cannot delete your own account.' });
|
|
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id);
|
|
if (!existing) return res.status(404).json({ error: 'User not found.' });
|
|
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);
|
|
res.json({ success: true });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------
|
|
// RESTFUL API: Devices / Inventory
|
|
// -------------------------------------------------------------
|
|
app.get('/api/devices', requireAuth, (_req, res) => {
|
|
try {
|
|
const devices = db.prepare('SELECT * FROM devices').all() as Device[];
|
|
res.json(devices);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/devices', requireAuth, (req, res) => {
|
|
try {
|
|
const { hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt } = req.body;
|
|
if (!hostname || !ip || !type) {
|
|
return res.status(400).json({ error: 'Missing required device specifications.' });
|
|
}
|
|
|
|
const id = uid("dev");
|
|
db.prepare(`
|
|
INSERT INTO devices (id, hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(id, hostname, ip, location || '', notes || '', type, status || 'unknown', emergencySheet || '', lastCheckedAt || null);
|
|
|
|
const logId = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'maintenance',
|
|
`Provisioned a new host candidate "${hostname}" (${ip}) inside location ${location || 'Inventory'}.`,
|
|
id, req.user!.userId);
|
|
|
|
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
|
|
res.status(201).json(device);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.put('/api/devices/:id', requireAuth, (req, res) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const { hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt, operatorName } = req.body;
|
|
|
|
db.prepare(`
|
|
UPDATE devices SET hostname = ?, ip = ?, location = ?, notes = ?, type = ?, status = ?, emergencySheet = ?, lastCheckedAt = ?
|
|
WHERE id = ?
|
|
`).run(hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt ?? null, id);
|
|
|
|
const logId = uid("log");
|
|
const operatorText = operatorName ? `${operatorName} finished ` : 'Updated ';
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'maintenance',
|
|
`${operatorText}refining the device specifications for "${hostname}".`, id, req.user!.userId);
|
|
|
|
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
|
|
res.json(device);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/devices/:id', requireAuth, (req, res) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const dev = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
|
|
if (!dev) return res.status(404).json({ error: 'Device not found.' });
|
|
|
|
db.prepare('DELETE FROM devices WHERE id = ?').run(id);
|
|
|
|
const labs = db.prepare('SELECT * FROM labs').all() as any[];
|
|
const updateLabStmt = db.prepare('UPDATE labs SET deviceIds = ?, topology = ? WHERE id = ?');
|
|
for (const lab of labs) {
|
|
const deviceIds: string[] = JSON.parse(lab.deviceIds);
|
|
const topology: any[] = JSON.parse(lab.topology);
|
|
updateLabStmt.run(
|
|
JSON.stringify(deviceIds.filter(dId => dId !== id)),
|
|
JSON.stringify(topology.filter(t => t.fromDevice !== id && t.toDevice !== id)),
|
|
lab.id
|
|
);
|
|
}
|
|
|
|
const logId = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'maintenance',
|
|
`Permanently removed the host device "${dev.hostname || id}" from the inventory records.`,
|
|
null, req.user!.userId);
|
|
|
|
res.json({ success: true, message: 'Device deleted successfully and cleaned from topologies.' });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------
|
|
// RESTFUL API: Lab Templates
|
|
// -------------------------------------------------------------
|
|
app.get('/api/labs', requireAuth, (_req, res) => {
|
|
try {
|
|
const rows = db.prepare('SELECT * FROM labs').all() as any[];
|
|
const labs: LabTemplate[] = 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)
|
|
}));
|
|
res.json(labs);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/labs', requireAuth, (req, res) => {
|
|
try {
|
|
const { name, description, contactPerson, location, deviceIds, topology } = req.body;
|
|
if (!name || !deviceIds || !Array.isArray(deviceIds)) {
|
|
return res.status(400).json({ error: 'Missing name or associated device configurations.' });
|
|
}
|
|
|
|
const id = uid("lab");
|
|
db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
.run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []));
|
|
|
|
const logId = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'maintenance',
|
|
`Released a new lab template profile "${name}" (${location || 'Staging Area'}) for engineering teams.`,
|
|
req.user!.userId);
|
|
|
|
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
|
|
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) });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.put('/api/labs/:id', requireAuth, (req, res) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const { name, description, contactPerson, location, deviceIds, topology } = req.body;
|
|
|
|
db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ? WHERE id = ?`)
|
|
.run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), id);
|
|
|
|
const logId = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'maintenance',
|
|
`Modified the active topology mapping schema for the "${name}" lab template.`, req.user!.userId);
|
|
|
|
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
|
|
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) });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/labs/:id', requireAuth, (req, res) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const lab = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
|
|
if (!lab) return res.status(404).json({ error: 'Lab template not found.' });
|
|
|
|
db.prepare('DELETE FROM labs WHERE id = ?').run(id);
|
|
db.prepare(`UPDATE bookings SET status = 'cancelled' WHERE labId = ? AND status = 'upcoming'`).run(id);
|
|
|
|
const logId = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'booking',
|
|
`Withdrew the lab testing template "${lab.name || id}".`, req.user!.userId);
|
|
|
|
res.json({ success: true, message: 'Lab template deleted and future reservations cancelled.' });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------
|
|
// RESTFUL API: Bookings / Reservations
|
|
// -------------------------------------------------------------
|
|
app.get('/api/bookings', requireAuth, (_req, res) => {
|
|
try {
|
|
const rows = db.prepare('SELECT * FROM bookings').all() as any[];
|
|
const bookings: Booking[] = 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 any,
|
|
notified: r.notified === 1, emailSent: r.emailSent === 1
|
|
}));
|
|
res.json(bookings);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/bookings', requireAuth, (req, res) => {
|
|
try {
|
|
const { labId, userId, startDateTime, endDateTime, notes, status, operatorName } = req.body;
|
|
if (!labId || !userId || !startDateTime || !endDateTime) {
|
|
return res.status(400).json({ error: 'Missing reservation timestamps or laboratory ID.' });
|
|
}
|
|
|
|
const id = uid("book");
|
|
db.prepare(`INSERT INTO bookings (id, labId, userId, startDateTime, endDateTime, notes, status, notified, emailSent) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 1)`)
|
|
.run(id, labId, userId, startDateTime, endDateTime, notes || '', status || 'upcoming');
|
|
|
|
const lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(labId) as { name: string } | undefined;
|
|
const logId = uid("log");
|
|
const operatorText = operatorName || 'An operator';
|
|
const startF = new Date(startDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
|
const endF = new Date(endDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
|
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'booking',
|
|
`${operatorText} booked the lab template "${lab?.name || 'Unknown'}" from ${startF} to ${endF}.`, userId);
|
|
|
|
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
|
|
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.`
|
|
});
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.put('/api/bookings/:id', requireAuth, (req, res) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const { status, operatorName } = req.body;
|
|
|
|
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
|
|
if (!booking) return res.status(404).json({ error: 'Reservation not found.' });
|
|
|
|
db.prepare('UPDATE bookings SET status = ? WHERE id = ?').run(status, id);
|
|
|
|
if (status === 'cancelled') {
|
|
const lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(booking.labId) as { name: string } | undefined;
|
|
const logId = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'booking',
|
|
`${operatorName || 'An operator'} canceled the reservation for lab template "${lab?.name || 'Unknown'}" and released associated nodes back into the pool.`,
|
|
req.user!.userId);
|
|
}
|
|
|
|
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
|
|
res.json({ 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 });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/bookings/:id', requireAuth, (req, res) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
|
|
if (!booking) return res.status(404).json({ error: 'Reservation not found.' });
|
|
|
|
db.prepare('DELETE FROM bookings WHERE id = ?').run(id);
|
|
const lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(booking.labId) as { name: string } | undefined;
|
|
|
|
const logId = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'booking',
|
|
`Permanently deleted the reservation records for lab "${lab?.name || 'Unknown'}".`, req.user!.userId);
|
|
|
|
res.json({ success: true, message: 'Reservation and its logs have been resolved correctly.' });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------
|
|
// RESTFUL API: Logs
|
|
// -------------------------------------------------------------
|
|
app.get('/api/logs', requireAuth, (_req, res) => {
|
|
try {
|
|
const logs = db.prepare('SELECT * FROM logs ORDER BY timestamp DESC').all() as LogEntry[];
|
|
res.json(logs);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/logs', requireAuth, (req, res) => {
|
|
try {
|
|
const { type, message, deviceId, userId } = req.body;
|
|
if (!message || !type) {
|
|
return res.status(400).json({ error: 'Missing log message or classification type.' });
|
|
}
|
|
|
|
const id = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
.run(id, new Date().toISOString(), type, message, deviceId || null, userId || null);
|
|
|
|
const log = db.prepare('SELECT * FROM logs WHERE id = ?').get(id) as LogEntry;
|
|
res.status(201).json(log);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------
|
|
// RESTFUL API: Quick Links (shared link dashboard)
|
|
// -------------------------------------------------------------
|
|
app.get('/api/links', requireAuth, (_req, res) => {
|
|
try {
|
|
const links = db.prepare('SELECT * FROM links ORDER BY category ASC, title ASC').all() as QuickLink[];
|
|
res.json(links);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/links', requireAuth, (req, res) => {
|
|
try {
|
|
const { title, url, description, category, color } = req.body;
|
|
if (!title || !url) {
|
|
return res.status(400).json({ error: 'A title and a URL are required.' });
|
|
}
|
|
|
|
const id = uid("link");
|
|
const createdAt = new Date().toISOString();
|
|
db.prepare(`INSERT INTO links (id, title, url, description, category, color, createdBy, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
.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;
|
|
res.status(201).json(link);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.put('/api/links/:id', requireAuth, (req, res) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const { title, url, description, category, color } = req.body;
|
|
const existing = db.prepare('SELECT id FROM links WHERE id = ?').get(id);
|
|
if (!existing) return res.status(404).json({ error: 'Link not found.' });
|
|
|
|
db.prepare(`UPDATE links SET title = ?, url = ?, description = ?, category = ?, color = ? WHERE id = ?`)
|
|
.run(title, url, description || '', category || '', color || 'emerald', id);
|
|
|
|
const link = db.prepare('SELECT * FROM links WHERE id = ?').get(id) as QuickLink;
|
|
res.json(link);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/links/:id', requireAuth, (req, res) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const existing = db.prepare('SELECT id FROM links WHERE id = ?').get(id);
|
|
if (!existing) return res.status(404).json({ error: 'Link not found.' });
|
|
|
|
db.prepare('DELETE FROM links WHERE id = ?').run(id);
|
|
res.json({ success: true, message: 'Link removed from the shared dashboard.' });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------
|
|
// VITE / STATIC SERVING
|
|
// -------------------------------------------------------------
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
const vite = await createViteServer({
|
|
server: { middlewareMode: true },
|
|
appType: 'spa',
|
|
});
|
|
app.use(vite.middlewares);
|
|
} else {
|
|
const distPath = path.join(process.cwd(), 'dist');
|
|
app.use(express.static(distPath));
|
|
app.get('*', (_req, res) => {
|
|
res.sendFile(path.join(distPath, 'index.html'));
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// CYCLIC CHECKMK STATUS SYNC
|
|
// Looks up each device by IP address in CheckMK's host_config collection,
|
|
// then fetches the monitoring state. Devices not found in CheckMK are reset
|
|
// to 'unknown'. Runs on a self-scheduling setTimeout so that interval changes
|
|
// in Settings take effect on the next cycle without a server restart.
|
|
// -------------------------------------------------------------
|
|
async function syncCheckMkStatuses() {
|
|
if (getSetting('checkmk_enabled') !== 'true') return;
|
|
const CHECKMK_API_URL = getSetting('checkmk_api_url') || process.env.CHECKMK_API_URL;
|
|
const CHECKMK_API_USER = getSetting('checkmk_api_user') || process.env.CHECKMK_API_USER || 'automation';
|
|
const CHECKMK_API_SECRET = getSetting('checkmk_api_secret') || process.env.CHECKMK_API_SECRET;
|
|
if (!CHECKMK_API_URL || !CHECKMK_API_SECRET) return;
|
|
|
|
// Step 1: build IP → hostname map from CheckMK host configurations
|
|
let ipToHostname: Map<string, string>;
|
|
try {
|
|
const cfgRes = await fetch(
|
|
`${CHECKMK_API_URL}/domain-types/host_config/collections/all`,
|
|
{ headers: { Authorization: `Bearer ${CHECKMK_API_USER} ${CHECKMK_API_SECRET}`, Accept: 'application/json' } }
|
|
);
|
|
if (!cfgRes.ok) throw new Error(`HTTP ${cfgRes.status}`);
|
|
const cfgData = await cfgRes.json();
|
|
ipToHostname = new Map<string, string>();
|
|
for (const host of cfgData?.value ?? []) {
|
|
const ip: string | undefined = host?.extensions?.attributes?.ipaddress;
|
|
const name: string | undefined = host?.id;
|
|
if (ip && name) ipToHostname.set(ip, name);
|
|
}
|
|
} catch (err) {
|
|
console.error('[CheckMK] Failed to fetch host configs:', err);
|
|
return;
|
|
}
|
|
|
|
// Step 2: update each device based on IP lookup and log status changes
|
|
const rows = db.prepare('SELECT id, hostname, ip, status FROM devices').all() as { id: string; hostname: string; ip: string; status: string }[];
|
|
const counts = { online: 0, offline: 0, unknown: 0 };
|
|
const now = new Date().toISOString();
|
|
|
|
for (const dev of rows) {
|
|
const cmkHost = ipToHostname.get(dev.ip);
|
|
if (!cmkHost) {
|
|
if (dev.status !== 'unknown') {
|
|
db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ? WHERE id = ?').run('unknown', now, dev.id);
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)')
|
|
.run(uid('log'), now, 'status', `CheckMK: ${dev.hostname} (${dev.ip}) not found in monitoring — status set to unknown.`, dev.id);
|
|
}
|
|
counts.unknown++;
|
|
continue;
|
|
}
|
|
try {
|
|
const res = await fetch(
|
|
`${CHECKMK_API_URL}/objects/host/${encodeURIComponent(cmkHost)}`,
|
|
{ headers: { Authorization: `Bearer ${CHECKMK_API_USER} ${CHECKMK_API_SECRET}`, Accept: 'application/json' } }
|
|
);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
const hardState: number = data?.extensions?.state ?? -1;
|
|
const newStatus = hardState === 0 ? 'online' : 'offline';
|
|
db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ? WHERE id = ?').run(newStatus, now, dev.id);
|
|
if (dev.status !== newStatus) {
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)')
|
|
.run(uid('log'), now, 'status', `CheckMK: ${dev.hostname} (${dev.ip}) status changed to ${newStatus} (was: ${dev.status}).`, dev.id);
|
|
}
|
|
counts[newStatus as 'online' | 'offline']++;
|
|
} catch (err) {
|
|
console.error(`[CheckMK] Status sync failed for ${dev.hostname} (${dev.ip}):`, err);
|
|
counts.unknown++;
|
|
}
|
|
}
|
|
|
|
// Summary log entry for every sync run
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
|
.run(uid('log'), now, 'system',
|
|
`CheckMK sync completed — ${counts.online} online, ${counts.offline} offline, ${counts.unknown} unknown (${rows.length} devices total).`);
|
|
}
|
|
|
|
async function scheduleSync() {
|
|
await syncCheckMkStatuses();
|
|
const ms = Number(getSetting('checkmk_sync_interval_ms')) || 60_000;
|
|
setTimeout(scheduleSync, ms);
|
|
}
|
|
scheduleSync();
|
|
|
|
app.post('/api/checkmk/sync', requireAuth, async (_req, res) => {
|
|
try {
|
|
await syncCheckMkStatuses();
|
|
res.json({ ok: true });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`[Server] Core Server running at http://0.0.0.0:${PORT}`);
|
|
});
|
|
}
|
|
|
|
startServer().catch(err => {
|
|
console.error('[Server] Critical Crash during bootstrap:', err);
|
|
});
|