Ansible trigger successes now logged as type 'booking' so they appear in the default filter view. Removed the redundant 'All incl. System' filter button.
1002 lines
44 KiB
TypeScript
1002 lines
44 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', 'semaphore_api_token'];
|
|
|
|
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 };
|
|
|
|
const logId = uid("log");
|
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
.run(logId, new Date().toISOString(), 'system', `${row.name} logged in.`, null, row.id);
|
|
|
|
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`;
|
|
const cmkApiUrl = getSetting('checkmk_api_url') || process.env.CHECKMK_API_URL || '';
|
|
res.json({
|
|
azureEnabled: enabled && Boolean(clientId) && Boolean(tenantId) && Boolean(secret),
|
|
effectiveRedirectUri,
|
|
checkmkEnabled: getSetting('checkmk_enabled') === 'true',
|
|
checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''),
|
|
});
|
|
});
|
|
|
|
// 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', 'azure_allowed_group', 'checkmk_enabled', 'checkmk_api_url', 'checkmk_api_user',
|
|
'checkmk_api_secret', 'checkmk_sync_interval_ms',
|
|
'semaphore_enabled', 'semaphore_api_url', 'semaphore_api_token', 'semaphore_project_id'];
|
|
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),
|
|
semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '',
|
|
semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '',
|
|
}));
|
|
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, semaphoreSetupTemplateId, semaphoreTeardownTemplateId } = 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, semaphoreSetupTemplateId, semaphoreTeardownTemplateId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
.run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '');
|
|
|
|
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), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '' });
|
|
} 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, semaphoreSetupTemplateId, semaphoreTeardownTemplateId } = req.body;
|
|
|
|
db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ?, semaphoreSetupTemplateId = ?, semaphoreTeardownTemplateId = ? WHERE id = ?`)
|
|
.run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', 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), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '' });
|
|
} 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,
|
|
ansibleSetupTriggered: r.ansibleSetupTriggered === 1,
|
|
ansibleTeardownTriggered: r.ansibleTeardownTriggered === 1,
|
|
ansibleSetupJobId: r.ansibleSetupJobId || '',
|
|
ansibleTeardownJobId: r.ansibleTeardownJobId || '',
|
|
}));
|
|
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, async (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, semaphoreTeardownTemplateId FROM labs WHERE id = ?').get(booking.labId) as { name: string; semaphoreTeardownTemplateId: 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);
|
|
|
|
// Trigger teardown if booking had already started and teardown not yet triggered
|
|
const now = new Date();
|
|
if (new Date(booking.startDateTime) <= now && !booking.ansibleTeardownTriggered) {
|
|
const templateId = lab?.semaphoreTeardownTemplateId;
|
|
if (templateId) {
|
|
const jobId = await triggerSemaphoreTask(Number(templateId), {
|
|
booking_id: booking.id, lab_name: lab?.name || '', user_id: booking.userId,
|
|
start_time: booking.startDateTime, end_time: booking.endDateTime,
|
|
});
|
|
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?')
|
|
.run(jobId !== null ? String(jobId) : '', booking.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
ansibleSetupTriggered: r.ansibleSetupTriggered === 1,
|
|
ansibleTeardownTriggered: r.ansibleTeardownTriggered === 1,
|
|
ansibleSetupJobId: r.ansibleSetupJobId || '',
|
|
ansibleTeardownJobId: r.ansibleTeardownJobId || '',
|
|
});
|
|
} 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.
|
|
// -------------------------------------------------------------
|
|
function checkmkHttpHint(status: number): string {
|
|
if (status === 401) return 'HTTP 401 Unauthorized - wrong automation user or secret (check Settings → CheckMK)';
|
|
if (status === 403) return 'HTTP 403 Forbidden - automation user lacks permission in CheckMK';
|
|
if (status === 404) return 'HTTP 404 Not Found - API URL incorrect or site name wrong';
|
|
return `HTTP ${status}`;
|
|
}
|
|
|
|
async function syncCheckMkStatuses() {
|
|
const now = new Date().toISOString();
|
|
|
|
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) {
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
|
.run(uid('log'), now, 'system', 'CheckMK sync skipped — API URL or secret not configured. Check Settings.');
|
|
return;
|
|
}
|
|
|
|
const authHeader = `Bearer ${CHECKMK_API_USER} ${CHECKMK_API_SECRET}`;
|
|
const headers = { Authorization: authHeader, Accept: 'application/json' };
|
|
|
|
// Step 1: build IP → hostname map from host configuration
|
|
// Checks both attributes (explicitly set) and effective_attributes (inherited).
|
|
let ipToHostname: Map<string, string>;
|
|
try {
|
|
const cfgRes = await fetch(
|
|
`${CHECKMK_API_URL}/domain-types/host_config/collections/all`,
|
|
{ headers }
|
|
);
|
|
if (!cfgRes.ok) throw new Error(checkmkHttpHint(cfgRes.status));
|
|
const cfgData = await cfgRes.json();
|
|
ipToHostname = new Map<string, string>();
|
|
for (const host of cfgData?.value ?? []) {
|
|
const ext = host?.extensions;
|
|
const ip: string | undefined =
|
|
ext?.attributes?.ipaddress || ext?.effective_attributes?.ipaddress;
|
|
const name: string | undefined = host?.id;
|
|
if (ip && name) ipToHostname.set(ip, name);
|
|
}
|
|
} catch (err: any) {
|
|
const msg = `CheckMK sync failed — could not fetch host list: ${err?.message ?? err}`;
|
|
console.error('[CheckMK]', msg);
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
|
.run(uid('log'), now, 'system', msg);
|
|
return;
|
|
}
|
|
|
|
// Step 2: for each device, look up its CheckMK hostname by IP, then query the
|
|
// individual monitoring object at /objects/host/{name} for the live state.
|
|
// The collection endpoint (/domain-types/host/collections/all) only returns
|
|
// minimal fields without state, so per-host calls are required.
|
|
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 };
|
|
|
|
for (const dev of rows) {
|
|
const cmkHost = ipToHostname.get(dev.ip);
|
|
if (!cmkHost) {
|
|
if (dev.status !== 'unknown') {
|
|
db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ?, cmkHostname = ? 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 hostRes = await fetch(
|
|
`${CHECKMK_API_URL}/objects/host/${encodeURIComponent(cmkHost)}?columns=state&columns=hard_state&columns=has_been_checked`,
|
|
{ headers }
|
|
);
|
|
if (!hostRes.ok) throw new Error(checkmkHttpHint(hostRes.status));
|
|
const hostData = await hostRes.json();
|
|
|
|
const state: number = hostData?.extensions?.state ?? -1;
|
|
const newStatus = state === 0 ? 'online' : state === 1 || state === 2 ? 'offline' : 'unknown';
|
|
db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ?, cmkHostname = ? WHERE id = ?').run(newStatus, now, cmkHost, 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' | 'unknown']++;
|
|
} catch (err: any) {
|
|
const msg = `CheckMK: status sync failed for ${dev.hostname} (${dev.ip}) — ${err?.message ?? err}`;
|
|
console.error('[CheckMK]', msg);
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)')
|
|
.run(uid('log'), now, 'system', msg, dev.id);
|
|
counts.unknown++;
|
|
}
|
|
}
|
|
|
|
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 });
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------
|
|
// ANSIBLE SEMAPHORE INTEGRATION
|
|
// Triggers Semaphore task templates at booking start (setup) and
|
|
// booking end (teardown). Uses the same self-rescheduling pattern
|
|
// as CheckMK. Template IDs are configured per lab template.
|
|
// -------------------------------------------------------------
|
|
async function triggerSemaphoreTask(templateId: number, extraVars: Record<string, string>): Promise<number | null> {
|
|
const now = new Date().toISOString();
|
|
const apiUrl = getSetting('semaphore_api_url');
|
|
const token = getSetting('semaphore_api_token');
|
|
const projectId = getSetting('semaphore_project_id');
|
|
|
|
if (!apiUrl || !token || !projectId) {
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
|
.run(uid('log'), now, 'system', 'Semaphore trigger skipped — API URL, token, or project ID not configured. Check Settings.');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`${apiUrl}/api/project/${projectId}/tasks`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
},
|
|
body: JSON.stringify({ template_id: templateId, environment: JSON.stringify(extraVars) }),
|
|
});
|
|
if (!res.ok) {
|
|
const hint = res.status === 401 ? 'HTTP 401 Unauthorized — check API token'
|
|
: res.status === 403 ? 'HTTP 403 Forbidden — token lacks permission'
|
|
: res.status === 404 ? 'HTTP 404 — wrong project ID or Semaphore URL'
|
|
: `HTTP ${res.status}`;
|
|
throw new Error(hint);
|
|
}
|
|
const data = await res.json() as { id?: number };
|
|
const jobId = data?.id ?? null;
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
|
.run(uid('log'), now, 'booking', `Semaphore: triggered template #${templateId} → job #${jobId} (booking ${extraVars.booking_id}).`);
|
|
return jobId;
|
|
} catch (err: any) {
|
|
const msg = `Semaphore trigger failed for template #${templateId} — ${err?.message ?? err}`;
|
|
console.error('[Semaphore]', msg);
|
|
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
|
.run(uid('log'), now, 'system', msg);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function checkAndTriggerAnsibleTasks() {
|
|
if (getSetting('semaphore_enabled') !== 'true') return;
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
// Bookings that have started but setup hasn't been triggered yet
|
|
const setupPending = db.prepare(
|
|
`SELECT b.*, l.semaphoreSetupTemplateId, l.name AS labName
|
|
FROM bookings b LEFT JOIN labs l ON b.labId = l.id
|
|
WHERE b.startDateTime <= ? AND b.ansibleSetupTriggered = 0 AND b.status != 'cancelled'`
|
|
).all(now) as any[];
|
|
|
|
for (const row of setupPending) {
|
|
const templateId = row.semaphoreSetupTemplateId;
|
|
if (!templateId) {
|
|
db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1 WHERE id = ?').run(row.id);
|
|
continue;
|
|
}
|
|
const jobId = await triggerSemaphoreTask(Number(templateId), {
|
|
booking_id: row.id, lab_name: row.labName || '', user_id: row.userId,
|
|
start_time: row.startDateTime, end_time: row.endDateTime,
|
|
});
|
|
db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1, ansibleSetupJobId = ? WHERE id = ?')
|
|
.run(jobId !== null ? String(jobId) : '', row.id);
|
|
}
|
|
|
|
// Bookings that have ended but teardown hasn't been triggered yet
|
|
const teardownPending = db.prepare(
|
|
`SELECT b.*, l.semaphoreTeardownTemplateId, l.name AS labName
|
|
FROM bookings b LEFT JOIN labs l ON b.labId = l.id
|
|
WHERE b.endDateTime <= ? AND b.ansibleTeardownTriggered = 0 AND b.status != 'cancelled'`
|
|
).all(now) as any[];
|
|
|
|
for (const row of teardownPending) {
|
|
const templateId = row.semaphoreTeardownTemplateId;
|
|
if (!templateId) {
|
|
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1 WHERE id = ?').run(row.id);
|
|
continue;
|
|
}
|
|
const jobId = await triggerSemaphoreTask(Number(templateId), {
|
|
booking_id: row.id, lab_name: row.labName || '', user_id: row.userId,
|
|
start_time: row.startDateTime, end_time: row.endDateTime,
|
|
});
|
|
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?')
|
|
.run(jobId !== null ? String(jobId) : '', row.id);
|
|
}
|
|
}
|
|
|
|
async function scheduleSemaphoreCheck() {
|
|
await checkAndTriggerAnsibleTasks();
|
|
setTimeout(scheduleSemaphoreCheck, 30_000);
|
|
}
|
|
scheduleSemaphoreCheck();
|
|
|
|
// Proxy Semaphore template list so the UI can populate dropdowns
|
|
app.get('/api/semaphore/templates', requireAuth, async (_req, res) => {
|
|
const apiUrl = getSetting('semaphore_api_url');
|
|
const token = getSetting('semaphore_api_token');
|
|
const projectId = getSetting('semaphore_project_id');
|
|
if (!apiUrl || !token || !projectId) {
|
|
return res.status(400).json({ error: 'Semaphore not fully configured.' });
|
|
}
|
|
try {
|
|
const r = await fetch(`${apiUrl}/api/project/${projectId}/templates`, {
|
|
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
});
|
|
if (!r.ok) return res.status(r.status).json({ error: `Semaphore returned HTTP ${r.status}` });
|
|
res.json(await r.json());
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Manual re-trigger for a specific booking (admin use / testing)
|
|
app.post('/api/semaphore/trigger/:bookingId', requireAuth, async (req, res) => {
|
|
try {
|
|
const { bookingId } = req.params;
|
|
const { type } = req.body as { type: 'setup' | 'teardown' };
|
|
const row = db.prepare(
|
|
`SELECT b.*, l.semaphoreSetupTemplateId, l.semaphoreTeardownTemplateId, l.name AS labName
|
|
FROM bookings b LEFT JOIN labs l ON b.labId = l.id WHERE b.id = ?`
|
|
).get(bookingId) as any;
|
|
if (!row) return res.status(404).json({ error: 'Booking not found.' });
|
|
|
|
const templateId = type === 'setup' ? row.semaphoreSetupTemplateId : row.semaphoreTeardownTemplateId;
|
|
if (!templateId) return res.status(400).json({ error: `No Semaphore ${type} template configured for this lab.` });
|
|
|
|
const jobId = await triggerSemaphoreTask(Number(templateId), {
|
|
booking_id: row.id, lab_name: row.labName || '', user_id: row.userId,
|
|
start_time: row.startDateTime, end_time: row.endDateTime,
|
|
});
|
|
|
|
if (type === 'setup') {
|
|
db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1, ansibleSetupJobId = ? WHERE id = ?')
|
|
.run(jobId !== null ? String(jobId) : '', bookingId);
|
|
} else {
|
|
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?')
|
|
.run(jobId !== null ? String(jobId) : '', bookingId);
|
|
}
|
|
|
|
res.json({ ok: true, jobId });
|
|
} 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);
|
|
});
|