refactor(db): rename redirect_path→redirect, add uid/addLog helpers, simplify Caddy CRUD

- Rename caddy.redirect_path to caddy.redirect across schema, server, frontend and docs
- Remove obsolete ALTER TABLE migration (fresh-install model has no migrations)
- Move uid() from server.ts to server-db.ts for shared use
- Add addLog() general helper (prepared statement, shared timestamp support) and
  replace ~24 inline INSERT INTO logs calls throughout server.ts
- Caddy CRUD now takes CaddyRouteInput object instead of positional arguments;
  add/update reuse getCaddyRouteById() to avoid duplicate SELECT
This commit is contained in:
Brückner
2026-06-10 15:08:35 +02:00
parent 515052fbda
commit be007791dc
4 changed files with 117 additions and 125 deletions

View File

@ -282,7 +282,7 @@ CREATE TABLE IF NOT EXISTS caddy (
upstream TEXT NOT NULL, upstream TEXT NOT NULL,
tls INTEGER NOT NULL DEFAULT 1, tls INTEGER NOT NULL DEFAULT 1,
compress INTEGER NOT NULL DEFAULT 1, compress INTEGER NOT NULL DEFAULT 1,
redirect_path TEXT NOT NULL DEFAULT '', -- optional 'redir / <path>' for the bare root redirect TEXT NOT NULL DEFAULT '', -- optional 'redir / <path>' for the bare root
created_at TEXT DEFAULT (datetime('now')) created_at TEXT DEFAULT (datetime('now'))
); );
``` ```
@ -464,8 +464,8 @@ Manual: POST /api/semaphore/trigger/{bookingId} body { type: 'setup'|'teardown
``` ```
buildCaddyfile(): buildCaddyfile():
{ local_certs } # global block { local_certs } # global block
per custom route { [encode] [tls internal] [redir / <redirect_path>] reverse_proxy <upstream> { … } } per custom route { [encode] [tls internal] [redir / <redirect>] reverse_proxy <upstream> { … } }
redirect_path set → `redir / <path>` redirects only the bare root '/' redirect set → `redir / <path>` redirects only the bare root '/'
(other paths pass through; e.g. CheckMK served at /<site>/check_mk/) (other paths pass through; e.g. CheckMK served at /<site>/check_mk/)
every reverse_proxy block carries standard forwarding headers: every reverse_proxy block carries standard forwarding headers:
header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-Proto {scheme}
@ -716,7 +716,7 @@ Backup : ghostgrid.db + ghostgrid.db-wal + ghostgrid.db-shm
``` ```
GhostGrid/ GhostGrid/
+-- server.ts # Express app: all routes, auth, integrations, background jobs +-- server.ts # Express app: all routes, auth, integrations, background jobs
+-- server-db.ts # SQLite connection, full schema, settings/Caddy helpers +-- server-db.ts # SQLite connection, full schema, settings/Caddy/log helpers (uid, addLog)
+-- index.html # Vite HTML entry (#root > src/main.tsx); links /favicon.svg +-- index.html # Vite HTML entry (#root > src/main.tsx); links /favicon.svg
+-- public/ +-- public/
| +-- favicon.svg # app favicon (GhostGrid logo; served at site root by Vite) | +-- favicon.svg # app favicon (GhostGrid logo; served at site root by Vite)

View File

@ -3,6 +3,9 @@ import path from 'path';
export const DB_FILE = path.join(process.cwd(), 'ghostgrid.db'); export const DB_FILE = path.join(process.cwd(), 'ghostgrid.db');
/** App-generated primary key: `${prefix}-${epochMs}-${rand}` (e.g. `log-…`, `dev-…`). */
export const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
console.log(`[Database] Connecting to SQLite database at: ${DB_FILE}`); console.log(`[Database] Connecting to SQLite database at: ${DB_FILE}`);
const db = new Database(DB_FILE); const db = new Database(DB_FILE);
@ -91,14 +94,11 @@ db.exec(`
upstream TEXT NOT NULL, upstream TEXT NOT NULL,
tls INTEGER NOT NULL DEFAULT 1, tls INTEGER NOT NULL DEFAULT 1,
compress INTEGER NOT NULL DEFAULT 1, compress INTEGER NOT NULL DEFAULT 1,
redirect_path TEXT NOT NULL DEFAULT '', redirect TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT (datetime('now')) created_at TEXT DEFAULT (datetime('now'))
); );
`); `);
// redirect_path was added later; ensure it exists on databases created before it.
try { db.exec("ALTER TABLE caddy ADD COLUMN redirect_path TEXT NOT NULL DEFAULT ''"); } catch { /* column already exists */ }
// Seed default settings — INSERT OR IGNORE writes a key only if it is absent. // Seed default settings — INSERT OR IGNORE writes a key only if it is absent.
const DEFAULT_SETTINGS: Record<string, string> = { const DEFAULT_SETTINGS: Record<string, string> = {
azure_enabled: 'false', azure_enabled: 'false',
@ -117,7 +117,7 @@ const DEFAULT_SETTINGS: Record<string, string> = {
semaphore_api_token: '', semaphore_api_token: '',
semaphore_project_id: '', semaphore_project_id: '',
caddy_enabled: 'false', caddy_enabled: 'false',
caddy_admin_url: 'http://localhost:2019', caddy_admin_url: 'http://127.0.0.1:2019',
}; };
const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
@ -137,39 +137,68 @@ export function getAllSettings(): Record<string, string> {
return Object.fromEntries(rows.map(r => [r.key, r.value])); return Object.fromEntries(rows.map(r => [r.key, r.value]));
} }
const insertLog = db.prepare(
'INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)',
);
/**
* Append a logbook entry. `deviceId`/`userId` default to NULL; `timestamp`
* defaults to now (pass one to share a single timestamp across a batch).
* Returns the generated log id.
*/
export function addLog(
type: string,
message: string,
opts: { deviceId?: string | null; userId?: string | null; timestamp?: string } = {},
): string {
const id = uid('log');
insertLog.run(id, opts.timestamp ?? new Date().toISOString(), type, message, opts.deviceId ?? null, opts.userId ?? null);
return id;
}
/** A reverse-proxy route as stored (booleans are SQLite 0/1 integers). */
export interface CaddyRoute { export interface CaddyRoute {
id: number; id: number;
hostname: string; hostname: string;
upstream: string; upstream: string;
tls: number; tls: number;
compress: number; compress: number;
redirect_path: string; redirect: string;
created_at: string; created_at: string;
} }
/** Fields a caller supplies to create or update a route (JS booleans). */
export interface CaddyRouteInput {
hostname: string;
upstream: string;
tls: boolean;
compress: boolean;
redirect?: string;
}
export function getCaddyRoutes(): CaddyRoute[] { export function getCaddyRoutes(): CaddyRoute[] {
return db.prepare('SELECT * FROM caddy ORDER BY id ASC').all() as CaddyRoute[]; return db.prepare('SELECT * FROM caddy ORDER BY id ASC').all() as CaddyRoute[];
} }
export function addCaddyRoute(hostname: string, upstream: string, tls: boolean, compress: boolean, redirectPath = ''): CaddyRoute { export function getCaddyRouteById(id: number): CaddyRoute | undefined {
const { lastInsertRowid } = db.prepare( return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute | undefined;
'INSERT INTO caddy (hostname, upstream, tls, compress, redirect_path) VALUES (?, ?, ?, ?, ?)'
).run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0, redirectPath);
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(lastInsertRowid) as CaddyRoute;
} }
export function updateCaddyRoute(id: number, hostname: string, upstream: string, tls: boolean, compress: boolean, redirectPath = ''): CaddyRoute { export function addCaddyRoute(route: CaddyRouteInput): CaddyRoute {
db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ?, redirect_path = ? WHERE id = ?') const { lastInsertRowid } = db.prepare(
.run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0, redirectPath, id); 'INSERT INTO caddy (hostname, upstream, tls, compress, redirect) VALUES (?, ?, ?, ?, ?)',
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute; ).run(route.hostname, route.upstream, route.tls ? 1 : 0, route.compress ? 1 : 0, route.redirect ?? '');
return getCaddyRouteById(Number(lastInsertRowid))!;
}
export function updateCaddyRoute(id: number, route: CaddyRouteInput): CaddyRoute {
db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ?, redirect = ? WHERE id = ?')
.run(route.hostname, route.upstream, route.tls ? 1 : 0, route.compress ? 1 : 0, route.redirect ?? '', id);
return getCaddyRouteById(id)!;
} }
export function deleteCaddyRoute(id: number): void { export function deleteCaddyRoute(id: number): void {
db.prepare('DELETE FROM caddy WHERE id = ?').run(id); db.prepare('DELETE FROM caddy WHERE id = ?').run(id);
} }
export function getCaddyRouteById(id: number): CaddyRoute | undefined {
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute | undefined;
}
export default db; export default db;

155
server.ts
View File

@ -7,11 +7,9 @@ import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import DatabaseConstructor from 'better-sqlite3'; import DatabaseConstructor from 'better-sqlite3';
import { ConfidentialClientApplication } from '@azure/msal-node'; import { ConfidentialClientApplication } from '@azure/msal-node';
import db, { getSetting, setSetting, getAllSettings, getCaddyRoutes, addCaddyRoute, updateCaddyRoute, deleteCaddyRoute, getCaddyRouteById, DB_FILE } from './server-db'; import db, { uid, addLog, getSetting, setSetting, getAllSettings, getCaddyRoutes, addCaddyRoute, updateCaddyRoute, deleteCaddyRoute, getCaddyRouteById, DB_FILE } from './server-db';
import { Device, LabTemplate, Booking, LogEntry, User, QuickLink } from './src/types'; 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_SECRET = process.env.JWT_SECRET || 'ghostgrid-dev-secret-change-in-production';
const JWT_EXPIRY = '24h'; const JWT_EXPIRY = '24h';
@ -87,10 +85,10 @@ function buildCaddyfile(): string {
lines.push(`${route.hostname} {`); lines.push(`${route.hostname} {`);
if (route.compress) lines.push(' encode zstd gzip'); if (route.compress) lines.push(' encode zstd gzip');
if (route.tls) lines.push(' tls internal'); if (route.tls) lines.push(' tls internal');
if (route.redirect_path) { if (route.redirect) {
// Redirect only the bare root ('/') to the given path — other paths pass // Redirect only the bare root ('/') to the given path — other paths pass
// through to the backend unchanged (e.g. CheckMK at /<site>/check_mk/). // through to the backend unchanged (e.g. CheckMK at /<site>/check_mk/).
const target = route.redirect_path.startsWith('/') ? route.redirect_path : `/${route.redirect_path}`; const target = route.redirect.startsWith('/') ? route.redirect : `/${route.redirect}`;
lines.push(` redir / ${target}`); lines.push(` redir / ${target}`);
} }
lines.push(` reverse_proxy ${route.upstream} {`); lines.push(` reverse_proxy ${route.upstream} {`);
@ -138,24 +136,21 @@ function importCaddyfileRoutes(userId?: string): void {
const upstream = upstreamMatch[1]; const upstream = upstreamMatch[1];
const tls = /tls\s+internal/.test(block); const tls = /tls\s+internal/.test(block);
const compress = /encode/.test(block); const compress = /encode/.test(block);
addCaddyRoute(hostname, upstream, tls, compress); addCaddyRoute({ hostname, upstream, tls, compress });
imported.push(`${hostname}${upstream}`); imported.push(`${hostname}${upstream}`);
} }
} }
i++; i++;
} }
if (imported.length > 0) { if (imported.length > 0) {
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)') addLog('system', `Caddy: imported ${imported.length} route(s) from Caddyfile — ${imported.join(', ')}`, { userId });
.run(uid('log'), new Date().toISOString(), 'system',
`Caddy: imported ${imported.length} route(s) from Caddyfile — ${imported.join(', ')}`,
null, userId ?? null);
} }
} }
async function pushCaddyConfig(): Promise<void> { async function pushCaddyConfig(): Promise<void> {
if (!IS_PRODUCTION) return; if (!IS_PRODUCTION) return;
if (getSetting('caddy_enabled') !== 'true') return; if (getSetting('caddy_enabled') !== 'true') return;
const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019'; const adminUrl = getSetting('caddy_admin_url') || 'http://127.0.0.1:2019';
const body = buildCaddyfile(); const body = buildCaddyfile();
const res = await fetch(`${adminUrl}/load`, { const res = await fetch(`${adminUrl}/load`, {
method: 'POST', method: 'POST',
@ -241,9 +236,7 @@ async function startServer() {
const token = jwt.sign({ userId: row.id, email: row.email }, JWT_SECRET, { expiresIn: JWT_EXPIRY }); 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 user: User = { id: row.id, name: row.name, role: row.role, email: row.email };
const logId = uid("log"); addLog('system', `${row.name} logged in.`, { userId: row.id });
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 }); res.json({ token, user });
} catch (err: any) { } catch (err: any) {
@ -269,7 +262,7 @@ async function startServer() {
const clientId = getSetting('azure_client_id'); const clientId = getSetting('azure_client_id');
const tenantId = getSetting('azure_tenant_id'); const tenantId = getSetting('azure_tenant_id');
const secret = getSetting('azure_client_secret'); const secret = getSetting('azure_client_secret');
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`; const appUrl = process.env.APP_URL || `http://127.0.0.1:${PORT}`;
const effectiveRedirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`; const effectiveRedirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
const cmkApiUrl = getSetting('checkmk_api_url') || process.env.CHECKMK_API_URL || ''; const cmkApiUrl = getSetting('checkmk_api_url') || process.env.CHECKMK_API_URL || '';
res.json({ res.json({
@ -287,7 +280,7 @@ async function startServer() {
if (!msalClient) { if (!msalClient) {
return res.redirect('/?auth_error=Azure+login+not+configured'); return res.redirect('/?auth_error=Azure+login+not+configured');
} }
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`; const appUrl = process.env.APP_URL || `http://127.0.0.1:${PORT}`;
const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`; const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
try { try {
const authCodeUrl = await msalClient.getAuthCodeUrl({ const authCodeUrl = await msalClient.getAuthCodeUrl({
@ -315,7 +308,7 @@ async function startServer() {
if (!msalClient) { if (!msalClient) {
return res.redirect('/?auth_error=Azure+login+not+configured'); return res.redirect('/?auth_error=Azure+login+not+configured');
} }
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`; const appUrl = process.env.APP_URL || `http://127.0.0.1:${PORT}`;
const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`; const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
try { try {
const result = await msalClient.acquireTokenByCode({ const result = await msalClient.acquireTokenByCode({
@ -457,11 +450,9 @@ async function startServer() {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, hostname, ip, location || '', notes || '', type, status || 'unknown', emergencySheet || '', lastCheckedAt || null); `).run(id, hostname, ip, location || '', notes || '', type, status || 'unknown', emergencySheet || '', lastCheckedAt || null);
const logId = uid("log"); addLog('maintenance',
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`) `Provisioned a new host candidate "${hostname}" (${ip}) inside location ${location || 'Inventory'}.`,
.run(logId, new Date().toISOString(), 'maintenance', { deviceId: id, userId: req.user!.userId });
`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; const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
res.status(201).json(device); res.status(201).json(device);
@ -480,11 +471,10 @@ async function startServer() {
WHERE id = ? WHERE id = ?
`).run(hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt ?? null, id); `).run(hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt ?? null, id);
const logId = uid("log");
const operatorText = operatorName ? `${operatorName} finished ` : 'Updated '; const operatorText = operatorName ? `${operatorName} finished ` : 'Updated ';
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`) addLog('maintenance',
.run(logId, new Date().toISOString(), 'maintenance', `${operatorText}refining the device specifications for "${hostname}".`,
`${operatorText}refining the device specifications for "${hostname}".`, id, 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;
res.json(device); res.json(device);
@ -513,11 +503,9 @@ async function startServer() {
); );
} }
const logId = uid("log"); addLog('maintenance',
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`) `Permanently removed the host device "${dev.hostname || id}" from the inventory records.`,
.run(logId, new Date().toISOString(), 'maintenance', { userId: req.user!.userId });
`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.' }); res.json({ success: true, message: 'Device deleted successfully and cleaned from topologies.' });
} catch (err: any) { } catch (err: any) {
@ -555,11 +543,9 @@ async function startServer() {
db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) 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 || ''); .run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '');
const logId = uid("log"); addLog('maintenance',
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`) `Released a new lab template profile "${name}" (${location || 'Staging Area'}) for engineering teams.`,
.run(logId, new Date().toISOString(), 'maintenance', { userId: req.user!.userId });
`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; 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 || '' }); 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 || '' });
@ -576,10 +562,9 @@ async function startServer() {
db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ?, semaphoreSetupTemplateId = ?, semaphoreTeardownTemplateId = ? WHERE id = ?`) 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); .run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', id);
const logId = uid("log"); addLog('maintenance',
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`) `Modified the active topology mapping schema for the "${name}" lab template.`,
.run(logId, new Date().toISOString(), 'maintenance', { userId: req.user!.userId });
`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; 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 || '' }); 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 || '' });
@ -597,10 +582,9 @@ async function startServer() {
db.prepare('DELETE FROM labs WHERE id = ?').run(id); db.prepare('DELETE FROM labs WHERE id = ?').run(id);
db.prepare(`UPDATE bookings SET status = 'cancelled' WHERE labId = ? AND status = 'upcoming'`).run(id); db.prepare(`UPDATE bookings SET status = 'cancelled' WHERE labId = ? AND status = 'upcoming'`).run(id);
const logId = uid("log"); addLog('booking',
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`) `Withdrew the lab testing template "${lab.name || id}".`,
.run(logId, new Date().toISOString(), 'booking', { userId: req.user!.userId });
`Withdrew the lab testing template "${lab.name || id}".`, req.user!.userId);
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) {
@ -642,14 +626,13 @@ async function startServer() {
.run(id, labId, userId, startDateTime, endDateTime, notes || '', status || 'upcoming'); .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 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 operatorText = operatorName || 'An operator';
const startF = new Date(startDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); 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' }); 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 (?, ?, ?, ?, ?)`) addLog('booking',
.run(logId, new Date().toISOString(), 'booking', `${operatorText} booked the lab template "${lab?.name || 'Unknown'}" from ${startF} to ${endF}.`,
`${operatorText} booked the lab template "${lab?.name || 'Unknown'}" from ${startF} to ${endF}.`, 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;
res.status(201).json({ res.status(201).json({
@ -673,11 +656,9 @@ async function startServer() {
if (status === 'cancelled') { if (status === 'cancelled') {
const lab = db.prepare('SELECT name, semaphoreTeardownTemplateId FROM labs WHERE id = ?').get(booking.labId) as { name: string; semaphoreTeardownTemplateId: string } | undefined; const lab = db.prepare('SELECT name, semaphoreTeardownTemplateId FROM labs WHERE id = ?').get(booking.labId) as { name: string; semaphoreTeardownTemplateId: string } | undefined;
const logId = uid("log"); addLog('booking',
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`) `${operatorName || 'An operator'} canceled the reservation for lab template "${lab?.name || 'Unknown'}" and released associated nodes back into the pool.`,
.run(logId, new Date().toISOString(), 'booking', { userId: req.user!.userId });
`${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 // Trigger teardown if booking had already started and teardown not yet triggered
const now = new Date(); const now = new Date();
@ -718,10 +699,9 @@ async function startServer() {
db.prepare('DELETE FROM bookings WHERE id = ?').run(id); 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 lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(booking.labId) as { name: string } | undefined;
const logId = uid("log"); addLog('booking',
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`) `Permanently deleted the reservation records for lab "${lab?.name || 'Unknown'}".`,
.run(logId, new Date().toISOString(), 'booking', { userId: req.user!.userId });
`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.' }); res.json({ success: true, message: 'Reservation and its logs have been resolved correctly.' });
} catch (err: any) { } catch (err: any) {
@ -748,9 +728,7 @@ async function startServer() {
return res.status(400).json({ error: 'Missing log message or classification type.' }); return res.status(400).json({ error: 'Missing log message or classification type.' });
} }
const id = uid("log"); const id = addLog(type, message, { deviceId: deviceId || null, userId: userId || null });
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; const log = db.prepare('SELECT * FROM logs WHERE id = ?').get(id) as LogEntry;
res.status(201).json(log); res.status(201).json(log);
@ -929,8 +907,7 @@ async function startServer() {
const CHECKMK_API_SECRET = getSetting('checkmk_api_secret') || process.env.CHECKMK_API_SECRET; const CHECKMK_API_SECRET = getSetting('checkmk_api_secret') || process.env.CHECKMK_API_SECRET;
if (!CHECKMK_API_URL || !CHECKMK_API_SECRET) { if (!CHECKMK_API_URL || !CHECKMK_API_SECRET) {
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)') addLog('system', 'CheckMK sync skipped — API URL or secret not configured. Check Settings.', { timestamp: now });
.run(uid('log'), now, 'system', 'CheckMK sync skipped — API URL or secret not configured. Check Settings.');
return; return;
} }
@ -958,8 +935,7 @@ async function startServer() {
} catch (err: any) { } catch (err: any) {
const msg = `CheckMK sync failed — could not fetch host list: ${err?.message ?? err}`; const msg = `CheckMK sync failed — could not fetch host list: ${err?.message ?? err}`;
console.error('[CheckMK]', msg); console.error('[CheckMK]', msg);
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)') addLog('system', msg, { timestamp: now });
.run(uid('log'), now, 'system', msg);
return; return;
} }
@ -975,8 +951,7 @@ async function startServer() {
if (!cmkHost) { if (!cmkHost) {
if (dev.status !== 'unknown') { if (dev.status !== 'unknown') {
db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ?, cmkHostname = ? WHERE id = ?').run('unknown', now, '', dev.id); 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 (?, ?, ?, ?, ?)') addLog('status', `CheckMK: ${dev.hostname} (${dev.ip}) not found in monitoring — status set to unknown.`, { deviceId: dev.id, timestamp: now });
.run(uid('log'), now, 'status', `CheckMK: ${dev.hostname} (${dev.ip}) not found in monitoring — status set to unknown.`, dev.id);
} }
counts.unknown++; counts.unknown++;
continue; continue;
@ -993,22 +968,20 @@ async function startServer() {
const newStatus = state === 0 ? 'online' : state === 1 || state === 2 ? 'offline' : 'unknown'; 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); db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ?, cmkHostname = ? WHERE id = ?').run(newStatus, now, cmkHost, dev.id);
if (dev.status !== newStatus) { if (dev.status !== newStatus) {
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)') addLog('status', `CheckMK: ${dev.hostname} (${dev.ip}) status changed to ${newStatus} (was: ${dev.status}).`, { deviceId: dev.id, timestamp: now });
.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']++; counts[newStatus as 'online' | 'offline' | 'unknown']++;
} catch (err: any) { } catch (err: any) {
const msg = `CheckMK: status sync failed for ${dev.hostname} (${dev.ip}) — ${err?.message ?? err}`; const msg = `CheckMK: status sync failed for ${dev.hostname} (${dev.ip}) — ${err?.message ?? err}`;
console.error('[CheckMK]', msg); console.error('[CheckMK]', msg);
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)') addLog('system', msg, { deviceId: dev.id, timestamp: now });
.run(uid('log'), now, 'system', msg, dev.id);
counts.unknown++; counts.unknown++;
} }
} }
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)') addLog('system',
.run(uid('log'), now, '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 });
} }
async function scheduleSync() { async function scheduleSync() {
@ -1034,14 +1007,12 @@ async function startServer() {
// as CheckMK. Template IDs are configured per lab template. // as CheckMK. Template IDs are configured per lab template.
// ------------------------------------------------------------- // -------------------------------------------------------------
async function triggerSemaphoreTask(templateId: number, extraVars: Record<string, string>): Promise<number | null> { async function triggerSemaphoreTask(templateId: number, extraVars: Record<string, string>): Promise<number | null> {
const now = new Date().toISOString();
const apiUrl = getSetting('semaphore_api_url'); const apiUrl = getSetting('semaphore_api_url');
const token = getSetting('semaphore_api_token'); const token = getSetting('semaphore_api_token');
const projectId = getSetting('semaphore_project_id'); const projectId = getSetting('semaphore_project_id');
if (!apiUrl || !token || !projectId) { if (!apiUrl || !token || !projectId) {
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)') addLog('system', 'Semaphore trigger skipped — API URL, token, or project ID not configured. Check Settings.');
.run(uid('log'), now, 'system', 'Semaphore trigger skipped — API URL, token, or project ID not configured. Check Settings.');
return null; return null;
} }
@ -1064,14 +1035,12 @@ async function startServer() {
} }
const data = await res.json() as { id?: number }; const data = await res.json() as { id?: number };
const jobId = data?.id ?? null; const jobId = data?.id ?? null;
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)') addLog('booking', `Semaphore: triggered template #${templateId} > job #${jobId} (booking ${extraVars.booking_id}).`);
.run(uid('log'), now, 'booking', `Semaphore: triggered template #${templateId} > job #${jobId} (booking ${extraVars.booking_id}).`);
return jobId; return jobId;
} catch (err: any) { } catch (err: any) {
const msg = `Semaphore trigger failed for template #${templateId}${err?.message ?? err}`; const msg = `Semaphore trigger failed for template #${templateId}${err?.message ?? err}`;
console.error('[Semaphore]', msg); console.error('[Semaphore]', msg);
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)') addLog('system', msg);
.run(uid('log'), now, 'system', msg);
return null; return null;
} }
} }
@ -1187,7 +1156,7 @@ async function startServer() {
// ------------------------------------------------------------- // -------------------------------------------------------------
app.get('/api/caddy/status', requireAuth, async (_req, res) => { app.get('/api/caddy/status', requireAuth, async (_req, res) => {
try { try {
const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019'; const adminUrl = getSetting('caddy_admin_url') || 'http://127.0.0.1:2019';
const r = await fetch(`${adminUrl}/config/`, { signal: AbortSignal.timeout(2000) }); const r = await fetch(`${adminUrl}/config/`, { signal: AbortSignal.timeout(2000) });
res.json({ available: r.ok }); res.json({ available: r.ok });
} catch { } catch {
@ -1206,16 +1175,14 @@ async function startServer() {
app.post('/api/caddy/routes', requireAuth, async (req, res) => { app.post('/api/caddy/routes', requireAuth, async (req, res) => {
try { try {
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
const { hostname, upstream, tls, compress, redirectPath } = req.body as { const { hostname, upstream, tls, compress, redirect } = req.body as {
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirectPath?: string; hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirect?: string;
}; };
if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' }); if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' });
if (getCaddyRoutes().some(r => r.hostname === hostname.trim())) if (getCaddyRoutes().some(r => r.hostname === hostname.trim()))
return res.status(409).json({ error: `Route for ${hostname.trim()} already exists.` }); return res.status(409).json({ error: `Route for ${hostname.trim()} already exists.` });
const route = addCaddyRoute(hostname.trim(), upstream.trim(), tls !== false, compress !== false, (redirectPath ?? '').trim()); const route = addCaddyRoute({ hostname: hostname.trim(), upstream: upstream.trim(), tls: tls !== false, compress: compress !== false, redirect: (redirect ?? '').trim() });
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)') addLog('system', `Caddy route added: ${hostname.trim()}${upstream.trim()}`, { userId: req.user!.userId });
.run(uid('log'), new Date().toISOString(), 'system',
`Caddy route added: ${hostname.trim()}${upstream.trim()}`, null, req.user!.userId);
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route add:', err.message)); pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route add:', err.message));
res.json(route); res.json(route);
} catch (err: any) { } catch (err: any) {
@ -1228,14 +1195,12 @@ async function startServer() {
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
const id = Number(req.params.id); const id = Number(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid route id.' }); if (!id) return res.status(400).json({ error: 'Invalid route id.' });
const { hostname, upstream, tls, compress, redirectPath } = req.body as { const { hostname, upstream, tls, compress, redirect } = req.body as {
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirectPath?: string; hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirect?: string;
}; };
if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' }); if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' });
const route = updateCaddyRoute(id, hostname.trim(), upstream.trim(), tls !== false, compress !== false, (redirectPath ?? '').trim()); const route = updateCaddyRoute(id, { hostname: hostname.trim(), upstream: upstream.trim(), tls: tls !== false, compress: compress !== false, redirect: (redirect ?? '').trim() });
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)') addLog('system', `Caddy route updated: ${hostname.trim()}${upstream.trim()}`, { userId: req.user!.userId });
.run(uid('log'), new Date().toISOString(), 'system',
`Caddy route updated: ${hostname.trim()}${upstream.trim()}`, null, req.user!.userId);
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route update:', err.message)); pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route update:', err.message));
res.json(route); res.json(route);
} catch (err: any) { } catch (err: any) {
@ -1251,9 +1216,7 @@ async function startServer() {
const existing = getCaddyRouteById(id); const existing = getCaddyRouteById(id);
deleteCaddyRoute(id); deleteCaddyRoute(id);
if (existing) { if (existing) {
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)') addLog('system', `Caddy route deleted: ${existing.hostname}${existing.upstream}`, { userId: req.user!.userId });
.run(uid('log'), new Date().toISOString(), 'system',
`Caddy route deleted: ${existing.hostname}${existing.upstream}`, null, req.user!.userId);
} }
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route delete:', err.message)); pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route delete:', err.message));
res.status(204).send(); res.status(204).send();

View File

@ -35,7 +35,7 @@ interface CaddyRoute {
upstream: string; upstream: string;
tls: number; tls: number;
compress: number; compress: number;
redirect_path: string; redirect: string;
} }
interface DbInfo { interface DbInfo {
@ -384,7 +384,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
try { try {
const res = await authFetch('/api/caddy/routes', { const res = await authFetch('/api/caddy/routes', {
method: 'POST', method: 'POST',
body: JSON.stringify({ hostname: newHostname.trim(), upstream: newUpstream.trim(), tls: newTls, compress: newCompress, redirectPath: newRedirect.trim() }), body: JSON.stringify({ hostname: newHostname.trim(), upstream: newUpstream.trim(), tls: newTls, compress: newCompress, redirect: newRedirect.trim() }),
}); });
if (!res.ok) { if (!res.ok) {
const d = await res.json(); const d = await res.json();
@ -424,7 +424,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setEditUpstream(r.upstream); setEditUpstream(r.upstream);
setEditTls(r.tls === 1); setEditTls(r.tls === 1);
setEditCompress(r.compress === 1); setEditCompress(r.compress === 1);
setEditRedirect(r.redirect_path || ''); setEditRedirect(r.redirect || '');
} }
function handleEditCancel() { function handleEditCancel() {
@ -437,7 +437,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
try { try {
const res = await authFetch(`/api/caddy/routes/${id}`, { const res = await authFetch(`/api/caddy/routes/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ hostname: editHostname.trim(), upstream: editUpstream.trim(), tls: editTls, compress: editCompress, redirectPath: editRedirect.trim() }), body: JSON.stringify({ hostname: editHostname.trim(), upstream: editUpstream.trim(), tls: editTls, compress: editCompress, redirect: editRedirect.trim() }),
}); });
if (!res.ok) { if (!res.ok) {
const d = await res.json(); const d = await res.json();
@ -1013,7 +1013,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<span className="text-[11px] font-mono text-slate-400 truncate">{r.upstream}</span> <span className="text-[11px] font-mono text-slate-400 truncate">{r.upstream}</span>
{r.tls ? <span className="text-[9px] font-semibold text-sky-500 font-mono">TLS</span> : null} {r.tls ? <span className="text-[9px] font-semibold text-sky-500 font-mono">TLS</span> : null}
{r.compress ? <span className="text-[9px] font-semibold text-slate-500 font-mono">GZ</span> : null} {r.compress ? <span className="text-[9px] font-semibold text-slate-500 font-mono">GZ</span> : null}
{r.redirect_path ? <span className="text-[9px] font-mono text-slate-500 truncate">&#8627; {r.redirect_path}</span> : null} {r.redirect ? <span className="text-[9px] font-mono text-slate-500 truncate">&#8627; {r.redirect}</span> : null}
</div> </div>
<div className="flex items-center gap-1 ml-3 shrink-0"> <div className="flex items-center gap-1 ml-3 shrink-0">
<button type="button" onClick={() => handleEditStart(r)} <button type="button" onClick={() => handleEditStart(r)}