diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 39e3b75..a09f015 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -115,7 +115,7 @@ | Component | Technology | Purpose | |-----------|------------|---------| | Database | SQLite via `better-sqlite3` | Single file `ghostgrid.db`, WAL journal mode, synchronous queries | -| Schema | Idempotent `CREATE TABLE IF NOT EXISTS` | 8 tables defined in full and created on boot (fresh-install model, no migrations) | +| Schema | Idempotent `CREATE TABLE IF NOT EXISTS` | Baseline 8 tables created on boot via `server-db.ts`; additive changes to live DBs go in `server-migrations.ts` (see §4.4) | | Settings store | key/value `settings` table | Runtime config for all integrations, seeded with `INSERT OR IGNORE` | --- @@ -195,7 +195,7 @@ Networking (optional, managed in-app) ## 4. Database Schema Design -SQLite file `ghostgrid.db` in `process.cwd()`, opened with `journal_mode = WAL`. Every table is defined in full and created idempotently on boot (`CREATE TABLE IF NOT EXISTS`) — the app assumes a fresh install, so there is no migration layer. +SQLite file `ghostgrid.db` in `process.cwd()`, opened with `journal_mode = WAL`. Baseline tables are created idempotently on boot (`CREATE TABLE IF NOT EXISTS` in `server-db.ts`). Additive changes to live DBs (new columns, tables, default settings) are applied by the migration runner in `server-migrations.ts`. See §4.4. ### 4.1 Schema (as created in `server-db.ts`) @@ -231,8 +231,11 @@ CREATE TABLE IF NOT EXISTS labs ( deviceIds TEXT NOT NULL, -- JSON string: string[] topology TEXT NOT NULL, -- JSON string: TopologyLink[] semaphoreSetupTemplateId TEXT NOT NULL DEFAULT '', - semaphoreTeardownTemplateId TEXT NOT NULL DEFAULT '' + semaphoreTeardownTemplateId TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT 'global', -- 'global' | 'personal' + ownerId TEXT NOT NULL DEFAULT '' -- userId of creator; '' = legacy (pre-migration) ); +-- scope/ownerId were added after initial release via idempotent ALTER TABLE in server-db.ts CREATE TABLE IF NOT EXISTS bookings ( id TEXT PRIMARY KEY, @@ -277,12 +280,13 @@ CREATE TABLE IF NOT EXISTS settings ( ); CREATE TABLE IF NOT EXISTS caddy ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - hostname TEXT NOT NULL, - upstream TEXT NOT NULL, - tls INTEGER NOT NULL DEFAULT 1, - compress INTEGER NOT NULL DEFAULT 1, - created_at TEXT DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + hostname TEXT NOT NULL, + upstream TEXT NOT NULL, + tls INTEGER NOT NULL DEFAULT 1, + compress INTEGER NOT NULL DEFAULT 1, + redirect TEXT NOT NULL DEFAULT '', -- optional 'redir / ' for the bare root + created_at TEXT DEFAULT (datetime('now')) ); ``` @@ -294,7 +298,7 @@ CREATE TABLE IF NOT EXISTS caddy ( | JSON columns | `labs.deviceIds` and `labs.topology` are JSON strings, parsed in the API layer | | Booleans | Booking flags (`notified`, `emailSent`, `ansible*Triggered`) are `INTEGER` 0/1, mapped to JS booleans on read | | Cascade | No FK cascades; referential cleanup is done in code (e.g. deleting a device scrubs it from every lab's `deviceIds`/`topology`) | -| Schema changes | Fresh-install model — edit the `CREATE TABLE` block in `server-db.ts` directly; there is no migration helper | +| Schema changes | Baseline schema in `server-db.ts` (`CREATE TABLE IF NOT EXISTS`). New columns / tables / default settings for live DBs go in `server-migrations.ts` as an appended migration object. See §4.4. | ### 4.3 Settings (key/value config) @@ -309,6 +313,29 @@ Seeded with `INSERT OR IGNORE` (defaults below). Secret keys are never returned 🔒 = in `SECRET_KEYS`, masked on read, only updated when a non-`__SET__` value is sent. +### 4.4 Migration System + +Additive schema changes to live databases are handled by `server-migrations.ts`. The runner executes once at the start of `startServer()`, before routes or background jobs, and is synchronous (`better-sqlite3`). + +**How it works:** + +1. Creates (idempotently) a `_migrations` table: `id TEXT PRIMARY KEY, applied_at TEXT`. +2. Iterates the `migrations` array in order. For each entry whose `id` is not in `_migrations`, runs `up(db)` inside a transaction and records the id on success. +3. If `up()` throws, the transaction rolls back and the id is not recorded — the migration retries on the next start. +4. Already-applied migrations are skipped forever. + +**Rules for adding a migration:** + +- Append to the end of the `migrations` array in `server-migrations.ts`. **Never reorder or remove entries.** +- Use a unique, descriptive `id` — format: `NNNN_short_description` (e.g. `0001_bookings_add_color`). +- Only additive DDL: `ALTER TABLE … ADD COLUMN`, `CREATE TABLE IF NOT EXISTS`, `INSERT OR IGNORE INTO settings`. +- New default settings for **live DBs** go here. Day-0 seeds (fresh install) stay in `DEFAULT_SETTINGS` in `server-db.ts`. +- New settings still need allow-listing in `PUT /api/settings` (and `SECRET_KEYS` if secret) — the migration only seeds the value. + +**Fresh-install behavior:** `server-db.ts` creates all baseline tables first (at module import). `runMigrations` then applies every migration in the array — safe because all migrations are additive over the baseline. + +**`_migrations` is excluded** from `/api/database/import` and `/api/database/info` (not in their explicit table lists) — migration state belongs to the current install, not the imported data. + --- ## 5. API Design @@ -345,9 +372,9 @@ All `/api/*` routes return JSON. Every route except the public auth/config endpo | +-- /labs | +-- GET / # List labs (parses deviceIds/topology JSON) [auth] -| +-- POST / # Create lab [auth] -| +-- PUT /{id} # Update lab [auth] -| +-- DELETE /{id} # Delete + cancel upcoming bookings [auth] +| +-- POST / # Create lab; sets ownerId=req.user [auth] +| +-- PUT /{id} # Update lab; 403 if not owner/admin/legacy [auth] +| +-- DELETE /{id} # Delete + cancel upcoming bookings; same 403 guard [auth] | +-- /bookings | +-- GET / # List bookings (int flags > booleans) [auth] @@ -389,8 +416,10 @@ Auth model +-- Storage: browser localStorage (ghostgrid_token, ghostgrid_user) +-- Middleware | +-- requireAuth — verifies JWT, sets req.user; applied to all data routes -| +-- requireAdmin — checks users.role === 'admin' ⚠ DEFINED BUT NOT WIRED -+-- Roles: role column defaults to 'User'; no route currently enforces admin +| +-- requireAdmin — checks users.role === 'admin' ⚠ DEFINED BUT NOT WIRED to routes ++-- Roles: role column defaults to 'User' ++-- Lab ownership: PUT/DELETE /api/labs/:id enforce inline ownership check +| (owner || admin || legacy-lab with ownerId=''); 403 otherwise ``` **Local flow:** `register` (bcrypt hash, role `User`) / `login` (bcrypt compare) > issue JWT > client stores token + user. @@ -463,7 +492,9 @@ Manual: POST /api/semaphore/trigger/{bookingId} body { type: 'setup'|'teardown ``` buildCaddyfile(): { local_certs } # global block - per custom route { [encode] [tls internal] reverse_proxy { … } } + per custom route { [encode] [tls internal] [redir / ] reverse_proxy { … } } + redirect set → `redir / ` redirects only the bare root '/' + (other paths pass through; e.g. CheckMK served at //check_mk/) every reverse_proxy block carries standard forwarding headers: header_up X-Forwarded-Proto {scheme} header_up X-Real-IP {remote_host} @@ -585,6 +616,9 @@ Device Inventory Lab Templates + Topology +-- Lab CRUD; Semaphore setup/teardown template selection ++-- Scope toggle (Global / Personal) per lab; Personal labs visible only to owner + admins ++-- List sectioned: "My Topologies" / "Global Topologies" / "Others' Personal" (admin only) ++-- Edit/Delete buttons hidden for labs the current user cannot modify +-- Topology link editor (fromDevice > toDevice, link type) +-- TopologyPanel: SVG layout by node count (1 / 2 / 3 / circular) @@ -609,7 +643,7 @@ The single contract between frontend and backend — imported by **both** `serve | `DeviceType` | `'Switch' \| 'Access-Point' \| 'Firewall' \| 'Controller' \| (string & {})` — presets + free-form | | `Device` | `status: 'online' \| 'offline' \| 'unknown'`; `emergencySheet` markdown; optional `cmkHostname`, `lastCheckedAt` | | `TopologyLink` | `{ fromDevice, toDevice, type }` (e.g. `LACP-Trunk`, `Uplink`, `OOB-Management`) | -| `LabTemplate` | `deviceIds: string[]`, `topology: TopologyLink[]`, optional Semaphore template IDs | +| `LabTemplate` | `deviceIds: string[]`, `topology: TopologyLink[]`, optional Semaphore template IDs; `scope: 'global' \| 'personal'`, `ownerId: string` (userId of creator, `''` for legacy rows) | | `Booking` | `status: 'active' \| 'upcoming' \| 'completed' \| 'cancelled'`; ansible trigger flags + job IDs | | `LogEntry` | `type: 'maintenance' \| 'booking' \| 'status' \| 'system'` | | `User` | `{ id, name, role, email }` (never password on the client) | @@ -690,6 +724,8 @@ Backup : ghostgrid.db + ghostgrid.db-wal + ghostgrid.db-shm | +-- role column ('User'/'admin') exists | | +-- ⚠ requireAdmin defined but NOT applied — any | | authenticated user can read/write settings + users | +| +-- Lab ownership enforced on PUT/DELETE /api/labs/:id | +| (owner || admin || legacy ownerId=''); 403 otherwise | +-------------------------------------------------------------+ | Secret Handling | | +-- Integration secrets stored in settings table | @@ -713,7 +749,7 @@ Backup : ghostgrid.db + ghostgrid.db-wal + ghostgrid.db-shm ``` GhostGrid/ +-- 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 +-- public/ | +-- favicon.svg # app favicon (GhostGrid logo; served at site root by Vite) @@ -771,7 +807,7 @@ GhostGrid/ | CheckMK/Semaphore outage | Integration loops catch errors, log them, and retry next cycle; non-fatal | | Caddy admin API unreachable | `pushCaddyConfig()` failures are logged as warnings; routes apply when Caddy starts | | Data loss | Back up `ghostgrid.db` + `-wal`/`-shm`; each instance has its own DB | -| Schema evolution | Edit the `CREATE TABLE` block in `server-db.ts` (fresh-install model, no migrations); new settings need seed + allow-list (+ `SECRET_KEYS` if secret) | +| Schema evolution | For fresh installs, edit the `CREATE TABLE` block in `server-db.ts`. For live DBs (new columns, tables, default settings), append a migration to `server-migrations.ts` (see §4.4). New settings still need allow-listing in `PUT /api/settings` (+ `SECRET_KEYS` if secret). | --- @@ -839,7 +875,7 @@ Express (server.ts) ──► better-sqlite3 (ghostgrid.db, WAL) - `labs.deviceIds` / `labs.topology` are JSON strings in SQLite, parsed in the API. - Booking boolean flags are 0/1 integers in SQLite, mapped on read. - A new settings key must be: **seeded** in `server-db.ts`, **allow-listed** in `PUT /api/settings`, and (if secret) added to `SECRET_KEYS`. -- Schema changes go straight into the `CREATE TABLE` block in `server-db.ts` — fresh-install model, no migration helper. +- Baseline schema in `server-db.ts`. Additive schema changes (new columns, tables, default settings) go in `server-migrations.ts` as an appended migration object — never edit existing entries (see §4.4). - The SPA catch-all (`app.get('*')`) + static serving are registered **last** in `startServer()`, after every `/api` route — otherwise GET `/api/*` falls through to `index.html`. All `/api` responses carry `Cache-Control: no-store`. - One Caddy per container; `POST /load` replaces the whole config. Only the `CADDY_MANAGER=true` instance may push/seed/edit routes — never let the non-manager push. - All user-facing strings are in **English**. diff --git a/deploy/proxmox-ghostgrid.sh b/deploy/proxmox-ghostgrid.sh index d6bd8fe..73a7ac0 100644 --- a/deploy/proxmox-ghostgrid.sh +++ b/deploy/proxmox-ghostgrid.sh @@ -190,8 +190,8 @@ msg_info "Creating .env file for each instance" for d in "${APP_DIR}" "${DEV_DIR}"; do SECRET="$(openssl rand -hex 32)" run "printf 'JWT_SECRET=\"%s\"\n' '${SECRET}' > ${d}/.env && chown ghostgrid:ghostgrid ${d}/.env && chmod 600 ${d}/.env" - # Only the production instance owns the shared Caddy (one Caddy per container). - [[ "$d" == "${APP_DIR}" ]] && run "printf 'CADDY_MANAGER=true\n' >> ${d}/.env" + # Only the production instance owns Caddy and shows "Production" in the UI. + [[ "$d" == "${APP_DIR}" ]] && run "printf 'DEPLOY_ENV=production\n' >> ${d}/.env" done msg_ok ".env files created (main + dev)" diff --git a/server-db.ts b/server-db.ts index 9aefbb5..9d6c7e2 100644 --- a/server-db.ts +++ b/server-db.ts @@ -3,6 +3,9 @@ import path from 'path'; 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}`); const db = new Database(DB_FILE); @@ -86,15 +89,20 @@ db.exec(` ); CREATE TABLE IF NOT EXISTS caddy ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - hostname TEXT NOT NULL, - upstream TEXT NOT NULL, - tls INTEGER NOT NULL DEFAULT 1, - compress INTEGER NOT NULL DEFAULT 1, - created_at TEXT DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + hostname TEXT NOT NULL, + upstream TEXT NOT NULL, + tls INTEGER NOT NULL DEFAULT 1, + compress INTEGER NOT NULL DEFAULT 1, + redirect TEXT NOT NULL DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) ); `); +// Idempotent migrations — SQLite throws on duplicate column; the catch makes them safe to re-run. +try { db.prepare("ALTER TABLE labs ADD COLUMN scope TEXT NOT NULL DEFAULT 'global'").run(); } catch {} +try { db.prepare("ALTER TABLE labs ADD COLUMN ownerId TEXT NOT NULL DEFAULT ''").run(); } catch {} + // Seed default settings — INSERT OR IGNORE writes a key only if it is absent. const DEFAULT_SETTINGS: Record = { azure_enabled: 'false', @@ -113,7 +121,7 @@ const DEFAULT_SETTINGS: Record = { semaphore_api_token: '', semaphore_project_id: '', 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 (?, ?)'); @@ -133,38 +141,68 @@ export function getAllSettings(): Record { 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 { id: number; hostname: string; upstream: string; tls: number; compress: number; + redirect: 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[] { return db.prepare('SELECT * FROM caddy ORDER BY id ASC').all() as CaddyRoute[]; } -export function addCaddyRoute(hostname: string, upstream: string, tls: boolean, compress: boolean): CaddyRoute { - const { lastInsertRowid } = db.prepare( - 'INSERT INTO caddy (hostname, upstream, tls, compress) VALUES (?, ?, ?, ?)' - ).run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0); - return db.prepare('SELECT * FROM caddy WHERE id = ?').get(lastInsertRowid) as CaddyRoute; +export function getCaddyRouteById(id: number): CaddyRoute | undefined { + return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute | undefined; } -export function updateCaddyRoute(id: number, hostname: string, upstream: string, tls: boolean, compress: boolean): CaddyRoute { - db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ? WHERE id = ?') - .run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0, id); - return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute; +export function addCaddyRoute(route: CaddyRouteInput): CaddyRoute { + const { lastInsertRowid } = db.prepare( + 'INSERT INTO caddy (hostname, upstream, tls, compress, redirect) VALUES (?, ?, ?, ?, ?)', + ).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 { 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; \ No newline at end of file diff --git a/server-migrations.ts b/server-migrations.ts new file mode 100644 index 0000000..dee4042 --- /dev/null +++ b/server-migrations.ts @@ -0,0 +1,40 @@ +import Database from 'better-sqlite3'; + +interface Migration { + id: string; // unique, immutable — format: NNNN_short_description + up: (db: InstanceType) => void; +} + +// Append only. Never reorder or remove entries — that would corrupt tracking. +// Each `up` function receives the open DB handle inside an already-open transaction. +const migrations: Migration[] = [ + // Example: + // { + // id: '0001_bookings_add_color', + // up: (db) => { + // db.exec(`ALTER TABLE bookings ADD COLUMN color TEXT NOT NULL DEFAULT 'blue'`); + // }, + // }, +]; + +export function runMigrations(db: InstanceType): void { + db.exec(` + CREATE TABLE IF NOT EXISTS _migrations ( + id TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + + const isApplied = db.prepare('SELECT 1 FROM _migrations WHERE id = ?'); + const markApplied = db.prepare('INSERT INTO _migrations (id) VALUES (?)'); + + for (const migration of migrations) { + if (isApplied.get(migration.id)) continue; + console.log(`[Migrations] Applying: ${migration.id}`); + db.transaction(() => { + migration.up(db); + markApplied.run(migration.id); + })(); + console.log(`[Migrations] Applied: ${migration.id}`); + } +} diff --git a/server.ts b/server.ts index 493a440..5b70d01 100644 --- a/server.ts +++ b/server.ts @@ -7,18 +7,17 @@ import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import DatabaseConstructor from 'better-sqlite3'; 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 { runMigrations } from './server-migrations'; 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'; -// One Caddy serves the whole container; only the instance with CADDY_MANAGER=true -// owns it (pushes config, seeds routes, accepts route edits). The other instance -// must never push — POST /load replaces the entire config and would clobber it. -const IS_CADDY_MANAGER = process.env.CADDY_MANAGER === 'true'; +// DEPLOY_ENV=production marks the primary instance: it owns Caddy (pushes config, +// seeds routes, accepts route edits) and shows "Production" in the UI. The dev +// instance must never push to Caddy — POST /load replaces the entire config. +const IS_PRODUCTION = process.env.DEPLOY_ENV === 'production'; interface JwtPayload { userId: string; @@ -87,6 +86,12 @@ function buildCaddyfile(): string { lines.push(`${route.hostname} {`); if (route.compress) lines.push(' encode zstd gzip'); if (route.tls) lines.push(' tls internal'); + if (route.redirect) { + // Redirect only the bare root ('/') to the given path — other paths pass + // through to the backend unchanged (e.g. CheckMK at //check_mk/). + const target = route.redirect.startsWith('/') ? route.redirect : `/${route.redirect}`; + lines.push(` redir / ${target}`); + } lines.push(` reverse_proxy ${route.upstream} {`); // Standard forwarding headers for every backend. Caddy already sets the // X-Forwarded-* family and the Host header by default; these make them @@ -132,24 +137,21 @@ function importCaddyfileRoutes(userId?: string): void { const upstream = upstreamMatch[1]; const tls = /tls\s+internal/.test(block); const compress = /encode/.test(block); - addCaddyRoute(hostname, upstream, tls, compress); + addCaddyRoute({ hostname, upstream, tls, compress }); imported.push(`${hostname} → ${upstream}`); } } i++; } if (imported.length > 0) { - db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)') - .run(uid('log'), new Date().toISOString(), 'system', - `Caddy: imported ${imported.length} route(s) from Caddyfile — ${imported.join(', ')}`, - null, userId ?? null); + addLog('system', `Caddy: imported ${imported.length} route(s) from Caddyfile — ${imported.join(', ')}`, { userId }); } } async function pushCaddyConfig(): Promise { - if (!IS_CADDY_MANAGER) return; + if (!IS_PRODUCTION) 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 res = await fetch(`${adminUrl}/load`, { method: 'POST', @@ -163,6 +165,8 @@ async function pushCaddyConfig(): Promise { } async function startServer() { + runMigrations(db); + const app = express(); const PORT = Number(process.env.PORT) || 3000; @@ -174,7 +178,7 @@ async function startServer() { console.log('[Init] Default admin user created — login: admin@ghostgrid.local / admin'); } - if (IS_CADDY_MANAGER && getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) { + if (IS_PRODUCTION && getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) { importCaddyfileRoutes(); } @@ -235,9 +239,7 @@ async function startServer() { 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); + addLog('system', `${row.name} logged in.`, { userId: row.id }); res.json({ token, user }); } catch (err: any) { @@ -263,15 +265,16 @@ async function startServer() { 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 appUrl = process.env.APP_URL || `http://127.0.0.1:${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', + semaphoreEnabled: getSetting('semaphore_enabled') === 'true', checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''), - caddyManaged: IS_CADDY_MANAGER, + isProduction: IS_PRODUCTION, }); }); @@ -281,7 +284,7 @@ async function startServer() { if (!msalClient) { 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`; try { const authCodeUrl = await msalClient.getAuthCodeUrl({ @@ -309,7 +312,7 @@ async function startServer() { if (!msalClient) { 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`; try { const result = await msalClient.acquireTokenByCode({ @@ -337,6 +340,7 @@ async function startServer() { 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 }); + addLog('system', `${user.name} logged in via Microsoft.`, { userId: user.id }); res.redirect(`/?token=${encodeURIComponent(token)}`); } catch (err: any) { console.error('[Azure Auth] acquireTokenByCode error:', err); @@ -405,6 +409,29 @@ async function startServer() { 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; + const changes: string[] = []; + if (name && name !== existing.name) changes.push(`name "${existing.name}" → "${name}"`); + if (email && email !== existing.email) changes.push(`email "${existing.email}" → "${email}"`); + if (changes.length > 0) { + addLog('system', `User ${updated.email} updated: ${changes.join(', ')}.`, { userId: req.user!.userId }); + } + res.json(updated); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + }); + + app.patch('/api/users/:id/role', requireAuth, requireAdmin, (req, res) => { + try { + const id = req.params.id; + const { role } = req.body as { role: string }; + const safeRole = role?.toLowerCase() === 'admin' ? 'admin' : 'User'; + const existing = db.prepare('SELECT id, name, email, role FROM users WHERE id = ?').get(id) as User | undefined; + if (!existing) return res.status(404).json({ error: 'User not found.' }); + if (existing.role === safeRole) return res.json(existing); + db.prepare('UPDATE users SET role = ? WHERE id = ?').run(safeRole, id); + const updated = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User; + addLog('system', `User ${updated.email} role changed to ${safeRole}.`, { userId: req.user!.userId }); res.json(updated); } catch (err: any) { res.status(500).json({ error: err.message }); @@ -451,11 +478,9 @@ async function startServer() { 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); + addLog('maintenance', + `Provisioned a new host candidate "${hostname}" (${ip}) inside location ${location || 'Inventory'}.`, + { deviceId: id, userId: req.user!.userId }); const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device; res.status(201).json(device); @@ -474,11 +499,10 @@ async function startServer() { 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); + addLog('maintenance', + `${operatorText}refining the device specifications for "${hostname}".`, + { deviceId: id, userId: req.user!.userId }); const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device; res.json(device); @@ -507,11 +531,9 @@ async function startServer() { ); } - 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); + addLog('maintenance', + `Permanently removed the host device "${dev.hostname || id}" from the inventory records.`, + { userId: req.user!.userId }); res.json({ success: true, message: 'Device deleted successfully and cleaned from topologies.' }); } catch (err: any) { @@ -531,6 +553,8 @@ async function startServer() { deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', + scope: (r.scope === 'personal' ? 'personal' : 'global') as 'global' | 'personal', + ownerId: r.ownerId ?? '', })); res.json(labs); } catch (err: any) { @@ -540,43 +564,54 @@ async function startServer() { app.post('/api/labs', requireAuth, (req, res) => { try { - const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId } = req.body; + const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId, scope } = req.body; if (!name || !deviceIds || !Array.isArray(deviceIds)) { return res.status(400).json({ error: 'Missing name or associated device configurations.' }); } + const safeScope: 'global' | 'personal' = scope === 'personal' ? 'personal' : 'global'; + const ownerId = req.user!.userId; 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 || ''); + db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId, scope, ownerId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', safeScope, ownerId); - 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); + addLog('maintenance', + `Released a new lab template profile "${name}" (${location || 'Staging Area'}) for engineering teams.`, + { userId: 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 || '' }); + res.status(201).json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', scope: r.scope, ownerId: r.ownerId }); } catch (err: any) { res.status(500).json({ error: err.message }); } }); - app.put('/api/labs/:id', requireAuth, (req, res) => { + app.put('/api/labs/:id', requireAuth, async (req, res) => { try { const id = req.params.id; - const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId } = req.body; + const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId, scope } = 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 existing = db.prepare('SELECT ownerId, scope FROM labs WHERE id = ?').get(id) as any; + if (!existing) return res.status(404).json({ error: 'Lab template not found.' }); - 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 reqUser = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as any; + const isAdmin = reqUser?.role?.toLowerCase() === 'admin'; + const isOwner = existing.ownerId === req.user!.userId; + const isLegacy = existing.ownerId === ''; + if (!isOwner && !isAdmin && !isLegacy) { + return res.status(403).json({ error: 'You do not have permission to edit this topology.' }); + } + + const safeScope: 'global' | 'personal' = scope === 'personal' ? 'personal' : 'global'; + db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ?, semaphoreSetupTemplateId = ?, semaphoreTeardownTemplateId = ?, scope = ? WHERE id = ?`) + .run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', safeScope, id); + + addLog('maintenance', + `Modified the active topology mapping schema for the "${name}" lab template.`, + { userId: 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 || '' }); + res.json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', scope: r.scope, ownerId: r.ownerId }); } catch (err: any) { res.status(500).json({ error: err.message }); } @@ -588,13 +623,20 @@ async function startServer() { 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.' }); + const reqUser = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as any; + const isAdmin = reqUser?.role?.toLowerCase() === 'admin'; + const isOwner = lab.ownerId === req.user!.userId; + const isLegacy = lab.ownerId === ''; + if (!isOwner && !isAdmin && !isLegacy) { + return res.status(403).json({ error: 'You do not have permission to delete this topology.' }); + } + 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); + addLog('booking', + `Withdrew the lab testing template "${lab.name || id}".`, + { userId: req.user!.userId }); res.json({ success: true, message: 'Lab template deleted and future reservations cancelled.' }); } catch (err: any) { @@ -636,14 +678,13 @@ async function startServer() { .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); + addLog('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({ @@ -667,11 +708,9 @@ async function startServer() { 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); + addLog('booking', + `${operatorName || 'An operator'} canceled the reservation for lab template "${lab?.name || 'Unknown'}" and released associated nodes back into the pool.`, + { userId: req.user!.userId }); // Trigger teardown if booking had already started and teardown not yet triggered const now = new Date(); @@ -712,10 +751,9 @@ async function startServer() { 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); + addLog('booking', + `Permanently deleted the reservation records for lab "${lab?.name || 'Unknown'}".`, + { userId: req.user!.userId }); res.json({ success: true, message: 'Reservation and its logs have been resolved correctly.' }); } catch (err: any) { @@ -742,9 +780,7 @@ async function startServer() { 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 id = addLog(type, message, { deviceId: deviceId || null, userId: userId || null }); const log = db.prepare('SELECT * FROM logs WHERE id = ?').get(id) as LogEntry; res.status(201).json(log); @@ -923,8 +959,7 @@ async function startServer() { 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.'); + addLog('system', 'CheckMK sync skipped — API URL or secret not configured. Check Settings.', { timestamp: now }); return; } @@ -952,8 +987,7 @@ async function startServer() { } 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); + addLog('system', msg, { timestamp: now }); return; } @@ -969,8 +1003,7 @@ async function startServer() { 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); + addLog('status', `CheckMK: ${dev.hostname} (${dev.ip}) not found in monitoring — status set to unknown.`, { deviceId: dev.id, timestamp: now }); } counts.unknown++; continue; @@ -987,22 +1020,20 @@ async function startServer() { 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); + addLog('status', `CheckMK: ${dev.hostname} (${dev.ip}) status changed to ${newStatus} (was: ${dev.status}).`, { deviceId: dev.id, timestamp: now }); } 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); + addLog('system', msg, { deviceId: dev.id, timestamp: now }); 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).`); + addLog('system', + `CheckMK sync completed — ${counts.online} online, ${counts.offline} offline, ${counts.unknown} unknown (${rows.length} devices total).`, + { timestamp: now }); } async function scheduleSync() { @@ -1028,14 +1059,12 @@ async function startServer() { // as CheckMK. Template IDs are configured per lab template. // ------------------------------------------------------------- async function triggerSemaphoreTask(templateId: number, extraVars: Record): Promise { - 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.'); + addLog('system', 'Semaphore trigger skipped — API URL, token, or project ID not configured. Check Settings.'); return null; } @@ -1058,14 +1087,12 @@ async function startServer() { } 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}).`); + addLog('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); + addLog('system', msg); return null; } } @@ -1181,7 +1208,7 @@ async function startServer() { // ------------------------------------------------------------- app.get('/api/caddy/status', requireAuth, async (_req, res) => { 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) }); res.json({ available: r.ok }); } catch { @@ -1199,17 +1226,15 @@ async function startServer() { app.post('/api/caddy/routes', requireAuth, async (req, res) => { try { - if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); - const { hostname, upstream, tls, compress } = req.body as { - hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; + if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); + const { hostname, upstream, tls, compress, redirect } = req.body as { + 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 (getCaddyRoutes().some(r => r.hostname === hostname.trim())) return res.status(409).json({ error: `Route for ${hostname.trim()} already exists.` }); - const route = addCaddyRoute(hostname.trim(), upstream.trim(), tls !== false, compress !== false); - db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)') - .run(uid('log'), new Date().toISOString(), 'system', - `Caddy route added: ${hostname.trim()} → ${upstream.trim()}`, null, req.user!.userId); + const route = addCaddyRoute({ hostname: hostname.trim(), upstream: upstream.trim(), tls: tls !== false, compress: compress !== false, redirect: (redirect ?? '').trim() }); + addLog('system', `Caddy route added: ${hostname.trim()} → ${upstream.trim()}`, { userId: req.user!.userId }); pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route add:', err.message)); res.json(route); } catch (err: any) { @@ -1219,17 +1244,15 @@ async function startServer() { app.put('/api/caddy/routes/:id', requireAuth, async (req, res) => { try { - if (!IS_CADDY_MANAGER) 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); if (!id) return res.status(400).json({ error: 'Invalid route id.' }); - const { hostname, upstream, tls, compress } = req.body as { - hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; + const { hostname, upstream, tls, compress, redirect } = req.body as { + hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirect?: string; }; 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); - db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)') - .run(uid('log'), new Date().toISOString(), 'system', - `Caddy route updated: ${hostname.trim()} → ${upstream.trim()}`, null, req.user!.userId); + const route = updateCaddyRoute(id, { hostname: hostname.trim(), upstream: upstream.trim(), tls: tls !== false, compress: compress !== false, redirect: (redirect ?? '').trim() }); + addLog('system', `Caddy route updated: ${hostname.trim()} → ${upstream.trim()}`, { userId: req.user!.userId }); pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route update:', err.message)); res.json(route); } catch (err: any) { @@ -1239,15 +1262,13 @@ async function startServer() { app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => { try { - if (!IS_CADDY_MANAGER) 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); if (!id) return res.status(400).json({ error: 'Invalid route id.' }); const existing = getCaddyRouteById(id); deleteCaddyRoute(id); if (existing) { - db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)') - .run(uid('log'), new Date().toISOString(), 'system', - `Caddy route deleted: ${existing.hostname} → ${existing.upstream}`, null, req.user!.userId); + addLog('system', `Caddy route deleted: ${existing.hostname} → ${existing.upstream}`, { userId: req.user!.userId }); } pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route delete:', err.message)); res.status(204).send(); diff --git a/src/App.tsx b/src/App.tsx index 0c520ba..ffc7a4c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -55,6 +55,8 @@ export default function App() { const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState(null); const [checkmkEnabled, setCheckmkEnabled] = useState(false); const [checkmkBaseUrl, setCheckmkBaseUrl] = useState(''); + const [isProduction, setIsProduction] = useState(false); + const [semaphoreEnabled, setSemaphoreEnabled] = useState(false); useEffect(() => { const root = document.documentElement; @@ -143,7 +145,7 @@ export default function App() { if (bookingsRes.ok) setBookings(await bookingsRes.json()); if (logsRes.ok) setLogs(await logsRes.json()); if (linksRes.ok) setLinks(await linksRes.json()); - if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); setCheckmkBaseUrl(cfg.checkmkBaseUrl || ''); } + if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); setCheckmkBaseUrl(cfg.checkmkBaseUrl || ''); setIsProduction(!!cfg.isProduction); setSemaphoreEnabled(!!cfg.semaphoreEnabled); } } catch (err) { console.error('[App] Failed to load data:', err); } finally { @@ -298,7 +300,7 @@ export default function App() { }; // Lab handlers - const handleAddLab = async (newLab: Omit) => { + const handleAddLab = async (newLab: Omit) => { try { const res = await authFetch('/api/labs', { method: 'POST', body: JSON.stringify(newLab) }); if (res.ok) { @@ -360,6 +362,17 @@ export default function App() { } catch (err: any) { throw err; } }; + const handleSetUserRole = async (id: string, role: string) => { + try { + const res = await authFetch(`/api/users/${id}/role`, { method: 'PATCH', body: JSON.stringify({ role }) }); + if (res.ok) { + const updated: User = await res.json(); + setUsers(prev => prev.map(u => u.id === id ? updated : u)); + if (updated.id === currentUser?.id) setCurrentUser(updated); + } else { const d = await res.json(); throw new Error(d.error); } + } catch (err: any) { throw err; } + }; + // Quick-link handlers (shared link dashboard) const handleAddLink = async (newLink: Omit) => { try { @@ -484,6 +497,7 @@ export default function App() { theme={theme} onThemeToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')} onLogout={handleLogout} + isProduction={isProduction} />
@@ -595,6 +609,8 @@ export default function App() { )} {activeTab === 'logs' && ( diff --git a/src/components/BookingCalendar.tsx b/src/components/BookingCalendar.tsx index 6843370..0fd9a07 100644 --- a/src/components/BookingCalendar.tsx +++ b/src/components/BookingCalendar.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import { Booking, LabTemplate, Device, User } from '../types'; import { Calendar, Zap, CheckCircle2, AlertCircle, AlertTriangle, ChevronLeft, ChevronRight, Database, @@ -172,11 +172,23 @@ export default function BookingCalendar({ return { startMs: start.getTime(), endMs: end.getTime(), start, end }; }, [quickDuration]); + const bookableLabs = useMemo(() => labs.filter(l => + l.scope === 'global' || + l.ownerId === currentUser.id || + currentUser.role?.toLowerCase() === 'admin' + ), [labs, currentUser.id, currentUser.role]); + + useEffect(() => { + if (selectedLabId && !bookableLabs.find(l => l.id === selectedLabId)) { + setSelectedLabId(bookableLabs[0]?.id || ''); + } + }, [bookableLabs]); + // A lab is quick-bookable when every device is free (regardless of online status). - const availableLabs = useMemo(() => labs.filter(lab => + const availableLabs = useMemo(() => bookableLabs.filter(lab => lab.deviceIds.length > 0 && lab.deviceIds.every(dId => !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs)) - ), [labs, devices, bookings, quickWindow]); + ), [bookableLabs, devices, bookings, quickWindow]); const availableDevices = useMemo(() => devices.filter(dev => !isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs) @@ -576,9 +588,20 @@ export default function BookingCalendar({ onChange={(e) => setSelectedLabId(e.target.value)} className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500" > - {labs.map((l) => ( - - ))} + {bookableLabs.filter(l => l.scope === 'global').length > 0 && ( + + {bookableLabs.filter(l => l.scope === 'global').map((l) => ( + + ))} + + )} + {bookableLabs.filter(l => l.scope === 'personal').length > 0 && ( + + {bookableLabs.filter(l => l.scope === 'personal').map((l) => ( + + ))} + + )}
) : ( @@ -656,7 +679,6 @@ export default function BookingCalendar({