chore(release): merge dev into main

This commit is contained in:
Brückner
2026-06-10 16:39:27 +02:00
18 changed files with 590 additions and 307 deletions

View File

@ -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 / <path>' 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 <upstream> { … } }
per custom route { [encode] [tls internal] [redir / <redirect>] reverse_proxy <upstream> { … } }
redirect set → `redir / <path>` redirects only the bare root '/'
(other paths pass through; e.g. CheckMK served at /<site>/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**.

View File

@ -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)"

View File

@ -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<string, string> = {
azure_enabled: 'false',
@ -113,7 +121,7 @@ const DEFAULT_SETTINGS: Record<string, string> = {
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<string, string> {
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;

40
server-migrations.ts Normal file
View File

@ -0,0 +1,40 @@
import Database from 'better-sqlite3';
interface Migration {
id: string; // unique, immutable — format: NNNN_short_description
up: (db: InstanceType<typeof Database>) => 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<typeof Database>): 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}`);
}
}

247
server.ts
View File

@ -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 /<site>/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<void> {
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<void> {
}
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<string, string>): Promise<number | null> {
const now = new Date().toISOString();
const apiUrl = getSetting('semaphore_api_url');
const token = getSetting('semaphore_api_token');
const projectId = getSetting('semaphore_project_id');
if (!apiUrl || !token || !projectId) {
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
.run(uid('log'), now, 'system', 'Semaphore trigger skipped — API URL, token, or project ID not configured. Check Settings.');
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();

View File

@ -55,6 +55,8 @@ export default function App() {
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(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<LabTemplate, 'id'>) => {
const handleAddLab = async (newLab: Omit<LabTemplate, 'id' | 'ownerId'>) => {
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<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => {
try {
@ -484,6 +497,7 @@ export default function App() {
theme={theme}
onThemeToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
onLogout={handleLogout}
isProduction={isProduction}
/>
<div className="flex-1 flex flex-col md:flex-row">
@ -595,6 +609,8 @@ export default function App() {
<LabTemplates
labs={labs}
devices={devices}
currentUser={currentUser!}
semaphoreEnabled={semaphoreEnabled}
onAddLab={handleAddLab}
onUpdateLab={handleUpdateLab}
onDeleteLab={handleDeleteLab}
@ -617,6 +633,7 @@ export default function App() {
bookings={bookings}
onDeleteUser={handleDeleteUser}
onUpdateUser={handleUpdateUser}
onSetRole={handleSetUserRole}
/>
)}
{activeTab === 'logs' && (

View File

@ -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) => (
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
))}
{bookableLabs.filter(l => l.scope === 'global').length > 0 && (
<optgroup label="Global Topologies">
{bookableLabs.filter(l => l.scope === 'global').map((l) => (
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
))}
</optgroup>
)}
{bookableLabs.filter(l => l.scope === 'personal').length > 0 && (
<optgroup label="My Personal Topologies">
{bookableLabs.filter(l => l.scope === 'personal').map((l) => (
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
))}
</optgroup>
)}
</select>
</div>
) : (
@ -656,7 +679,6 @@ export default function BookingCalendar({
<textarea
required
rows={3}
placeholder="e.g. Validating STP failover convergence times..."
value={bookingNotes}
onChange={(e) => setBookingNotes(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"

View File

@ -255,7 +255,6 @@ export default function DeviceInventory({
</span>
<input
type="text"
placeholder="Search by hostname, IP address, rack location..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none focus:border-emerald-500 transition-colors placeholder:text-slate-500"
@ -497,7 +496,6 @@ Pick a box from the list to see its specs and break-glass playbook.
value={formData.hostname}
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono"
placeholder="SW-CORE-03"
/>
</div>
<div>
@ -508,7 +506,6 @@ Pick a box from the list to see its specs and break-glass playbook.
value={formData.ip}
onChange={(e) => setFormData({ ...formData, ip: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono"
placeholder="172.16.x.x"
/>
</div>
</div>
@ -522,7 +519,6 @@ Pick a box from the list to see its specs and break-glass playbook.
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="Server Room R02, Rack C4..."
/>
</div>
<div>
@ -554,7 +550,6 @@ Pick a box from the list to see its specs and break-glass playbook.
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as DeviceType })}
className="w-full mt-2 bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="Enter new device class (e.g. Router, Load-Balancer)"
/>
)}
</div>
@ -567,7 +562,6 @@ Pick a box from the list to see its specs and break-glass playbook.
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="Serial numbers, module slots, connected uplinks, license status..."
/>
</div>

View File

@ -11,6 +11,7 @@ interface HeaderProps {
theme: 'dark' | 'light';
onThemeToggle: () => void;
onLogout: () => void;
isProduction: boolean;
}
export default function Header({
@ -22,6 +23,7 @@ export default function Header({
theme,
onThemeToggle,
onLogout,
isProduction,
}: HeaderProps) {
const [showMailInbox, setShowMailInbox] = useState(false);
const [showBellDropdown, setShowBellDropdown] = useState(false);
@ -63,8 +65,8 @@ export default function Header({
{/* System Indicator */}
<div className="hidden md:flex items-center gap-2 px-3 py-1 bg-slate-800/60 rounded-full border border-slate-700/50 text-xs font-mono text-slate-300">
<span className={`w-2 h-2 rounded-full animate-pulse ${(import.meta.env.VITE_DEPLOY_ENV === 'dev' || import.meta.env.DEV) ? 'bg-amber-400' : 'bg-emerald-500'}`} />
<span>System: {(import.meta.env.VITE_DEPLOY_ENV === 'dev' || import.meta.env.DEV) ? 'Development' : 'Production'}</span>
<span className={`w-2 h-2 rounded-full animate-pulse ${isProduction ? 'bg-emerald-500' : 'bg-amber-400'}`} />
<span>System: {isProduction ? 'Production' : 'Development'}</span>
</div>
{/* Mail Inbox */}

View File

@ -4,17 +4,19 @@
*/
import React, { useState } from 'react';
import { LabTemplate, Device, TopologyLink } from '../types';
import { LabTemplate, Device, TopologyLink, User } from '../types';
import TopologyPanel from './TopologyPanel';
import {
Server, Plus, Edit3, Trash, User, MapPin,
Layers, ChevronRight, Save, X, Check, Pencil, Terminal,
Server, Plus, Edit3, Trash, User as UserIcon, MapPin,
Layers, ChevronRight, X, Check, Pencil, Terminal, Lock, Globe,
} from 'lucide-react';
interface LabTemplatesProps {
labs: LabTemplate[];
devices: Device[];
onAddLab: (lab: Omit<LabTemplate, 'id'>) => void;
currentUser: User;
semaphoreEnabled: boolean;
onAddLab: (lab: Omit<LabTemplate, 'id' | 'ownerId'>) => void;
onUpdateLab: (lab: LabTemplate) => void;
onDeleteLab: (id: string) => void;
onOpenDeviceDetails: (device: Device) => void;
@ -23,6 +25,8 @@ interface LabTemplatesProps {
export default function LabTemplates({
labs,
devices,
currentUser,
semaphoreEnabled,
onAddLab,
onUpdateLab,
onDeleteLab,
@ -49,6 +53,7 @@ export default function LabTemplates({
deviceIds: string[];
semaphoreSetupTemplateId: string;
semaphoreTeardownTemplateId: string;
scope: 'global' | 'personal';
}>({
name: '',
description: '',
@ -57,6 +62,7 @@ export default function LabTemplates({
deviceIds: [],
semaphoreSetupTemplateId: '',
semaphoreTeardownTemplateId: '',
scope: 'global',
});
// Calculate filtered devices associated with selected lab
@ -75,6 +81,7 @@ export default function LabTemplates({
deviceIds: [],
semaphoreSetupTemplateId: '',
semaphoreTeardownTemplateId: '',
scope: 'global',
});
setIsEditing(true);
};
@ -91,6 +98,7 @@ export default function LabTemplates({
deviceIds: [...lab.deviceIds],
semaphoreSetupTemplateId: lab.semaphoreSetupTemplateId || '',
semaphoreTeardownTemplateId: lab.semaphoreTeardownTemplateId || '',
scope: lab.scope ?? 'global',
});
setIsEditing(true);
};
@ -137,20 +145,107 @@ export default function LabTemplates({
topology: tempLinks,
semaphoreSetupTemplateId: formData.semaphoreSetupTemplateId,
semaphoreTeardownTemplateId: formData.semaphoreTeardownTemplateId,
scope: formData.scope,
};
if (formMode === 'add') {
onAddLab(savedLabData);
} else if (formMode === 'edit' && formData.id) {
const existing = labs.find(l => l.id === formData.id);
onUpdateLab({
...savedLabData,
id: formData.id
id: formData.id,
ownerId: existing?.ownerId ?? '',
});
}
setIsEditing(false);
};
const isAdmin = currentUser.role?.toLowerCase() === 'admin';
const canEdit = (lab: LabTemplate) => isAdmin || lab.ownerId === currentUser.id || lab.ownerId === '';
const myPersonalLabs = labs.filter(l => l.scope === 'personal' && l.ownerId === currentUser.id);
const globalLabs = labs.filter(l => l.scope === 'global');
const othersPersonal = isAdmin ? labs.filter(l => l.scope === 'personal' && l.ownerId !== currentUser.id) : [];
const renderLabCard = (lab: LabTemplate) => {
const isSelected = selectedLab?.id === lab.id;
const editable = canEdit(lab);
return (
<div
key={lab.id}
onClick={() => setSelectedLab(lab)}
className={`p-4 rounded-xl border transition-all cursor-pointer relative ${
isSelected
? 'bg-slate-900 border-emerald-500'
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
}`}
>
<div className="flex justify-between items-start">
<h3 className="font-bold text-sm text-white">{lab.name}</h3>
<div className="flex gap-1.5" onClick={e => e.stopPropagation()}>
{editable && (
<>
<button
onClick={() => handleOpenEdit(lab)}
className="text-slate-400 hover:text-indigo-400 p-0.5"
title="Edit template configuration"
>
<Edit3 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Are you sure you want to delete lab template "${lab.name}"? Active/completed scheduling logs are retained in the system.`)) {
onDeleteLab(lab.id);
}
}}
className="text-slate-400 hover:text-rose-400 p-0.5"
title="Delete template"
>
<Trash className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</div>
<p className="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">
{lab.description}
</p>
<div className="mt-3 pt-3 border-t border-slate-800/80 grid grid-cols-2 gap-1 text-[10px] text-slate-350">
<div className="flex items-center gap-1">
<UserIcon className="w-3.5 h-3.5 text-slate-500 shrink-0" />
<span className="truncate">{lab.contactPerson}</span>
</div>
<div className="flex items-center gap-1">
<MapPin className="w-3.5 h-3.5 text-slate-500 shrink-0" />
<span className="truncate">{lab.location}</span>
</div>
</div>
<div className="mt-2.5 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-mono text-emerald-400 bg-emerald-950/50 px-2 py-0.5 rounded border border-emerald-900/50">
{lab.deviceIds.length} connected devices
</span>
{lab.scope === 'personal' ? (
<span className="text-[10px] font-mono text-indigo-400 bg-indigo-950/50 px-2 py-0.5 rounded border border-indigo-900/50 flex items-center gap-1">
<Lock className="w-2.5 h-2.5" /> Personal
</span>
) : (
<span className="text-[10px] font-mono text-slate-400 bg-slate-950/50 px-2 py-0.5 rounded border border-slate-800/50 flex items-center gap-1">
<Globe className="w-2.5 h-2.5" /> Global
</span>
)}
</div>
<ChevronRight className={`w-4 h-4 text-slate-500 transition-transform ${isSelected ? 'translate-x-1 text-emerald-400' : ''}`} />
</div>
</div>
);
};
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="lab-templates-container">
@ -174,68 +269,29 @@ export default function LabTemplates({
</button>
</div>
{/* Labs templates list */}
{/* Labs templates list — sectioned */}
<div className="space-y-3 flex-1 overflow-y-auto max-h-[550px] pr-1">
{labs.map((lab) => {
const isSelected = selectedLab?.id === lab.id;
return (
<div
key={lab.id}
onClick={() => setSelectedLab(lab)}
className={`p-4 rounded-xl border transition-all cursor-pointer relative ${
isSelected
? 'bg-slate-900 border-emerald-500'
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
}`}
>
<div className="flex justify-between items-start">
<h3 className="font-bold text-sm text-white">{lab.name}</h3>
<div className="flex gap-1.5" onClick={e => e.stopPropagation()}>
<button
onClick={() => handleOpenEdit(lab)}
className="text-slate-400 hover:text-indigo-400 p-0.5"
title="Edit template configuration"
>
<Edit3 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Are you sure you want to delete lab template "${lab.name}"? Active/completed scheduling logs are retained in the system.`)) {
onDeleteLab(lab.id);
}
}}
className="text-slate-400 hover:text-rose-400 p-0.5"
title="Delete template"
>
<Trash className="w-3.5 h-3.5" />
</button>
</div>
</div>
<p className="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">
{lab.description}
</p>
<div className="mt-3 pt-3 border-t border-slate-800/80 grid grid-cols-2 gap-1 text-[10px] text-slate-350">
<div className="flex items-center gap-1">
<User className="w-3.5 h-3.5 text-slate-500 shrink-0" />
<span className="truncate">{lab.contactPerson}</span>
</div>
<div className="flex items-center gap-1">
<MapPin className="w-3.5 h-3.5 text-slate-500 shrink-0" />
<span className="truncate">{lab.location}</span>
</div>
</div>
<div className="mt-2.5 flex items-center justify-between">
<span className="text-[10px] font-mono text-emerald-400 bg-emerald-950/50 px-2 py-0.5 rounded border border-emerald-900/50">
{lab.deviceIds.length} connected devices
</span>
<ChevronRight className={`w-4 h-4 text-slate-500 transition-transform ${isSelected ? 'translate-x-1 text-emerald-400' : ''}`} />
</div>
</div>
);
})}
{myPersonalLabs.length > 0 && (
<>
<p className="text-[10px] font-mono uppercase tracking-widest text-indigo-400 px-1">My Topologies</p>
{myPersonalLabs.map(renderLabCard)}
</>
)}
{globalLabs.length > 0 && (
<>
<p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Global Topologies</p>
{globalLabs.map(renderLabCard)}
</>
)}
{othersPersonal.length > 0 && (
<>
<p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Others' Personal</p>
{othersPersonal.map(renderLabCard)}
</>
)}
{labs.length === 0 && (
<p className="text-xs text-slate-500 text-center py-8">No topology templates yet.</p>
)}
</div>
</div>
@ -254,7 +310,7 @@ export default function LabTemplates({
</div>
<div className="flex items-center gap-2.5">
<div className="bg-slate-900 p-2 rounded-lg border border-slate-805 text-[10px] flex items-center gap-1">
<User className="w-3.5 h-3.5 text-slate-400" />
<UserIcon className="w-3.5 h-3.5 text-slate-400" />
<div>
<p className="text-slate-400 leading-none">Primary Contact</p>
<p className="text-white font-semibold mt-0.5">{selectedLab.contactPerson}</p>
@ -347,7 +403,6 @@ export default function LabTemplates({
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="e.g. Campus Core OSPF Backup Route"
/>
</div>
<div>
@ -358,7 +413,6 @@ export default function LabTemplates({
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="e.g. Server Room R01, Cabinet B"
/>
</div>
</div>
@ -373,7 +427,6 @@ export default function LabTemplates({
value={formData.contactPerson}
onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="e.g. Jane Doe"
/>
</div>
<div>
@ -384,11 +437,39 @@ export default function LabTemplates({
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="Purpose, VLAN mappings, target device models..."
/>
</div>
</div>
{/* Scope toggle */}
<div className="border-t border-slate-800 pt-3">
<label className="block text-slate-300 font-semibold mb-1.5">Visibility</label>
<div className="grid grid-cols-2 gap-1.5">
<button
type="button"
onClick={() => setFormData({ ...formData, scope: 'global' })}
className={`py-2 rounded-lg text-xs font-semibold border transition-all flex items-center justify-center gap-1.5 ${
formData.scope === 'global'
? 'bg-slate-800 border-slate-500 text-slate-200'
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
}`}
>
<Globe className="w-3.5 h-3.5" /> Global — visible to all
</button>
<button
type="button"
onClick={() => setFormData({ ...formData, scope: 'personal' })}
className={`py-2 rounded-lg text-xs font-semibold border transition-all flex items-center justify-center gap-1.5 ${
formData.scope === 'personal'
? 'bg-indigo-900/30 border-indigo-500 text-indigo-300'
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
}`}
>
<Lock className="w-3.5 h-3.5" /> Personal — only you
</button>
</div>
</div>
{/* Hardware checklist */}
<div className="border-t border-slate-800 pt-3">
<label className="block text-slate-300 font-bold mb-1.5">1. Assign Dedicated Hardware Nodes</label>
@ -458,7 +539,6 @@ export default function LabTemplates({
<input
type="text"
className="w-full bg-slate-950 text-slate-250 border border-slate-805 p-1 rounded font-mono text-[11px]"
placeholder="e.g. LACP Port-Channel 1"
value={linkType}
onChange={(e) => setLinkType(e.target.value)}
/>
@ -536,7 +616,7 @@ export default function LabTemplates({
</div>
{/* Ansible Semaphore Automation */}
<div className="border-t border-slate-800 pt-3">
{semaphoreEnabled && <div className="border-t border-slate-800 pt-3">
<label className="block text-slate-300 font-bold mb-1.5 flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5 text-orange-400" />
3. Ansible Automation (optional)
@ -551,7 +631,6 @@ export default function LabTemplates({
value={formData.semaphoreSetupTemplateId}
onChange={(e) => setFormData({ ...formData, semaphoreSetupTemplateId: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 font-mono text-xs focus:outline-none focus:border-orange-500/60"
placeholder="e.g. 3"
/>
</div>
<div>
@ -562,11 +641,10 @@ export default function LabTemplates({
value={formData.semaphoreTeardownTemplateId}
onChange={(e) => setFormData({ ...formData, semaphoreTeardownTemplateId: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 font-mono text-xs focus:outline-none focus:border-orange-500/60"
placeholder="e.g. 4"
/>
</div>
</div>
</div>
</div>}
{/* Form submit handlers */}
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
@ -581,7 +659,7 @@ export default function LabTemplates({
type="submit"
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs animate-none"
>
Save Lab Template
Save
</button>
</div>

View File

@ -168,7 +168,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
<span className="absolute left-3 top-2.5 text-slate-550"><Search className="w-4 h-4" /></span>
<input
type="text"
placeholder="Search links by name, host, category…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none focus:border-emerald-600"
@ -263,7 +262,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
if (e.key === 'Escape') { setEditingDescId(null); }
}}
className="w-full mt-3 bg-slate-950 text-slate-200 text-[11px] border border-emerald-600 rounded px-2 py-1 resize-none focus:outline-none leading-relaxed"
placeholder="Add a description"
/>
) : (
<p
@ -325,7 +323,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
required autoFocus
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
placeholder="e.g. CheckMK Monitoring"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
/>
</div>
@ -336,7 +333,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
required
value={draft.url}
onChange={(e) => setDraft({ ...draft, url: e.target.value })}
placeholder="https://checkmk.internal"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600 font-mono"
/>
</div>
@ -347,7 +343,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
list="link-categories"
value={draft.category}
onChange={(e) => setDraft({ ...draft, category: e.target.value })}
placeholder="e.g. Monitoring, Automation, Docs"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
/>
<datalist id="link-categories">
@ -361,7 +356,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
rows={2}
value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
placeholder="What is this tool for?"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600 resize-none"
/>
</div>

View File

@ -117,7 +117,6 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
</span>
<input
type="text"
placeholder="Filter audit log entries..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-slate-950 text-slate-101 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none"
@ -239,7 +238,6 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
value={logMessage}
onChange={(e) => setLogMessage(e.target.value)}
className="w-full bg-slate-950 text-slate-200 border border-slate-800 rounded p-2 focus:outline-none"
placeholder="Describe SFP fiber replacements, port trunking alterations, routing table diagnostic evaluations..."
/>
</div>

View File

@ -102,7 +102,6 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
placeholder="user@airit.rocks"
/>
</div>
@ -119,7 +118,6 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 pr-10 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
placeholder="••••••••"
/>
<button
type="button"

View File

@ -106,7 +106,6 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
value={name}
onChange={e => setName(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
placeholder="Max Mustermann"
/>
</div>
@ -122,7 +121,6 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
placeholder="you@example.com"
/>
</div>
@ -139,7 +137,6 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 pr-10 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
placeholder="Min. 8 characters"
/>
<button
type="button"
@ -174,7 +171,6 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
? 'border-red-700 focus:ring-red-500/50'
: 'border-slate-700 focus:ring-cyan-500/50 focus:border-cyan-500/50'
}`}
placeholder="Repeat password"
/>
</div>

View File

@ -35,6 +35,7 @@ interface CaddyRoute {
upstream: string;
tls: number;
compress: number;
redirect: string;
}
interface DbInfo {
@ -108,10 +109,9 @@ function Input({ value, onChange, placeholder, monospace, icon }: {
);
}
function SecretInput({ value, onChange, alreadySet, show, onToggleShow }: {
function SecretInput({ value, onChange, show, onToggleShow }: {
value: string;
onChange: (v: string) => void;
alreadySet: boolean;
show: boolean;
onToggleShow: () => void;
}) {
@ -124,7 +124,6 @@ function SecretInput({ value, onChange, alreadySet, show, onToggleShow }: {
type={show ? 'text' : 'password'}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={alreadySet ? '•••••••• (leave blank to keep)' : 'Secret'}
className="w-full bg-slate-900 border border-slate-700 rounded-lg pl-9 pr-10 py-2.5 text-xs font-mono text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
/>
<button
@ -174,7 +173,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [checkmkEnabled, setCheckmkEnabled] = useState(false);
const [checkmkApiUrl, setCheckmkApiUrl] = useState('');
const [checkmkApiUser, setCheckmkApiUser] = useState('automation');
const [checkmkApiUser, setCheckmkApiUser] = useState('');
const [checkmkApiSecret, setCheckmkApiSecret] = useState('');
const [checkmkSecretSet, setCheckmkSecretSet] = useState(false);
const [checkmkSyncInterval, setCheckmkSyncInterval] = useState('60000');
@ -192,7 +191,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [caddyEnabled, setCaddyEnabled] = useState(false);
const [caddyManaged, setCaddyManaged] = useState(true);
const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://localhost:2019');
const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://127.0.0.1:2019');
const [caddyStatus, setCaddyStatus] = useState<'unknown' | 'available' | 'unavailable'>('unknown');
const [caddyRoutes, setCaddyRoutes] = useState<CaddyRoute[]>([]);
const [addingRoute, setAddingRoute] = useState(false);
@ -200,12 +199,14 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [newUpstream, setNewUpstream] = useState('');
const [newTls, setNewTls] = useState(true);
const [newCompress, setNewCompress] = useState(true);
const [newRedirect, setNewRedirect] = useState('');
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
const [editHostname, setEditHostname] = useState('');
const [editUpstream, setEditUpstream] = useState('');
const [editTls, setEditTls] = useState(true);
const [editCompress, setEditCompress] = useState(true);
const [editRedirect, setEditRedirect] = useState('');
const [savingRoute, setSavingRoute] = useState(false);
const [dbInfo, setDbInfo] = useState<DbInfo | null>(null);
@ -222,7 +223,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
.then(r => r.json())
.then(d => {
if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri);
setCaddyManaged(d.caddyManaged !== false);
setCaddyManaged(d.isProduction !== false);
})
.catch(() => {});
}, []);
@ -252,7 +253,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setAzureAllowedGroup(data.azure_allowed_group || '');
setCheckmkEnabled(data.checkmk_enabled === 'true');
setCheckmkApiUrl(data.checkmk_api_url || '');
setCheckmkApiUser(data.checkmk_api_user || 'automation');
setCheckmkApiUser(data.checkmk_api_user || '');
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
setCheckmkApiSecret('');
setCheckmkSyncInterval(data.checkmk_sync_interval_ms || '60000');
@ -262,7 +263,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setSemaphoreApiToken('');
setSemaphoreProjectId(data.semaphore_project_id || '');
setCaddyEnabled(data.caddy_enabled === 'true');
setCaddyAdminUrl(data.caddy_admin_url || 'http://localhost:2019');
setCaddyAdminUrl(data.caddy_admin_url || 'http://127.0.0.1:2019');
} catch {
setError('Network error loading settings.');
} finally {
@ -347,7 +348,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
return;
}
const templates = await res.json() as any[];
setSemaphoreTestResult(`Connected ${templates.length} task template${templates.length !== 1 ? 's' : ''} found.`);
setSemaphoreTestResult(`Connected - ${templates.length} task template${templates.length !== 1 ? 's' : ''} found.`);
} catch {
setSemaphoreTestResult('Error: Network error connecting to Semaphore.');
} finally {
@ -368,7 +369,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const res = await authFetch('/api/caddy/routes');
if (res.ok) setCaddyRoutes(await res.json());
} catch {}
// Status check runs separately purely informational, never blocks the list
// Status check runs separately - purely informational, never blocks the list
authFetch('/api/caddy/status')
.then(res => res.ok ? res.json() : null)
.then(s => setCaddyStatus(s?.available ? 'available' : 'unavailable'))
@ -381,7 +382,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
try {
const res = await authFetch('/api/caddy/routes', {
method: 'POST',
body: JSON.stringify({ hostname: newHostname.trim(), upstream: newUpstream.trim(), tls: newTls, compress: newCompress }),
body: JSON.stringify({ hostname: newHostname.trim(), upstream: newUpstream.trim(), tls: newTls, compress: newCompress, redirect: newRedirect.trim() }),
});
if (!res.ok) {
const d = await res.json();
@ -392,6 +393,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setNewUpstream('');
setNewTls(true);
setNewCompress(true);
setNewRedirect('');
await loadCaddyRoutes();
} catch {
setError('Network error adding route.');
@ -420,6 +422,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setEditUpstream(r.upstream);
setEditTls(r.tls === 1);
setEditCompress(r.compress === 1);
setEditRedirect(r.redirect || '');
}
function handleEditCancel() {
@ -432,7 +435,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
try {
const res = await authFetch(`/api/caddy/routes/${id}`, {
method: 'PUT',
body: JSON.stringify({ hostname: editHostname.trim(), upstream: editUpstream.trim(), tls: editTls, compress: editCompress }),
body: JSON.stringify({ hostname: editHostname.trim(), upstream: editUpstream.trim(), tls: editTls, compress: editCompress, redirect: editRedirect.trim() }),
});
if (!res.ok) {
const d = await res.json();
@ -565,7 +568,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</div>
)}
{/* Section tabs switch between Integrations and System to keep the page light */}
{/* Section tabs - switch between Integrations and System to keep the page light */}
<div className="inline-flex items-center gap-0.5 p-0.5 bg-slate-900/50 border border-slate-800 rounded-lg w-fit">
{([
{ id: 'integrations', label: 'Integrations', icon: <Plug className="w-3.5 h-3.5" /> },
@ -629,10 +632,10 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<div className={`grid grid-cols-1 sm:grid-cols-2 gap-5 transition-opacity duration-200 ${!azureEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<FieldRow label="Tenant ID" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
<Input value={azureTenantId} onChange={setAzureTenantId} placeholder="Tenant ID" monospace />
<Input value={azureTenantId} onChange={setAzureTenantId} monospace />
</FieldRow>
<FieldRow label="Client ID (Application ID)" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
<Input value={azureClientId} onChange={setAzureClientId} placeholder="Client ID" monospace />
<Input value={azureClientId} onChange={setAzureClientId} monospace />
</FieldRow>
<FieldRow
label="Client Secret"
@ -641,7 +644,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<SecretInput
value={azureClientSecret}
onChange={setAzureClientSecret}
alreadySet={azureSecretSet}
show={showAzureSecret}
onToggleShow={() => setShowAzureSecret(v => !v)}
/>
@ -654,7 +656,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<Input
value={azureRedirectUri}
onChange={setAzureRedirectUri}
placeholder={effectiveRedirectUri || 'https://…/api/auth/azure/callback'}
monospace
/>
</FieldRow>
@ -666,7 +667,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<Input
value={azureAllowedGroup}
onChange={setAzureAllowedGroup}
placeholder="Leave blank to allow all tenant users"
monospace
icon={<Users className="w-3.5 h-3.5" />}
/>
@ -737,17 +737,15 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<Input
value={checkmkApiUrl}
onChange={setCheckmkApiUrl}
placeholder="https://checkmk/<site>/check_mk/api/1.0"
monospace
icon={<Globe className="w-3.5 h-3.5" />}
/>
</FieldRow>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<FieldRow label="Automation User" hint="Setup > Users > Automation user (e.g. automation)">
<FieldRow label="Automation User" hint="Setup > Users > Automation user">
<Input
value={checkmkApiUser}
onChange={setCheckmkApiUser}
placeholder="automation"
monospace
icon={<KeyRound className="w-3.5 h-3.5" />}
/>
@ -760,7 +758,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<SecretInput
value={checkmkApiSecret}
onChange={setCheckmkApiSecret}
alreadySet={checkmkSecretSet}
show={showCheckmkSecret}
onToggleShow={() => setShowCheckmkSecret(v => !v)}
/>
@ -769,7 +766,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<Input
value={checkmkSyncInterval}
onChange={setCheckmkSyncInterval}
placeholder="60000"
monospace
icon={<Clock className="w-3.5 h-3.5" />}
/>
@ -830,7 +826,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<Input
value={semaphoreApiUrl}
onChange={setSemaphoreApiUrl}
placeholder="https://semaphore/api/v1alpha"
monospace
icon={<Globe className="w-3.5 h-3.5" />}
/>
@ -844,7 +839,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<SecretInput
value={semaphoreApiToken}
onChange={setSemaphoreApiToken}
alreadySet={semaphoreTokenSet}
show={showSemaphoreToken}
onToggleShow={() => setShowSemaphoreToken(v => !v)}
/>
@ -853,7 +847,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<Input
value={semaphoreProjectId}
onChange={setSemaphoreProjectId}
placeholder="1"
monospace
icon={<KeyRound className="w-3.5 h-3.5" />}
/>
@ -939,19 +932,19 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
{caddyManaged && (
<div className={`space-y-5 transition-opacity duration-200 ${!caddyEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<FieldRow label="Caddy Admin URL" hint="Default: http://localhost:2019">
<Input value={caddyAdminUrl} onChange={setCaddyAdminUrl} placeholder="http://localhost:2019" monospace icon={<Globe className="w-3.5 h-3.5" />} />
<FieldRow label="Caddy Admin URL" hint="Default: http://127.0.0.1:2019">
<Input value={caddyAdminUrl} onChange={setCaddyAdminUrl} monospace icon={<Globe className="w-3.5 h-3.5" />} />
</FieldRow>
{/* Route list */}
{caddyEnabled && (
<div className="space-y-2">
<Label>Proxy Routes</Label>
<Hint>Prefix the upstream with https:// for TLS backends (e.g. Semaphore) — the certificate is not verified.</Hint>
<Hint>Prefix the upstream with https:// for TLS backends - the certificate is not verified.</Hint>
{caddyStatus === 'unavailable' && (
<p className="text-[11px] font-mono text-amber-400 mb-2">
Caddy Admin API not reachable routes will be applied when Caddy starts.
Caddy Admin API not reachable - routes will be applied when Caddy starts.
</p>
)}
@ -965,14 +958,15 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
{caddyRoutes.map((r: CaddyRoute) => (
<div key={r.id} className="bg-slate-900/60 border border-slate-700/60 rounded-xl px-4 py-2.5">
{editingRouteId === r.id ? (
<div className="space-y-2">
<div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<Label>Hostname</Label>
<Input value={editHostname} onChange={setEditHostname} placeholder="hostname" monospace />
<Input value={editHostname} onChange={setEditHostname} monospace />
</div>
<div className="flex-1 min-w-0">
<Label>Upstream</Label>
<Input value={editUpstream} onChange={setEditUpstream} placeholder="127.0.0.1:3000" monospace />
<Input value={editUpstream} onChange={setEditUpstream} monospace />
</div>
<div className="flex flex-col items-center gap-1 pb-0.5">
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">TLS</span>
@ -997,6 +991,8 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<X className="w-3.5 h-3.5" />
</button>
</div>
<Input value={editRedirect} onChange={setEditRedirect} monospace />
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0">
@ -1005,6 +1001,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<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.compress ? <span className="text-[9px] font-semibold text-slate-500 font-mono">GZ</span> : null}
{r.redirect ? <span className="text-[9px] font-mono text-slate-500 truncate">&#8627; {r.redirect}</span> : null}
</div>
<div className="flex items-center gap-1 ml-3 shrink-0">
<button type="button" onClick={() => handleEditStart(r)}
@ -1022,14 +1019,15 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
))}
{/* Add route form */}
<div className="flex items-end gap-2 pt-1">
<div className="space-y-2 pt-1">
<div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<Label>Hostname</Label>
<Input value={newHostname} onChange={setNewHostname} placeholder="semaphore" monospace />
<Input value={newHostname} onChange={setNewHostname} monospace />
</div>
<div className="flex-1 min-w-0">
<Label>Upstream</Label>
<Input value={newUpstream} onChange={setNewUpstream} placeholder="127.0.0.1:3000" monospace />
<Input value={newUpstream} onChange={setNewUpstream} monospace />
</div>
<div className="flex flex-col items-center gap-1 pb-0.5">
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">TLS</span>
@ -1061,6 +1059,8 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
{addingRoute ? 'Adding…' : 'Add'}
</button>
</div>
<Input value={newRedirect} onChange={setNewRedirect} monospace />
</div>
</div>
)}
</div>
@ -1083,7 +1083,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</div>
<div className="text-right">
<p className="text-xl font-bold text-white font-mono leading-none">
{dbInfo ? formatBytes(dbInfo.sizeBytes) : ''}
{dbInfo ? formatBytes(dbInfo.sizeBytes) : '-'}
</p>
<p className="text-[10px] text-slate-500 font-mono mt-1">
{dbInfo ? new Date(dbInfo.lastModified).toLocaleDateString() : 'Loading…'}
@ -1160,7 +1160,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<div className="flex items-start gap-2 bg-amber-950/40 border border-amber-900/50 rounded-xl px-3 py-2">
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-amber-300 leading-relaxed">
<strong>Import overwrites the entire database</strong> this cannot be undone.
<strong>Import overwrites the entire database</strong> - this cannot be undone.
</p>
</div>
<label className="w-full flex items-center gap-2 cursor-pointer bg-slate-900 border border-slate-700 hover:border-slate-600 rounded-lg px-3 py-2 text-xs text-slate-400 hover:text-slate-200 transition-all">

View File

@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { User, Booking } from '../types';
import { Users, Search, Mail, Calendar, Activity, Trash2, Pencil, X, Save, AlertCircle } from 'lucide-react';
import { Users, Search, Mail, Calendar, Activity, Trash2, Pencil, X, Save, AlertCircle, ShieldCheck, Shield } from 'lucide-react';
interface UserDirectoryProps {
users: User[];
@ -8,6 +8,7 @@ interface UserDirectoryProps {
bookings: Booking[];
onDeleteUser: (id: string) => Promise<void>;
onUpdateUser: (id: string, name: string, email: string) => Promise<void>;
onSetRole: (id: string, role: string) => Promise<void>;
}
const AVATAR_COLORS = [
@ -117,10 +118,13 @@ function EditModal({ user, onClose, onSave }: EditModalProps) {
);
}
export default function UserDirectory({ users, currentUser, bookings, onDeleteUser, onUpdateUser }: UserDirectoryProps) {
export default function UserDirectory({ users, currentUser, bookings, onDeleteUser, onUpdateUser, onSetRole }: UserDirectoryProps) {
const [search, setSearch] = useState('');
const [editingUser, setEditingUser] = useState<User | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [togglingRoleId, setTogglingRoleId] = useState<string | null>(null);
const [roleError, setRoleError] = useState<string | null>(null);
const isCurrentUserAdmin = currentUser.role.toLowerCase() === 'admin';
const bookingCount = useMemo(() => {
const map = new Map<string, number>();
@ -152,6 +156,18 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
try { await onDeleteUser(id); } finally { setDeletingId(null); }
}
async function handleToggleRole(user: User) {
setTogglingRoleId(user.id);
setRoleError(null);
try {
await onSetRole(user.id, user.role.toLowerCase() === 'admin' ? 'User' : 'admin');
} catch (err: any) {
setRoleError(err.message || 'Failed to change role.');
} finally {
setTogglingRoleId(null);
}
}
async function handleSaveEdit(name: string, email: string) {
if (!editingUser) return;
await onUpdateUser(editingUser.id, name, email);
@ -190,12 +206,19 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
</div>
</div>
{roleError && (
<div className="flex items-center gap-2 bg-red-950/50 border border-red-900/50 rounded-lg px-3 py-2 text-xs text-red-300">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
{roleError}
<button onClick={() => setRoleError(null)} className="ml-auto text-red-400 hover:text-white"><X className="w-3 h-3" /></button>
</div>
)}
{/* Search */}
<div className="relative">
<span className="absolute left-3 top-2.5 text-slate-500"><Search className="w-4 h-4" /></span>
<input
type="text"
placeholder="Search operators by name, email or role…"
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-[#1E293B] text-slate-100 border border-slate-800 rounded-xl pl-9 pr-4 py-2 text-xs focus:outline-none focus:border-emerald-600"
@ -240,7 +263,10 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
</div>
<div className="mt-4 pt-3 border-t border-slate-800 flex items-center justify-between gap-2">
<span className="text-[10px] font-mono text-slate-500 uppercase tracking-wider">Operator</span>
{user.role.toLowerCase() === 'admin'
? <span className="flex items-center gap-1 text-[10px] font-mono text-amber-400 uppercase tracking-wider"><ShieldCheck className="w-3 h-3" />Admin</span>
: <span className="text-[10px] font-mono text-slate-500 uppercase tracking-wider">User</span>
}
<div className="flex items-center gap-2">
<div className="flex items-center gap-3 text-[10px] font-mono text-slate-400">
@ -256,6 +282,20 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
{/* Action buttons */}
<div className="flex items-center gap-1 ml-1">
{isCurrentUserAdmin && !isMe && (
<button
onClick={() => handleToggleRole(user)}
disabled={togglingRoleId === user.id}
className={`p-1.5 rounded-lg transition-all disabled:opacity-40 ${user.role.toLowerCase() === 'admin' ? 'text-amber-400 hover:text-slate-400 hover:bg-slate-900' : 'text-slate-500 hover:text-amber-400 hover:bg-slate-900'}`}
title={user.role.toLowerCase() === 'admin' ? 'Remove admin' : 'Make admin'}
>
{togglingRoleId === user.id
? <span className="w-3.5 h-3.5 border-2 border-slate-600 border-t-amber-400 rounded-full animate-spin inline-block" />
: user.role.toLowerCase() === 'admin'
? <ShieldCheck className="w-3.5 h-3.5" />
: <Shield className="w-3.5 h-3.5" />}
</button>
)}
<button
onClick={() => setEditingUser(user)}
className="p-1.5 rounded-lg text-slate-500 hover:text-cyan-400 hover:bg-slate-900 transition-all"

View File

@ -109,6 +109,7 @@
:root.light .bg-slate-950\/20,
:root.light .bg-slate-950\/30,
:root.light .bg-slate-950\/40,
:root.light .bg-slate-950\/50,
:root.light .bg-slate-950\/60,
:root.light .bg-slate-950\/80 {
background-color: rgba(241, 245, 249, 0.85) !important;
@ -730,6 +731,12 @@
/* ── Missing border opacity variants ─────────────────────────────── */
/* slate-800 with opacity */
:root.light .border-slate-800\/50,
:root.light .border-slate-800\/40 {
border-color: var(--border) !important;
}
/* slate-700 with opacity */
:root.light .border-slate-700\/40,
:root.light .border-slate-700\/50,

View File

@ -36,6 +36,8 @@ export interface LabTemplate {
topology: TopologyLink[];
semaphoreSetupTemplateId?: string;
semaphoreTeardownTemplateId?: string;
scope: 'global' | 'personal';
ownerId: string;
}
export interface Booking {