Compare commits

...

14 Commits

Author SHA1 Message Date
cc96f5b6ce chore(release): merge dev into main 2026-06-10 16:39:27 +02:00
e0fd19f471 feat(topology): hide Ansible Automation section when Semaphore is disabled 2026-06-10 16:37:50 +02:00
5c7ad3140a feat(db): add lightweight migration system
Introduce server-migrations.ts with a named-migration runner that tracks
applied migrations in a _migrations table. runMigrations(db) is called at
startup before routes, so additive schema changes (ALTER TABLE, new settings)
are applied once and skipped on subsequent restarts.

Update ARCHITECTURE.md: five inline edits + new §4.4 documenting the convention.
2026-06-10 16:30:44 +02:00
c3931e7f36 style(ui): remove placeholder text from all input fields
Strips example/hint placeholder attributes across all components for a cleaner, less cluttered form UX.
2026-06-10 16:25:28 +02:00
d78ade4629 docs(architecture): reflect personal/global topology scope feature 2026-06-10 16:20:42 +02:00
84bad8c0e6 feat(auth): admin role management with logbook entries 2026-06-10 16:05:08 +02:00
08a4df5503 feat(topology): add personal/global scope to lab templates
Labs can now be marked as Personal or Global when creating or editing.
Personal topologies are visible only to the owner and admins; others
cannot see, book, or edit them. Global topologies are visible to all
but editable only by the creator, admins, or legacy (migrated) labs.

- DB: idempotent ALTER TABLE adds scope + ownerId columns to labs
- API: POST sets ownerId from JWT; PUT/DELETE enforce ownership (403 for
  unauthorized edits; legacy ownerId='' remains freely editable)
- Types: LabTemplate extended with scope and ownerId fields
- LabTemplates UI: sectioned list (My / Global / Others' Personal),
  Personal/Global toggle in form, Lock/Globe badges on cards,
  edit+delete buttons hidden for non-owners
- BookingCalendar: personal labs filtered from selects/quick booking,
  optgroup grouping for Global vs Personal in topology dropdown
- Light mode: add missing bg-slate-950/50 and border-slate-800/50
  overrides so the Global badge renders correctly
2026-06-10 15:51:53 +02:00
cb36caff2e fix(auth): log Entra login events to logbook 2026-06-10 15:15:23 +02:00
be007791dc refactor(db): rename redirect_path→redirect, add uid/addLog helpers, simplify Caddy CRUD
- Rename caddy.redirect_path to caddy.redirect across schema, server, frontend and docs
- Remove obsolete ALTER TABLE migration (fresh-install model has no migrations)
- Move uid() from server.ts to server-db.ts for shared use
- Add addLog() general helper (prepared statement, shared timestamp support) and
  replace ~24 inline INSERT INTO logs calls throughout server.ts
- Caddy CRUD now takes CaddyRouteInput object instead of positional arguments;
  add/update reuse getCaddyRouteById() to avoid duplicate SELECT
2026-06-10 15:08:35 +02:00
515052fbda refactor: replace CADDY_MANAGER with DEPLOY_ENV for instance-role awareness
DEPLOY_ENV=production now marks the primary instance globally - used for
Caddy ownership, the Dev/Prod header badge, and Caddy UI gating. Removes
build-time VITE_DEPLOY_ENV/import.meta.env.DEV from the header in favour
of the runtime API response (isProduction field in /api/auth/config).
2026-06-10 14:43:31 +02:00
49cd0ae4f6 feat(caddy): optional root redirect per route
Add a redirect_path column to the caddy table and an optional 'root redirect'
field in the route form. When set, buildCaddyfile emits 'redir / <path>' so the
bare host (e.g. checkmk.domain.local/) redirects to a sub-path (e.g.
/monitoring/check_mk/) while every other path still passes through to the
backend — the safe pattern for apps like CheckMK that bake their site path into
absolute URLs. Defensive ALTER TABLE keeps existing databases working.
2026-06-10 10:22:39 +02:00
a2d515992c fix(logbook): 'All' filter shows every log including system entries
Drop the 'non-system' default filter; 'All' now means all log types.
2026-06-09 13:09:04 +02:00
2a2902d5bc feat(ui): distinguish dev/prod via VITE_DEPLOY_ENV
Both instances run with NODE_ENV=production, so import.meta.env.PROD was
always true and the header always showed 'Production'. deploy.sh now passes
VITE_DEPLOY_ENV=<branch> into the build and Header reads it to label the
system indicator dev vs prod correctly.
2026-06-09 13:09:03 +02:00
ac1cf8fec7 docs(architecture): sync Caddy manager gate in first-start + ownership invariant 2026-06-09 13:09:01 +02:00
19 changed files with 596 additions and 313 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,
@ -282,6 +285,7 @@ CREATE TABLE IF NOT EXISTS caddy (
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}
@ -501,8 +532,8 @@ Default admin user (only on a blank database):
INSERT user (name='admin', role='admin', email='admin@ghostgrid.local', password=bcrypt('admin'))
→ log "[Init] Default admin user created"
Caddy route import (re-deploy safety net):
if caddy_enabled === 'true' AND caddy table is empty:
Caddy route import (re-deploy safety net, Caddy manager only):
if CADDY_MANAGER === 'true' AND caddy_enabled === 'true' AND caddy table is empty:
importCaddyfileRoutes() → seed routes from /etc/caddy/Caddyfile
(also runs in PUT /api/settings on the disabled → enabled transition)
@ -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,8 +875,9 @@ 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

@ -16,7 +16,7 @@ git fetch --prune origin
git checkout "$BRANCH"
git pull --ff-only origin "$BRANCH"
npm ci
npm run build
VITE_DEPLOY_ENV="$BRANCH" npm run build
sudo systemctl restart "$SVC"
echo "Deployed $BRANCH ($SVC). Status:"
systemctl --no-pager status "$SVC" | head -n 5

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);
@ -91,10 +94,15 @@ db.exec(`
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}`);
}
}

239
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',
addLog('maintenance',
`Provisioned a new host candidate "${hostname}" (${ip}) inside location ${location || 'Inventory'}.`,
id, req.user!.userId);
{ 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',
addLog('maintenance',
`Permanently removed the host device "${dev.hostname || id}" from the inventory records.`,
null, req.user!.userId);
{ 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',
addLog('maintenance',
`Released a new lab template profile "${name}" (${location || 'Staging Area'}) for engineering teams.`,
req.user!.userId);
{ 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',
addLog('booking',
`${operatorName || 'An operator'} canceled the reservation for lab template "${lab?.name || 'Unknown'}" and released associated nodes back into the pool.`,
req.user!.userId);
{ 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) => (
{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.PROD ? 'bg-emerald-500' : 'bg-amber-400'}`} />
<span>System: {import.meta.env.PROD ? 'Production' : 'Development'}</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

@ -20,7 +20,7 @@ interface LogbookProps {
export default function Logbook({ logs, devices, users, currentUser, onAddLog }: LogbookProps) {
const [searchTerm, setSearchTerm] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('non-system');
const [typeFilter, setTypeFilter] = useState<string>('all');
// Custom Maintenance Log state
const [showAddLog, setShowAddLog] = useState(false);
@ -35,7 +35,6 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
const matchesSearch = log.message.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType =
typeFilter === 'all' ? true :
typeFilter === 'non-system' ? log.type !== 'system' :
log.type === typeFilter;
return matchesSearch && matchesType;
});
@ -118,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"
@ -126,7 +124,7 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
</div>
<div className="flex gap-1 shrink-0 text-xs font-medium flex-wrap">
{[
{ key: 'non-system', label: 'All' },
{ key: 'all', label: 'All' },
{ key: 'booking', label: 'Booking' },
{ key: 'maintenance',label: 'Maintenance' },
{ key: 'status', label: 'Status' },
@ -240,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 {