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 | | Component | Technology | Purpose |
|-----------|------------|---------| |-----------|------------|---------|
| Database | SQLite via `better-sqlite3` | Single file `ghostgrid.db`, WAL journal mode, synchronous queries | | 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` | | 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 ## 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`) ### 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[] deviceIds TEXT NOT NULL, -- JSON string: string[]
topology TEXT NOT NULL, -- JSON string: TopologyLink[] topology TEXT NOT NULL, -- JSON string: TopologyLink[]
semaphoreSetupTemplateId TEXT NOT NULL DEFAULT '', 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 ( CREATE TABLE IF NOT EXISTS bookings (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -277,12 +280,13 @@ CREATE TABLE IF NOT EXISTS settings (
); );
CREATE TABLE IF NOT EXISTS caddy ( CREATE TABLE IF NOT EXISTS caddy (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL, hostname TEXT NOT NULL,
upstream TEXT NOT NULL, upstream TEXT NOT NULL,
tls INTEGER NOT NULL DEFAULT 1, tls INTEGER NOT NULL DEFAULT 1,
compress INTEGER NOT NULL DEFAULT 1, compress INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')) 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 | | 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 | | 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`) | | 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) ### 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. 🔒 = 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 ## 5. API Design
@ -345,9 +372,9 @@ All `/api/*` routes return JSON. Every route except the public auth/config endpo
| |
+-- /labs +-- /labs
| +-- GET / # List labs (parses deviceIds/topology JSON) [auth] | +-- GET / # List labs (parses deviceIds/topology JSON) [auth]
| +-- POST / # Create lab [auth] | +-- POST / # Create lab; sets ownerId=req.user [auth]
| +-- PUT /{id} # Update lab [auth] | +-- PUT /{id} # Update lab; 403 if not owner/admin/legacy [auth]
| +-- DELETE /{id} # Delete + cancel upcoming bookings [auth] | +-- DELETE /{id} # Delete + cancel upcoming bookings; same 403 guard [auth]
| |
+-- /bookings +-- /bookings
| +-- GET / # List bookings (int flags > booleans) [auth] | +-- GET / # List bookings (int flags > booleans) [auth]
@ -389,8 +416,10 @@ Auth model
+-- Storage: browser localStorage (ghostgrid_token, ghostgrid_user) +-- Storage: browser localStorage (ghostgrid_token, ghostgrid_user)
+-- Middleware +-- Middleware
| +-- requireAuth — verifies JWT, sets req.user; applied to all data routes | +-- requireAuth — verifies JWT, sets req.user; applied to all data routes
| +-- requireAdmin — checks users.role === 'admin' ⚠ DEFINED BUT NOT WIRED | +-- requireAdmin — checks users.role === 'admin' ⚠ DEFINED BUT NOT WIRED to routes
+-- Roles: role column defaults to 'User'; no route currently enforces admin +-- 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. **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(): buildCaddyfile():
{ local_certs } # global block { 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: every reverse_proxy block carries standard forwarding headers:
header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host} 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')) INSERT user (name='admin', role='admin', email='admin@ghostgrid.local', password=bcrypt('admin'))
→ log "[Init] Default admin user created" → log "[Init] Default admin user created"
Caddy route import (re-deploy safety net): Caddy route import (re-deploy safety net, Caddy manager only):
if caddy_enabled === 'true' AND caddy table is empty: if CADDY_MANAGER === 'true' AND caddy_enabled === 'true' AND caddy table is empty:
importCaddyfileRoutes() → seed routes from /etc/caddy/Caddyfile importCaddyfileRoutes() → seed routes from /etc/caddy/Caddyfile
(also runs in PUT /api/settings on the disabled → enabled transition) (also runs in PUT /api/settings on the disabled → enabled transition)
@ -585,6 +616,9 @@ Device Inventory
Lab Templates + Topology Lab Templates + Topology
+-- Lab CRUD; Semaphore setup/teardown template selection +-- 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) +-- Topology link editor (fromDevice > toDevice, link type)
+-- TopologyPanel: SVG layout by node count (1 / 2 / 3 / circular) +-- 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 | | `DeviceType` | `'Switch' \| 'Access-Point' \| 'Firewall' \| 'Controller' \| (string & {})` — presets + free-form |
| `Device` | `status: 'online' \| 'offline' \| 'unknown'`; `emergencySheet` markdown; optional `cmkHostname`, `lastCheckedAt` | | `Device` | `status: 'online' \| 'offline' \| 'unknown'`; `emergencySheet` markdown; optional `cmkHostname`, `lastCheckedAt` |
| `TopologyLink` | `{ fromDevice, toDevice, type }` (e.g. `LACP-Trunk`, `Uplink`, `OOB-Management`) | | `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 | | `Booking` | `status: 'active' \| 'upcoming' \| 'completed' \| 'cancelled'`; ansible trigger flags + job IDs |
| `LogEntry` | `type: 'maintenance' \| 'booking' \| 'status' \| 'system'` | | `LogEntry` | `type: 'maintenance' \| 'booking' \| 'status' \| 'system'` |
| `User` | `{ id, name, role, email }` (never password on the client) | | `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 | | +-- role column ('User'/'admin') exists |
| +-- ⚠ requireAdmin defined but NOT applied — any | | +-- ⚠ requireAdmin defined but NOT applied — any |
| authenticated user can read/write settings + users | | authenticated user can read/write settings + users |
| +-- Lab ownership enforced on PUT/DELETE /api/labs/:id |
| (owner || admin || legacy ownerId=''); 403 otherwise |
+-------------------------------------------------------------+ +-------------------------------------------------------------+
| Secret Handling | | Secret Handling |
| +-- Integration secrets stored in settings table | | +-- Integration secrets stored in settings table |
@ -713,7 +749,7 @@ Backup : ghostgrid.db + ghostgrid.db-wal + ghostgrid.db-shm
``` ```
GhostGrid/ GhostGrid/
+-- server.ts # Express app: all routes, auth, integrations, background jobs +-- server.ts # Express app: all routes, auth, integrations, background jobs
+-- server-db.ts # SQLite connection, full schema, settings/Caddy helpers +-- server-db.ts # SQLite connection, full schema, settings/Caddy/log helpers (uid, addLog)
+-- index.html # Vite HTML entry (#root > src/main.tsx); links /favicon.svg +-- index.html # Vite HTML entry (#root > src/main.tsx); links /favicon.svg
+-- public/ +-- public/
| +-- favicon.svg # app favicon (GhostGrid logo; served at site root by Vite) | +-- favicon.svg # app favicon (GhostGrid logo; served at site root by Vite)
@ -771,7 +807,7 @@ GhostGrid/
| CheckMK/Semaphore outage | Integration loops catch errors, log them, and retry next cycle; non-fatal | | 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 | | 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 | | 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. - `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. - 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`. - 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`. - 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**. - All user-facing strings are in **English**.
--- ---

View File

@ -16,7 +16,7 @@ git fetch --prune origin
git checkout "$BRANCH" git checkout "$BRANCH"
git pull --ff-only origin "$BRANCH" git pull --ff-only origin "$BRANCH"
npm ci npm ci
npm run build VITE_DEPLOY_ENV="$BRANCH" npm run build
sudo systemctl restart "$SVC" sudo systemctl restart "$SVC"
echo "Deployed $BRANCH ($SVC). Status:" echo "Deployed $BRANCH ($SVC). Status:"
systemctl --no-pager status "$SVC" | head -n 5 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 for d in "${APP_DIR}" "${DEV_DIR}"; do
SECRET="$(openssl rand -hex 32)" SECRET="$(openssl rand -hex 32)"
run "printf 'JWT_SECRET=\"%s\"\n' '${SECRET}' > ${d}/.env && chown ghostgrid:ghostgrid ${d}/.env && chmod 600 ${d}/.env" 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). # Only the production instance owns Caddy and shows "Production" in the UI.
[[ "$d" == "${APP_DIR}" ]] && run "printf 'CADDY_MANAGER=true\n' >> ${d}/.env" [[ "$d" == "${APP_DIR}" ]] && run "printf 'DEPLOY_ENV=production\n' >> ${d}/.env"
done done
msg_ok ".env files created (main + dev)" 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'); export const DB_FILE = path.join(process.cwd(), 'ghostgrid.db');
/** App-generated primary key: `${prefix}-${epochMs}-${rand}` (e.g. `log-…`, `dev-…`). */
export const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
console.log(`[Database] Connecting to SQLite database at: ${DB_FILE}`); console.log(`[Database] Connecting to SQLite database at: ${DB_FILE}`);
const db = new Database(DB_FILE); const db = new Database(DB_FILE);
@ -86,15 +89,20 @@ db.exec(`
); );
CREATE TABLE IF NOT EXISTS caddy ( CREATE TABLE IF NOT EXISTS caddy (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL, hostname TEXT NOT NULL,
upstream TEXT NOT NULL, upstream TEXT NOT NULL,
tls INTEGER NOT NULL DEFAULT 1, tls INTEGER NOT NULL DEFAULT 1,
compress INTEGER NOT NULL DEFAULT 1, compress INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')) 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. // Seed default settings — INSERT OR IGNORE writes a key only if it is absent.
const DEFAULT_SETTINGS: Record<string, string> = { const DEFAULT_SETTINGS: Record<string, string> = {
azure_enabled: 'false', azure_enabled: 'false',
@ -113,7 +121,7 @@ const DEFAULT_SETTINGS: Record<string, string> = {
semaphore_api_token: '', semaphore_api_token: '',
semaphore_project_id: '', semaphore_project_id: '',
caddy_enabled: 'false', caddy_enabled: 'false',
caddy_admin_url: 'http://localhost:2019', caddy_admin_url: 'http://127.0.0.1:2019',
}; };
const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
@ -133,38 +141,68 @@ export function getAllSettings(): Record<string, string> {
return Object.fromEntries(rows.map(r => [r.key, r.value])); return Object.fromEntries(rows.map(r => [r.key, r.value]));
} }
const insertLog = db.prepare(
'INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)',
);
/**
* Append a logbook entry. `deviceId`/`userId` default to NULL; `timestamp`
* defaults to now (pass one to share a single timestamp across a batch).
* Returns the generated log id.
*/
export function addLog(
type: string,
message: string,
opts: { deviceId?: string | null; userId?: string | null; timestamp?: string } = {},
): string {
const id = uid('log');
insertLog.run(id, opts.timestamp ?? new Date().toISOString(), type, message, opts.deviceId ?? null, opts.userId ?? null);
return id;
}
/** A reverse-proxy route as stored (booleans are SQLite 0/1 integers). */
export interface CaddyRoute { export interface CaddyRoute {
id: number; id: number;
hostname: string; hostname: string;
upstream: string; upstream: string;
tls: number; tls: number;
compress: number; compress: number;
redirect: string;
created_at: string; created_at: string;
} }
/** Fields a caller supplies to create or update a route (JS booleans). */
export interface CaddyRouteInput {
hostname: string;
upstream: string;
tls: boolean;
compress: boolean;
redirect?: string;
}
export function getCaddyRoutes(): CaddyRoute[] { export function getCaddyRoutes(): CaddyRoute[] {
return db.prepare('SELECT * FROM caddy ORDER BY id ASC').all() as CaddyRoute[]; return db.prepare('SELECT * FROM caddy ORDER BY id ASC').all() as CaddyRoute[];
} }
export function addCaddyRoute(hostname: string, upstream: string, tls: boolean, compress: boolean): CaddyRoute { export function getCaddyRouteById(id: number): CaddyRoute | undefined {
const { lastInsertRowid } = db.prepare( return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute | undefined;
'INSERT INTO caddy (hostname, upstream, tls, compress) VALUES (?, ?, ?, ?)'
).run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0);
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(lastInsertRowid) as CaddyRoute;
} }
export function updateCaddyRoute(id: number, hostname: string, upstream: string, tls: boolean, compress: boolean): CaddyRoute { export function addCaddyRoute(route: CaddyRouteInput): CaddyRoute {
db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ? WHERE id = ?') const { lastInsertRowid } = db.prepare(
.run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0, id); 'INSERT INTO caddy (hostname, upstream, tls, compress, redirect) VALUES (?, ?, ?, ?, ?)',
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute; ).run(route.hostname, route.upstream, route.tls ? 1 : 0, route.compress ? 1 : 0, route.redirect ?? '');
return getCaddyRouteById(Number(lastInsertRowid))!;
}
export function updateCaddyRoute(id: number, route: CaddyRouteInput): CaddyRoute {
db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ?, redirect = ? WHERE id = ?')
.run(route.hostname, route.upstream, route.tls ? 1 : 0, route.compress ? 1 : 0, route.redirect ?? '', id);
return getCaddyRouteById(id)!;
} }
export function deleteCaddyRoute(id: number): void { export function deleteCaddyRoute(id: number): void {
db.prepare('DELETE FROM caddy WHERE id = ?').run(id); db.prepare('DELETE FROM caddy WHERE id = ?').run(id);
} }
export function getCaddyRouteById(id: number): CaddyRoute | undefined { export default db;
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute | undefined;
}
export default db;

40
server-migrations.ts Normal file
View File

@ -0,0 +1,40 @@
import Database from 'better-sqlite3';
interface Migration {
id: string; // unique, immutable — format: NNNN_short_description
up: (db: InstanceType<typeof Database>) => void;
}
// Append only. Never reorder or remove entries — that would corrupt tracking.
// Each `up` function receives the open DB handle inside an already-open transaction.
const migrations: Migration[] = [
// Example:
// {
// id: '0001_bookings_add_color',
// up: (db) => {
// db.exec(`ALTER TABLE bookings ADD COLUMN color TEXT NOT NULL DEFAULT 'blue'`);
// },
// },
];
export function runMigrations(db: InstanceType<typeof Database>): void {
db.exec(`
CREATE TABLE IF NOT EXISTS _migrations (
id TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
const isApplied = db.prepare('SELECT 1 FROM _migrations WHERE id = ?');
const markApplied = db.prepare('INSERT INTO _migrations (id) VALUES (?)');
for (const migration of migrations) {
if (isApplied.get(migration.id)) continue;
console.log(`[Migrations] Applying: ${migration.id}`);
db.transaction(() => {
migration.up(db);
markApplied.run(migration.id);
})();
console.log(`[Migrations] Applied: ${migration.id}`);
}
}

247
server.ts
View File

@ -7,18 +7,17 @@ import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import DatabaseConstructor from 'better-sqlite3'; import DatabaseConstructor from 'better-sqlite3';
import { ConfidentialClientApplication } from '@azure/msal-node'; import { ConfidentialClientApplication } from '@azure/msal-node';
import db, { getSetting, setSetting, getAllSettings, getCaddyRoutes, addCaddyRoute, updateCaddyRoute, deleteCaddyRoute, getCaddyRouteById, DB_FILE } from './server-db'; import db, { uid, addLog, getSetting, setSetting, getAllSettings, getCaddyRoutes, addCaddyRoute, updateCaddyRoute, deleteCaddyRoute, getCaddyRouteById, DB_FILE } from './server-db';
import { runMigrations } from './server-migrations';
import { Device, LabTemplate, Booking, LogEntry, User, QuickLink } from './src/types'; import { Device, LabTemplate, Booking, LogEntry, User, QuickLink } from './src/types';
const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const JWT_SECRET = process.env.JWT_SECRET || 'ghostgrid-dev-secret-change-in-production'; const JWT_SECRET = process.env.JWT_SECRET || 'ghostgrid-dev-secret-change-in-production';
const JWT_EXPIRY = '24h'; const JWT_EXPIRY = '24h';
// One Caddy serves the whole container; only the instance with CADDY_MANAGER=true // DEPLOY_ENV=production marks the primary instance: it owns Caddy (pushes config,
// owns it (pushes config, seeds routes, accepts route edits). The other instance // seeds routes, accepts route edits) and shows "Production" in the UI. The dev
// must never push — POST /load replaces the entire config and would clobber it. // instance must never push to Caddy — POST /load replaces the entire config.
const IS_CADDY_MANAGER = process.env.CADDY_MANAGER === 'true'; const IS_PRODUCTION = process.env.DEPLOY_ENV === 'production';
interface JwtPayload { interface JwtPayload {
userId: string; userId: string;
@ -87,6 +86,12 @@ function buildCaddyfile(): string {
lines.push(`${route.hostname} {`); lines.push(`${route.hostname} {`);
if (route.compress) lines.push(' encode zstd gzip'); if (route.compress) lines.push(' encode zstd gzip');
if (route.tls) lines.push(' tls internal'); if (route.tls) lines.push(' tls internal');
if (route.redirect) {
// 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} {`); lines.push(` reverse_proxy ${route.upstream} {`);
// Standard forwarding headers for every backend. Caddy already sets the // Standard forwarding headers for every backend. Caddy already sets the
// X-Forwarded-* family and the Host header by default; these make them // 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 upstream = upstreamMatch[1];
const tls = /tls\s+internal/.test(block); const tls = /tls\s+internal/.test(block);
const compress = /encode/.test(block); const compress = /encode/.test(block);
addCaddyRoute(hostname, upstream, tls, compress); addCaddyRoute({ hostname, upstream, tls, compress });
imported.push(`${hostname}${upstream}`); imported.push(`${hostname}${upstream}`);
} }
} }
i++; i++;
} }
if (imported.length > 0) { if (imported.length > 0) {
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)') addLog('system', `Caddy: imported ${imported.length} route(s) from Caddyfile — ${imported.join(', ')}`, { userId });
.run(uid('log'), new Date().toISOString(), 'system',
`Caddy: imported ${imported.length} route(s) from Caddyfile — ${imported.join(', ')}`,
null, userId ?? null);
} }
} }
async function pushCaddyConfig(): Promise<void> { async function pushCaddyConfig(): Promise<void> {
if (!IS_CADDY_MANAGER) return; if (!IS_PRODUCTION) return;
if (getSetting('caddy_enabled') !== 'true') return; if (getSetting('caddy_enabled') !== 'true') return;
const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019'; const adminUrl = getSetting('caddy_admin_url') || 'http://127.0.0.1:2019';
const body = buildCaddyfile(); const body = buildCaddyfile();
const res = await fetch(`${adminUrl}/load`, { const res = await fetch(`${adminUrl}/load`, {
method: 'POST', method: 'POST',
@ -163,6 +165,8 @@ async function pushCaddyConfig(): Promise<void> {
} }
async function startServer() { async function startServer() {
runMigrations(db);
const app = express(); const app = express();
const PORT = Number(process.env.PORT) || 3000; 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'); 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(); importCaddyfileRoutes();
} }
@ -235,9 +239,7 @@ async function startServer() {
const token = jwt.sign({ userId: row.id, email: row.email }, JWT_SECRET, { expiresIn: JWT_EXPIRY }); const token = jwt.sign({ userId: row.id, email: row.email }, JWT_SECRET, { expiresIn: JWT_EXPIRY });
const user: User = { id: row.id, name: row.name, role: row.role, email: row.email }; const user: User = { id: row.id, name: row.name, role: row.role, email: row.email };
const logId = uid("log"); addLog('system', `${row.name} logged in.`, { userId: row.id });
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
.run(logId, new Date().toISOString(), 'system', `${row.name} logged in.`, null, row.id);
res.json({ token, user }); res.json({ token, user });
} catch (err: any) { } catch (err: any) {
@ -263,15 +265,16 @@ async function startServer() {
const clientId = getSetting('azure_client_id'); const clientId = getSetting('azure_client_id');
const tenantId = getSetting('azure_tenant_id'); const tenantId = getSetting('azure_tenant_id');
const secret = getSetting('azure_client_secret'); const secret = getSetting('azure_client_secret');
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`; const appUrl = process.env.APP_URL || `http://127.0.0.1:${PORT}`;
const effectiveRedirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`; const effectiveRedirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
const cmkApiUrl = getSetting('checkmk_api_url') || process.env.CHECKMK_API_URL || ''; const cmkApiUrl = getSetting('checkmk_api_url') || process.env.CHECKMK_API_URL || '';
res.json({ res.json({
azureEnabled: enabled && Boolean(clientId) && Boolean(tenantId) && Boolean(secret), azureEnabled: enabled && Boolean(clientId) && Boolean(tenantId) && Boolean(secret),
effectiveRedirectUri, effectiveRedirectUri,
checkmkEnabled: getSetting('checkmk_enabled') === 'true', checkmkEnabled: getSetting('checkmk_enabled') === 'true',
semaphoreEnabled: getSetting('semaphore_enabled') === 'true',
checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''), checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''),
caddyManaged: IS_CADDY_MANAGER, isProduction: IS_PRODUCTION,
}); });
}); });
@ -281,7 +284,7 @@ async function startServer() {
if (!msalClient) { if (!msalClient) {
return res.redirect('/?auth_error=Azure+login+not+configured'); return res.redirect('/?auth_error=Azure+login+not+configured');
} }
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`; const appUrl = process.env.APP_URL || `http://127.0.0.1:${PORT}`;
const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`; const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
try { try {
const authCodeUrl = await msalClient.getAuthCodeUrl({ const authCodeUrl = await msalClient.getAuthCodeUrl({
@ -309,7 +312,7 @@ async function startServer() {
if (!msalClient) { if (!msalClient) {
return res.redirect('/?auth_error=Azure+login+not+configured'); return res.redirect('/?auth_error=Azure+login+not+configured');
} }
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`; const appUrl = process.env.APP_URL || `http://127.0.0.1:${PORT}`;
const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`; const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
try { try {
const result = await msalClient.acquireTokenByCode({ const result = await msalClient.acquireTokenByCode({
@ -337,6 +340,7 @@ async function startServer() {
user = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User; 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 }); 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)}`); res.redirect(`/?token=${encodeURIComponent(token)}`);
} catch (err: any) { } catch (err: any) {
console.error('[Azure Auth] acquireTokenByCode error:', err); 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 = ?') db.prepare('UPDATE users SET name = COALESCE(?, name), email = COALESCE(?, email) WHERE id = ?')
.run(name ?? null, email ?? null, 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 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); res.json(updated);
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
@ -451,11 +478,9 @@ async function startServer() {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, hostname, ip, location || '', notes || '', type, status || 'unknown', emergencySheet || '', lastCheckedAt || null); `).run(id, hostname, ip, location || '', notes || '', type, status || 'unknown', emergencySheet || '', lastCheckedAt || null);
const logId = uid("log"); addLog('maintenance',
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`) `Provisioned a new host candidate "${hostname}" (${ip}) inside location ${location || 'Inventory'}.`,
.run(logId, new Date().toISOString(), 'maintenance', { deviceId: id, userId: req.user!.userId });
`Provisioned a new host candidate "${hostname}" (${ip}) inside location ${location || 'Inventory'}.`,
id, req.user!.userId);
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device; const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
res.status(201).json(device); res.status(201).json(device);
@ -474,11 +499,10 @@ async function startServer() {
WHERE id = ? WHERE id = ?
`).run(hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt ?? null, id); `).run(hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt ?? null, id);
const logId = uid("log");
const operatorText = operatorName ? `${operatorName} finished ` : 'Updated '; const operatorText = operatorName ? `${operatorName} finished ` : 'Updated ';
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`) addLog('maintenance',
.run(logId, new Date().toISOString(), 'maintenance', `${operatorText}refining the device specifications for "${hostname}".`,
`${operatorText}refining the device specifications for "${hostname}".`, id, req.user!.userId); { deviceId: id, userId: req.user!.userId });
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device; const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
res.json(device); res.json(device);
@ -507,11 +531,9 @@ async function startServer() {
); );
} }
const logId = uid("log"); addLog('maintenance',
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`) `Permanently removed the host device "${dev.hostname || id}" from the inventory records.`,
.run(logId, new Date().toISOString(), 'maintenance', { userId: req.user!.userId });
`Permanently removed the host device "${dev.hostname || id}" from the inventory records.`,
null, req.user!.userId);
res.json({ success: true, message: 'Device deleted successfully and cleaned from topologies.' }); res.json({ success: true, message: 'Device deleted successfully and cleaned from topologies.' });
} catch (err: any) { } catch (err: any) {
@ -531,6 +553,8 @@ async function startServer() {
deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology),
semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '',
semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '',
scope: (r.scope === 'personal' ? 'personal' : 'global') as 'global' | 'personal',
ownerId: r.ownerId ?? '',
})); }));
res.json(labs); res.json(labs);
} catch (err: any) { } catch (err: any) {
@ -540,43 +564,54 @@ async function startServer() {
app.post('/api/labs', requireAuth, (req, res) => { app.post('/api/labs', requireAuth, (req, res) => {
try { 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)) { if (!name || !deviceIds || !Array.isArray(deviceIds)) {
return res.status(400).json({ error: 'Missing name or associated device configurations.' }); 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"); const id = uid("lab");
db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId, scope, ownerId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || ''); .run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', safeScope, ownerId);
const logId = uid("log"); addLog('maintenance',
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`) `Released a new lab template profile "${name}" (${location || 'Staging Area'}) for engineering teams.`,
.run(logId, new Date().toISOString(), 'maintenance', { userId: req.user!.userId });
`Released a new lab template profile "${name}" (${location || 'Staging Area'}) for engineering teams.`,
req.user!.userId);
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any; const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
res.status(201).json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '' }); res.status(201).json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', scope: r.scope, ownerId: r.ownerId });
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message }); 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 { try {
const id = req.params.id; 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 = ?`) const existing = db.prepare('SELECT ownerId, scope FROM labs WHERE id = ?').get(id) as any;
.run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', id); if (!existing) return res.status(404).json({ error: 'Lab template not found.' });
const logId = uid("log"); const reqUser = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as any;
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`) const isAdmin = reqUser?.role?.toLowerCase() === 'admin';
.run(logId, new Date().toISOString(), 'maintenance', const isOwner = existing.ownerId === req.user!.userId;
`Modified the active topology mapping schema for the "${name}" lab template.`, 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; 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) { } catch (err: any) {
res.status(500).json({ error: err.message }); 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; 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.' }); 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('DELETE FROM labs WHERE id = ?').run(id);
db.prepare(`UPDATE bookings SET status = 'cancelled' WHERE labId = ? AND status = 'upcoming'`).run(id); db.prepare(`UPDATE bookings SET status = 'cancelled' WHERE labId = ? AND status = 'upcoming'`).run(id);
const logId = uid("log"); addLog('booking',
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`) `Withdrew the lab testing template "${lab.name || id}".`,
.run(logId, new Date().toISOString(), 'booking', { userId: req.user!.userId });
`Withdrew the lab testing template "${lab.name || id}".`, req.user!.userId);
res.json({ success: true, message: 'Lab template deleted and future reservations cancelled.' }); res.json({ success: true, message: 'Lab template deleted and future reservations cancelled.' });
} catch (err: any) { } catch (err: any) {
@ -636,14 +678,13 @@ async function startServer() {
.run(id, labId, userId, startDateTime, endDateTime, notes || '', status || 'upcoming'); .run(id, labId, userId, startDateTime, endDateTime, notes || '', status || 'upcoming');
const lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(labId) as { name: string } | undefined; const lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(labId) as { name: string } | undefined;
const logId = uid("log");
const operatorText = operatorName || 'An operator'; const operatorText = operatorName || 'An operator';
const startF = new Date(startDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const startF = new Date(startDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
const endF = new Date(endDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const endF = new Date(endDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`) addLog('booking',
.run(logId, new Date().toISOString(), 'booking', `${operatorText} booked the lab template "${lab?.name || 'Unknown'}" from ${startF} to ${endF}.`,
`${operatorText} booked the lab template "${lab?.name || 'Unknown'}" from ${startF} to ${endF}.`, userId); { userId });
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any; const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
res.status(201).json({ res.status(201).json({
@ -667,11 +708,9 @@ async function startServer() {
if (status === 'cancelled') { if (status === 'cancelled') {
const lab = db.prepare('SELECT name, semaphoreTeardownTemplateId FROM labs WHERE id = ?').get(booking.labId) as { name: string; semaphoreTeardownTemplateId: string } | undefined; const lab = db.prepare('SELECT name, semaphoreTeardownTemplateId FROM labs WHERE id = ?').get(booking.labId) as { name: string; semaphoreTeardownTemplateId: string } | undefined;
const logId = uid("log"); addLog('booking',
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`) `${operatorName || 'An operator'} canceled the reservation for lab template "${lab?.name || 'Unknown'}" and released associated nodes back into the pool.`,
.run(logId, new Date().toISOString(), 'booking', { userId: req.user!.userId });
`${operatorName || 'An operator'} canceled the reservation for lab template "${lab?.name || 'Unknown'}" and released associated nodes back into the pool.`,
req.user!.userId);
// Trigger teardown if booking had already started and teardown not yet triggered // Trigger teardown if booking had already started and teardown not yet triggered
const now = new Date(); const now = new Date();
@ -712,10 +751,9 @@ async function startServer() {
db.prepare('DELETE FROM bookings WHERE id = ?').run(id); db.prepare('DELETE FROM bookings WHERE id = ?').run(id);
const lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(booking.labId) as { name: string } | undefined; const lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(booking.labId) as { name: string } | undefined;
const logId = uid("log"); addLog('booking',
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`) `Permanently deleted the reservation records for lab "${lab?.name || 'Unknown'}".`,
.run(logId, new Date().toISOString(), 'booking', { userId: req.user!.userId });
`Permanently deleted the reservation records for lab "${lab?.name || 'Unknown'}".`, req.user!.userId);
res.json({ success: true, message: 'Reservation and its logs have been resolved correctly.' }); res.json({ success: true, message: 'Reservation and its logs have been resolved correctly.' });
} catch (err: any) { } catch (err: any) {
@ -742,9 +780,7 @@ async function startServer() {
return res.status(400).json({ error: 'Missing log message or classification type.' }); return res.status(400).json({ error: 'Missing log message or classification type.' });
} }
const id = uid("log"); const id = addLog(type, message, { deviceId: deviceId || null, userId: userId || null });
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
.run(id, new Date().toISOString(), type, message, deviceId || null, userId || null);
const log = db.prepare('SELECT * FROM logs WHERE id = ?').get(id) as LogEntry; const log = db.prepare('SELECT * FROM logs WHERE id = ?').get(id) as LogEntry;
res.status(201).json(log); res.status(201).json(log);
@ -923,8 +959,7 @@ async function startServer() {
const CHECKMK_API_SECRET = getSetting('checkmk_api_secret') || process.env.CHECKMK_API_SECRET; const CHECKMK_API_SECRET = getSetting('checkmk_api_secret') || process.env.CHECKMK_API_SECRET;
if (!CHECKMK_API_URL || !CHECKMK_API_SECRET) { if (!CHECKMK_API_URL || !CHECKMK_API_SECRET) {
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)') addLog('system', 'CheckMK sync skipped — API URL or secret not configured. Check Settings.', { timestamp: now });
.run(uid('log'), now, 'system', 'CheckMK sync skipped — API URL or secret not configured. Check Settings.');
return; return;
} }
@ -952,8 +987,7 @@ async function startServer() {
} catch (err: any) { } catch (err: any) {
const msg = `CheckMK sync failed — could not fetch host list: ${err?.message ?? err}`; const msg = `CheckMK sync failed — could not fetch host list: ${err?.message ?? err}`;
console.error('[CheckMK]', msg); console.error('[CheckMK]', msg);
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)') addLog('system', msg, { timestamp: now });
.run(uid('log'), now, 'system', msg);
return; return;
} }
@ -969,8 +1003,7 @@ async function startServer() {
if (!cmkHost) { if (!cmkHost) {
if (dev.status !== 'unknown') { if (dev.status !== 'unknown') {
db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ?, cmkHostname = ? WHERE id = ?').run('unknown', now, '', dev.id); db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ?, cmkHostname = ? WHERE id = ?').run('unknown', now, '', dev.id);
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)') addLog('status', `CheckMK: ${dev.hostname} (${dev.ip}) not found in monitoring — status set to unknown.`, { deviceId: dev.id, timestamp: now });
.run(uid('log'), now, 'status', `CheckMK: ${dev.hostname} (${dev.ip}) not found in monitoring — status set to unknown.`, dev.id);
} }
counts.unknown++; counts.unknown++;
continue; continue;
@ -987,22 +1020,20 @@ async function startServer() {
const newStatus = state === 0 ? 'online' : state === 1 || state === 2 ? 'offline' : 'unknown'; const newStatus = state === 0 ? 'online' : state === 1 || state === 2 ? 'offline' : 'unknown';
db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ?, cmkHostname = ? WHERE id = ?').run(newStatus, now, cmkHost, dev.id); db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ?, cmkHostname = ? WHERE id = ?').run(newStatus, now, cmkHost, dev.id);
if (dev.status !== newStatus) { if (dev.status !== newStatus) {
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)') addLog('status', `CheckMK: ${dev.hostname} (${dev.ip}) status changed to ${newStatus} (was: ${dev.status}).`, { deviceId: dev.id, timestamp: now });
.run(uid('log'), now, 'status', `CheckMK: ${dev.hostname} (${dev.ip}) status changed to ${newStatus} (was: ${dev.status}).`, dev.id);
} }
counts[newStatus as 'online' | 'offline' | 'unknown']++; counts[newStatus as 'online' | 'offline' | 'unknown']++;
} catch (err: any) { } catch (err: any) {
const msg = `CheckMK: status sync failed for ${dev.hostname} (${dev.ip}) — ${err?.message ?? err}`; const msg = `CheckMK: status sync failed for ${dev.hostname} (${dev.ip}) — ${err?.message ?? err}`;
console.error('[CheckMK]', msg); console.error('[CheckMK]', msg);
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)') addLog('system', msg, { deviceId: dev.id, timestamp: now });
.run(uid('log'), now, 'system', msg, dev.id);
counts.unknown++; counts.unknown++;
} }
} }
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)') addLog('system',
.run(uid('log'), now, 'system', `CheckMK sync completed — ${counts.online} online, ${counts.offline} offline, ${counts.unknown} unknown (${rows.length} devices total).`,
`CheckMK sync completed — ${counts.online} online, ${counts.offline} offline, ${counts.unknown} unknown (${rows.length} devices total).`); { timestamp: now });
} }
async function scheduleSync() { async function scheduleSync() {
@ -1028,14 +1059,12 @@ async function startServer() {
// as CheckMK. Template IDs are configured per lab template. // as CheckMK. Template IDs are configured per lab template.
// ------------------------------------------------------------- // -------------------------------------------------------------
async function triggerSemaphoreTask(templateId: number, extraVars: Record<string, string>): Promise<number | null> { async function triggerSemaphoreTask(templateId: number, extraVars: Record<string, string>): Promise<number | null> {
const now = new Date().toISOString();
const apiUrl = getSetting('semaphore_api_url'); const apiUrl = getSetting('semaphore_api_url');
const token = getSetting('semaphore_api_token'); const token = getSetting('semaphore_api_token');
const projectId = getSetting('semaphore_project_id'); const projectId = getSetting('semaphore_project_id');
if (!apiUrl || !token || !projectId) { if (!apiUrl || !token || !projectId) {
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)') addLog('system', 'Semaphore trigger skipped — API URL, token, or project ID not configured. Check Settings.');
.run(uid('log'), now, 'system', 'Semaphore trigger skipped — API URL, token, or project ID not configured. Check Settings.');
return null; return null;
} }
@ -1058,14 +1087,12 @@ async function startServer() {
} }
const data = await res.json() as { id?: number }; const data = await res.json() as { id?: number };
const jobId = data?.id ?? null; const jobId = data?.id ?? null;
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)') addLog('booking', `Semaphore: triggered template #${templateId} > job #${jobId} (booking ${extraVars.booking_id}).`);
.run(uid('log'), now, 'booking', `Semaphore: triggered template #${templateId} > job #${jobId} (booking ${extraVars.booking_id}).`);
return jobId; return jobId;
} catch (err: any) { } catch (err: any) {
const msg = `Semaphore trigger failed for template #${templateId}${err?.message ?? err}`; const msg = `Semaphore trigger failed for template #${templateId}${err?.message ?? err}`;
console.error('[Semaphore]', msg); console.error('[Semaphore]', msg);
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)') addLog('system', msg);
.run(uid('log'), now, 'system', msg);
return null; return null;
} }
} }
@ -1181,7 +1208,7 @@ async function startServer() {
// ------------------------------------------------------------- // -------------------------------------------------------------
app.get('/api/caddy/status', requireAuth, async (_req, res) => { app.get('/api/caddy/status', requireAuth, async (_req, res) => {
try { try {
const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019'; const adminUrl = getSetting('caddy_admin_url') || 'http://127.0.0.1:2019';
const r = await fetch(`${adminUrl}/config/`, { signal: AbortSignal.timeout(2000) }); const r = await fetch(`${adminUrl}/config/`, { signal: AbortSignal.timeout(2000) });
res.json({ available: r.ok }); res.json({ available: r.ok });
} catch { } catch {
@ -1199,17 +1226,15 @@ async function startServer() {
app.post('/api/caddy/routes', requireAuth, async (req, res) => { app.post('/api/caddy/routes', requireAuth, async (req, res) => {
try { try {
if (!IS_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 { hostname, upstream, tls, compress } = req.body as { const { hostname, upstream, tls, compress, redirect } = req.body as {
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirect?: string;
}; };
if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' }); if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' });
if (getCaddyRoutes().some(r => r.hostname === hostname.trim())) if (getCaddyRoutes().some(r => r.hostname === hostname.trim()))
return res.status(409).json({ error: `Route for ${hostname.trim()} already exists.` }); return res.status(409).json({ error: `Route for ${hostname.trim()} already exists.` });
const route = addCaddyRoute(hostname.trim(), upstream.trim(), tls !== false, compress !== false); const route = addCaddyRoute({ hostname: hostname.trim(), upstream: upstream.trim(), tls: tls !== false, compress: compress !== false, redirect: (redirect ?? '').trim() });
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)') addLog('system', `Caddy route added: ${hostname.trim()}${upstream.trim()}`, { userId: req.user!.userId });
.run(uid('log'), new Date().toISOString(), 'system',
`Caddy route added: ${hostname.trim()}${upstream.trim()}`, null, req.user!.userId);
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route add:', err.message)); pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route add:', err.message));
res.json(route); res.json(route);
} catch (err: any) { } catch (err: any) {
@ -1219,17 +1244,15 @@ async function startServer() {
app.put('/api/caddy/routes/:id', requireAuth, async (req, res) => { app.put('/api/caddy/routes/:id', requireAuth, async (req, res) => {
try { 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); const id = Number(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid route id.' }); if (!id) return res.status(400).json({ error: 'Invalid route id.' });
const { hostname, upstream, tls, compress } = req.body as { const { hostname, upstream, tls, compress, redirect } = req.body as {
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirect?: string;
}; };
if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' }); if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' });
const route = updateCaddyRoute(id, hostname.trim(), upstream.trim(), tls !== false, compress !== false); const route = updateCaddyRoute(id, { hostname: hostname.trim(), upstream: upstream.trim(), tls: tls !== false, compress: compress !== false, redirect: (redirect ?? '').trim() });
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)') addLog('system', `Caddy route updated: ${hostname.trim()}${upstream.trim()}`, { userId: req.user!.userId });
.run(uid('log'), new Date().toISOString(), 'system',
`Caddy route updated: ${hostname.trim()}${upstream.trim()}`, null, req.user!.userId);
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route update:', err.message)); pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route update:', err.message));
res.json(route); res.json(route);
} catch (err: any) { } catch (err: any) {
@ -1239,15 +1262,13 @@ async function startServer() {
app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => { app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => {
try { 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); const id = Number(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid route id.' }); if (!id) return res.status(400).json({ error: 'Invalid route id.' });
const existing = getCaddyRouteById(id); const existing = getCaddyRouteById(id);
deleteCaddyRoute(id); deleteCaddyRoute(id);
if (existing) { if (existing) {
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)') addLog('system', `Caddy route deleted: ${existing.hostname}${existing.upstream}`, { userId: req.user!.userId });
.run(uid('log'), new Date().toISOString(), 'system',
`Caddy route deleted: ${existing.hostname}${existing.upstream}`, null, req.user!.userId);
} }
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route delete:', err.message)); pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route delete:', err.message));
res.status(204).send(); res.status(204).send();

View File

@ -55,6 +55,8 @@ export default function App() {
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null); const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null);
const [checkmkEnabled, setCheckmkEnabled] = useState(false); const [checkmkEnabled, setCheckmkEnabled] = useState(false);
const [checkmkBaseUrl, setCheckmkBaseUrl] = useState(''); const [checkmkBaseUrl, setCheckmkBaseUrl] = useState('');
const [isProduction, setIsProduction] = useState(false);
const [semaphoreEnabled, setSemaphoreEnabled] = useState(false);
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
@ -143,7 +145,7 @@ export default function App() {
if (bookingsRes.ok) setBookings(await bookingsRes.json()); if (bookingsRes.ok) setBookings(await bookingsRes.json());
if (logsRes.ok) setLogs(await logsRes.json()); if (logsRes.ok) setLogs(await logsRes.json());
if (linksRes.ok) setLinks(await linksRes.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) { } catch (err) {
console.error('[App] Failed to load data:', err); console.error('[App] Failed to load data:', err);
} finally { } finally {
@ -298,7 +300,7 @@ export default function App() {
}; };
// Lab handlers // Lab handlers
const handleAddLab = async (newLab: Omit<LabTemplate, 'id'>) => { const handleAddLab = async (newLab: Omit<LabTemplate, 'id' | 'ownerId'>) => {
try { try {
const res = await authFetch('/api/labs', { method: 'POST', body: JSON.stringify(newLab) }); const res = await authFetch('/api/labs', { method: 'POST', body: JSON.stringify(newLab) });
if (res.ok) { if (res.ok) {
@ -360,6 +362,17 @@ export default function App() {
} catch (err: any) { throw err; } } 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) // Quick-link handlers (shared link dashboard)
const handleAddLink = async (newLink: Omit<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => { const handleAddLink = async (newLink: Omit<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => {
try { try {
@ -484,6 +497,7 @@ export default function App() {
theme={theme} theme={theme}
onThemeToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')} onThemeToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
onLogout={handleLogout} onLogout={handleLogout}
isProduction={isProduction}
/> />
<div className="flex-1 flex flex-col md:flex-row"> <div className="flex-1 flex flex-col md:flex-row">
@ -595,6 +609,8 @@ export default function App() {
<LabTemplates <LabTemplates
labs={labs} labs={labs}
devices={devices} devices={devices}
currentUser={currentUser!}
semaphoreEnabled={semaphoreEnabled}
onAddLab={handleAddLab} onAddLab={handleAddLab}
onUpdateLab={handleUpdateLab} onUpdateLab={handleUpdateLab}
onDeleteLab={handleDeleteLab} onDeleteLab={handleDeleteLab}
@ -617,6 +633,7 @@ export default function App() {
bookings={bookings} bookings={bookings}
onDeleteUser={handleDeleteUser} onDeleteUser={handleDeleteUser}
onUpdateUser={handleUpdateUser} onUpdateUser={handleUpdateUser}
onSetRole={handleSetUserRole}
/> />
)} )}
{activeTab === 'logs' && ( {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 { Booking, LabTemplate, Device, User } from '../types';
import { import {
Calendar, Zap, CheckCircle2, AlertCircle, AlertTriangle, ChevronLeft, ChevronRight, Database, 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 }; return { startMs: start.getTime(), endMs: end.getTime(), start, end };
}, [quickDuration]); }, [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). // 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.length > 0 &&
lab.deviceIds.every(dId => !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs)) lab.deviceIds.every(dId => !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs))
), [labs, devices, bookings, quickWindow]); ), [bookableLabs, devices, bookings, quickWindow]);
const availableDevices = useMemo(() => devices.filter(dev => const availableDevices = useMemo(() => devices.filter(dev =>
!isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs) !isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs)
@ -576,9 +588,20 @@ export default function BookingCalendar({
onChange={(e) => setSelectedLabId(e.target.value)} 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" 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 && (
<option key={l.id} value={l.id}>{l.name} ({l.location})</option> <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> </select>
</div> </div>
) : ( ) : (
@ -656,7 +679,6 @@ export default function BookingCalendar({
<textarea <textarea
required required
rows={3} rows={3}
placeholder="e.g. Validating STP failover convergence times..."
value={bookingNotes} value={bookingNotes}
onChange={(e) => setBookingNotes(e.target.value)} 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" 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> </span>
<input <input
type="text" type="text"
placeholder="Search by hostname, IP address, rack location..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} 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" 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} value={formData.hostname}
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })} 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" 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>
<div> <div>
@ -508,7 +506,6 @@ Pick a box from the list to see its specs and break-glass playbook.
value={formData.ip} value={formData.ip}
onChange={(e) => setFormData({ ...formData, ip: e.target.value })} 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" 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>
</div> </div>
@ -522,7 +519,6 @@ Pick a box from the list to see its specs and break-glass playbook.
value={formData.location} value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })} 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" 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>
<div> <div>
@ -554,7 +550,6 @@ Pick a box from the list to see its specs and break-glass playbook.
value={formData.type} value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as DeviceType })} 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" 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> </div>
@ -567,7 +562,6 @@ Pick a box from the list to see its specs and break-glass playbook.
value={formData.notes} value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })} 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" 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> </div>

View File

@ -11,6 +11,7 @@ interface HeaderProps {
theme: 'dark' | 'light'; theme: 'dark' | 'light';
onThemeToggle: () => void; onThemeToggle: () => void;
onLogout: () => void; onLogout: () => void;
isProduction: boolean;
} }
export default function Header({ export default function Header({
@ -22,6 +23,7 @@ export default function Header({
theme, theme,
onThemeToggle, onThemeToggle,
onLogout, onLogout,
isProduction,
}: HeaderProps) { }: HeaderProps) {
const [showMailInbox, setShowMailInbox] = useState(false); const [showMailInbox, setShowMailInbox] = useState(false);
const [showBellDropdown, setShowBellDropdown] = useState(false); const [showBellDropdown, setShowBellDropdown] = useState(false);
@ -63,8 +65,8 @@ export default function Header({
{/* System Indicator */} {/* 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"> <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 className={`w-2 h-2 rounded-full animate-pulse ${isProduction ? 'bg-emerald-500' : 'bg-amber-400'}`} />
<span>System: {import.meta.env.PROD ? 'Production' : 'Development'}</span> <span>System: {isProduction ? 'Production' : 'Development'}</span>
</div> </div>
{/* Mail Inbox */} {/* Mail Inbox */}

View File

@ -4,25 +4,29 @@
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { LabTemplate, Device, TopologyLink } from '../types'; import { LabTemplate, Device, TopologyLink, User } from '../types';
import TopologyPanel from './TopologyPanel'; import TopologyPanel from './TopologyPanel';
import { import {
Server, Plus, Edit3, Trash, User, MapPin, Server, Plus, Edit3, Trash, User as UserIcon, MapPin,
Layers, ChevronRight, Save, X, Check, Pencil, Terminal, Layers, ChevronRight, X, Check, Pencil, Terminal, Lock, Globe,
} from 'lucide-react'; } from 'lucide-react';
interface LabTemplatesProps { interface LabTemplatesProps {
labs: LabTemplate[]; labs: LabTemplate[];
devices: Device[]; devices: Device[];
onAddLab: (lab: Omit<LabTemplate, 'id'>) => void; currentUser: User;
semaphoreEnabled: boolean;
onAddLab: (lab: Omit<LabTemplate, 'id' | 'ownerId'>) => void;
onUpdateLab: (lab: LabTemplate) => void; onUpdateLab: (lab: LabTemplate) => void;
onDeleteLab: (id: string) => void; onDeleteLab: (id: string) => void;
onOpenDeviceDetails: (device: Device) => void; onOpenDeviceDetails: (device: Device) => void;
} }
export default function LabTemplates({ export default function LabTemplates({
labs, labs,
devices, devices,
currentUser,
semaphoreEnabled,
onAddLab, onAddLab,
onUpdateLab, onUpdateLab,
onDeleteLab, onDeleteLab,
@ -49,6 +53,7 @@ export default function LabTemplates({
deviceIds: string[]; deviceIds: string[];
semaphoreSetupTemplateId: string; semaphoreSetupTemplateId: string;
semaphoreTeardownTemplateId: string; semaphoreTeardownTemplateId: string;
scope: 'global' | 'personal';
}>({ }>({
name: '', name: '',
description: '', description: '',
@ -57,6 +62,7 @@ export default function LabTemplates({
deviceIds: [], deviceIds: [],
semaphoreSetupTemplateId: '', semaphoreSetupTemplateId: '',
semaphoreTeardownTemplateId: '', semaphoreTeardownTemplateId: '',
scope: 'global',
}); });
// Calculate filtered devices associated with selected lab // Calculate filtered devices associated with selected lab
@ -75,6 +81,7 @@ export default function LabTemplates({
deviceIds: [], deviceIds: [],
semaphoreSetupTemplateId: '', semaphoreSetupTemplateId: '',
semaphoreTeardownTemplateId: '', semaphoreTeardownTemplateId: '',
scope: 'global',
}); });
setIsEditing(true); setIsEditing(true);
}; };
@ -91,6 +98,7 @@ export default function LabTemplates({
deviceIds: [...lab.deviceIds], deviceIds: [...lab.deviceIds],
semaphoreSetupTemplateId: lab.semaphoreSetupTemplateId || '', semaphoreSetupTemplateId: lab.semaphoreSetupTemplateId || '',
semaphoreTeardownTemplateId: lab.semaphoreTeardownTemplateId || '', semaphoreTeardownTemplateId: lab.semaphoreTeardownTemplateId || '',
scope: lab.scope ?? 'global',
}); });
setIsEditing(true); setIsEditing(true);
}; };
@ -137,23 +145,110 @@ export default function LabTemplates({
topology: tempLinks, topology: tempLinks,
semaphoreSetupTemplateId: formData.semaphoreSetupTemplateId, semaphoreSetupTemplateId: formData.semaphoreSetupTemplateId,
semaphoreTeardownTemplateId: formData.semaphoreTeardownTemplateId, semaphoreTeardownTemplateId: formData.semaphoreTeardownTemplateId,
scope: formData.scope,
}; };
if (formMode === 'add') { if (formMode === 'add') {
onAddLab(savedLabData); onAddLab(savedLabData);
} else if (formMode === 'edit' && formData.id) { } else if (formMode === 'edit' && formData.id) {
const existing = labs.find(l => l.id === formData.id);
onUpdateLab({ onUpdateLab({
...savedLabData, ...savedLabData,
id: formData.id id: formData.id,
ownerId: existing?.ownerId ?? '',
}); });
} }
setIsEditing(false); 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 ( return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="lab-templates-container"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="lab-templates-container">
{/* LEFT COLUMN: Lab List */} {/* LEFT COLUMN: Lab List */}
<div className="lg:col-span-4 bg-[#1E293B] border border-slate-800 rounded-xl p-5 flex flex-col h-full font-sans" id="labs-list-section"> <div className="lg:col-span-4 bg-[#1E293B] border border-slate-800 rounded-xl p-5 flex flex-col h-full font-sans" id="labs-list-section">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
@ -174,68 +269,29 @@ export default function LabTemplates({
</button> </button>
</div> </div>
{/* Labs templates list */} {/* Labs templates list — sectioned */}
<div className="space-y-3 flex-1 overflow-y-auto max-h-[550px] pr-1"> <div className="space-y-3 flex-1 overflow-y-auto max-h-[550px] pr-1">
{labs.map((lab) => { {myPersonalLabs.length > 0 && (
const isSelected = selectedLab?.id === lab.id; <>
return ( <p className="text-[10px] font-mono uppercase tracking-widest text-indigo-400 px-1">My Topologies</p>
<div {myPersonalLabs.map(renderLabCard)}
key={lab.id} </>
onClick={() => setSelectedLab(lab)} )}
className={`p-4 rounded-xl border transition-all cursor-pointer relative ${ {globalLabs.length > 0 && (
isSelected <>
? 'bg-slate-900 border-emerald-500' <p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Global Topologies</p>
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60' {globalLabs.map(renderLabCard)}
}`} </>
> )}
<div className="flex justify-between items-start"> {othersPersonal.length > 0 && (
<h3 className="font-bold text-sm text-white">{lab.name}</h3> <>
<div className="flex gap-1.5" onClick={e => e.stopPropagation()}> <p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Others' Personal</p>
<button {othersPersonal.map(renderLabCard)}
onClick={() => handleOpenEdit(lab)} </>
className="text-slate-400 hover:text-indigo-400 p-0.5" )}
title="Edit template configuration" {labs.length === 0 && (
> <p className="text-xs text-slate-500 text-center py-8">No topology templates yet.</p>
<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>
);
})}
</div> </div>
</div> </div>
@ -254,7 +310,7 @@ export default function LabTemplates({
</div> </div>
<div className="flex items-center gap-2.5"> <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"> <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> <div>
<p className="text-slate-400 leading-none">Primary Contact</p> <p className="text-slate-400 leading-none">Primary Contact</p>
<p className="text-white font-semibold mt-0.5">{selectedLab.contactPerson}</p> <p className="text-white font-semibold mt-0.5">{selectedLab.contactPerson}</p>
@ -347,7 +403,6 @@ export default function LabTemplates({
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} 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" 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>
<div> <div>
@ -358,7 +413,6 @@ export default function LabTemplates({
value={formData.location} value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })} 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" 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>
</div> </div>
@ -373,7 +427,6 @@ export default function LabTemplates({
value={formData.contactPerson} value={formData.contactPerson}
onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })} 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" 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>
<div> <div>
@ -384,11 +437,39 @@ export default function LabTemplates({
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} 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" 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>
</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 */} {/* Hardware checklist */}
<div className="border-t border-slate-800 pt-3"> <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> <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 <input
type="text" type="text"
className="w-full bg-slate-950 text-slate-250 border border-slate-805 p-1 rounded font-mono text-[11px]" 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} value={linkType}
onChange={(e) => setLinkType(e.target.value)} onChange={(e) => setLinkType(e.target.value)}
/> />
@ -536,7 +616,7 @@ export default function LabTemplates({
</div> </div>
{/* Ansible Semaphore Automation */} {/* 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"> <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" /> <Terminal className="w-3.5 h-3.5 text-orange-400" />
3. Ansible Automation (optional) 3. Ansible Automation (optional)
@ -551,7 +631,6 @@ export default function LabTemplates({
value={formData.semaphoreSetupTemplateId} value={formData.semaphoreSetupTemplateId}
onChange={(e) => setFormData({ ...formData, semaphoreSetupTemplateId: e.target.value })} 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" 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>
<div> <div>
@ -562,11 +641,10 @@ export default function LabTemplates({
value={formData.semaphoreTeardownTemplateId} value={formData.semaphoreTeardownTemplateId}
onChange={(e) => setFormData({ ...formData, semaphoreTeardownTemplateId: e.target.value })} 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" 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>
</div> </div>}
{/* Form submit handlers */} {/* Form submit handlers */}
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5"> <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" type="submit"
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs animate-none" 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> </button>
</div> </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> <span className="absolute left-3 top-2.5 text-slate-550"><Search className="w-4 h-4" /></span>
<input <input
type="text" type="text"
placeholder="Search links by name, host, category…"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} 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" 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); } 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" 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 <p
@ -325,7 +323,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
required autoFocus required autoFocus
value={draft.title} value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })} 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" className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
/> />
</div> </div>
@ -336,7 +333,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
required required
value={draft.url} value={draft.url}
onChange={(e) => setDraft({ ...draft, url: e.target.value })} 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" 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> </div>
@ -347,7 +343,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
list="link-categories" list="link-categories"
value={draft.category} value={draft.category}
onChange={(e) => setDraft({ ...draft, category: e.target.value })} 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" 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"> <datalist id="link-categories">
@ -361,7 +356,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
rows={2} rows={2}
value={draft.description} value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })} 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" 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> </div>

View File

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

View File

@ -102,7 +102,6 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
value={email} value={email}
onChange={e => setEmail(e.target.value)} 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" 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> </div>
@ -119,7 +118,6 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
value={password} value={password}
onChange={e => setPassword(e.target.value)} 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" 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 <button
type="button" type="button"

View File

@ -106,7 +106,6 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
value={name} value={name}
onChange={e => setName(e.target.value)} 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" 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> </div>
@ -122,7 +121,6 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
value={email} value={email}
onChange={e => setEmail(e.target.value)} 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" 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> </div>
@ -139,7 +137,6 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
value={password} value={password}
onChange={e => setPassword(e.target.value)} 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" 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 <button
type="button" type="button"
@ -174,7 +171,6 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
? 'border-red-700 focus:ring-red-500/50' ? 'border-red-700 focus:ring-red-500/50'
: 'border-slate-700 focus:ring-cyan-500/50 focus:border-cyan-500/50' : 'border-slate-700 focus:ring-cyan-500/50 focus:border-cyan-500/50'
}`} }`}
placeholder="Repeat password"
/> />
</div> </div>

View File

@ -35,6 +35,7 @@ interface CaddyRoute {
upstream: string; upstream: string;
tls: number; tls: number;
compress: number; compress: number;
redirect: string;
} }
interface DbInfo { 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; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
alreadySet: boolean;
show: boolean; show: boolean;
onToggleShow: () => void; onToggleShow: () => void;
}) { }) {
@ -124,7 +124,6 @@ function SecretInput({ value, onChange, alreadySet, show, onToggleShow }: {
type={show ? 'text' : 'password'} type={show ? 'text' : 'password'}
value={value} value={value}
onChange={e => onChange(e.target.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" 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 <button
@ -174,7 +173,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [checkmkEnabled, setCheckmkEnabled] = useState(false); const [checkmkEnabled, setCheckmkEnabled] = useState(false);
const [checkmkApiUrl, setCheckmkApiUrl] = useState(''); const [checkmkApiUrl, setCheckmkApiUrl] = useState('');
const [checkmkApiUser, setCheckmkApiUser] = useState('automation'); const [checkmkApiUser, setCheckmkApiUser] = useState('');
const [checkmkApiSecret, setCheckmkApiSecret] = useState(''); const [checkmkApiSecret, setCheckmkApiSecret] = useState('');
const [checkmkSecretSet, setCheckmkSecretSet] = useState(false); const [checkmkSecretSet, setCheckmkSecretSet] = useState(false);
const [checkmkSyncInterval, setCheckmkSyncInterval] = useState('60000'); const [checkmkSyncInterval, setCheckmkSyncInterval] = useState('60000');
@ -192,7 +191,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [caddyEnabled, setCaddyEnabled] = useState(false); const [caddyEnabled, setCaddyEnabled] = useState(false);
const [caddyManaged, setCaddyManaged] = useState(true); 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 [caddyStatus, setCaddyStatus] = useState<'unknown' | 'available' | 'unavailable'>('unknown');
const [caddyRoutes, setCaddyRoutes] = useState<CaddyRoute[]>([]); const [caddyRoutes, setCaddyRoutes] = useState<CaddyRoute[]>([]);
const [addingRoute, setAddingRoute] = useState(false); const [addingRoute, setAddingRoute] = useState(false);
@ -200,12 +199,14 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [newUpstream, setNewUpstream] = useState(''); const [newUpstream, setNewUpstream] = useState('');
const [newTls, setNewTls] = useState(true); const [newTls, setNewTls] = useState(true);
const [newCompress, setNewCompress] = useState(true); const [newCompress, setNewCompress] = useState(true);
const [newRedirect, setNewRedirect] = useState('');
const [editingRouteId, setEditingRouteId] = useState<number | null>(null); const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
const [editHostname, setEditHostname] = useState(''); const [editHostname, setEditHostname] = useState('');
const [editUpstream, setEditUpstream] = useState(''); const [editUpstream, setEditUpstream] = useState('');
const [editTls, setEditTls] = useState(true); const [editTls, setEditTls] = useState(true);
const [editCompress, setEditCompress] = useState(true); const [editCompress, setEditCompress] = useState(true);
const [editRedirect, setEditRedirect] = useState('');
const [savingRoute, setSavingRoute] = useState(false); const [savingRoute, setSavingRoute] = useState(false);
const [dbInfo, setDbInfo] = useState<DbInfo | null>(null); const [dbInfo, setDbInfo] = useState<DbInfo | null>(null);
@ -222,7 +223,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
.then(r => r.json()) .then(r => r.json())
.then(d => { .then(d => {
if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri); if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri);
setCaddyManaged(d.caddyManaged !== false); setCaddyManaged(d.isProduction !== false);
}) })
.catch(() => {}); .catch(() => {});
}, []); }, []);
@ -252,7 +253,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setAzureAllowedGroup(data.azure_allowed_group || ''); setAzureAllowedGroup(data.azure_allowed_group || '');
setCheckmkEnabled(data.checkmk_enabled === 'true'); setCheckmkEnabled(data.checkmk_enabled === 'true');
setCheckmkApiUrl(data.checkmk_api_url || ''); setCheckmkApiUrl(data.checkmk_api_url || '');
setCheckmkApiUser(data.checkmk_api_user || 'automation'); setCheckmkApiUser(data.checkmk_api_user || '');
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL); setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
setCheckmkApiSecret(''); setCheckmkApiSecret('');
setCheckmkSyncInterval(data.checkmk_sync_interval_ms || '60000'); setCheckmkSyncInterval(data.checkmk_sync_interval_ms || '60000');
@ -262,7 +263,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setSemaphoreApiToken(''); setSemaphoreApiToken('');
setSemaphoreProjectId(data.semaphore_project_id || ''); setSemaphoreProjectId(data.semaphore_project_id || '');
setCaddyEnabled(data.caddy_enabled === 'true'); 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 { } catch {
setError('Network error loading settings.'); setError('Network error loading settings.');
} finally { } finally {
@ -347,7 +348,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
return; return;
} }
const templates = await res.json() as any[]; 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 { } catch {
setSemaphoreTestResult('Error: Network error connecting to Semaphore.'); setSemaphoreTestResult('Error: Network error connecting to Semaphore.');
} finally { } finally {
@ -368,7 +369,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const res = await authFetch('/api/caddy/routes'); const res = await authFetch('/api/caddy/routes');
if (res.ok) setCaddyRoutes(await res.json()); if (res.ok) setCaddyRoutes(await res.json());
} catch {} } 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') authFetch('/api/caddy/status')
.then(res => res.ok ? res.json() : null) .then(res => res.ok ? res.json() : null)
.then(s => setCaddyStatus(s?.available ? 'available' : 'unavailable')) .then(s => setCaddyStatus(s?.available ? 'available' : 'unavailable'))
@ -381,7 +382,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
try { try {
const res = await authFetch('/api/caddy/routes', { const res = await authFetch('/api/caddy/routes', {
method: 'POST', method: 'POST',
body: JSON.stringify({ hostname: newHostname.trim(), upstream: newUpstream.trim(), tls: newTls, compress: newCompress }), body: JSON.stringify({ hostname: newHostname.trim(), upstream: newUpstream.trim(), tls: newTls, compress: newCompress, redirect: newRedirect.trim() }),
}); });
if (!res.ok) { if (!res.ok) {
const d = await res.json(); const d = await res.json();
@ -392,6 +393,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setNewUpstream(''); setNewUpstream('');
setNewTls(true); setNewTls(true);
setNewCompress(true); setNewCompress(true);
setNewRedirect('');
await loadCaddyRoutes(); await loadCaddyRoutes();
} catch { } catch {
setError('Network error adding route.'); setError('Network error adding route.');
@ -420,6 +422,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setEditUpstream(r.upstream); setEditUpstream(r.upstream);
setEditTls(r.tls === 1); setEditTls(r.tls === 1);
setEditCompress(r.compress === 1); setEditCompress(r.compress === 1);
setEditRedirect(r.redirect || '');
} }
function handleEditCancel() { function handleEditCancel() {
@ -432,7 +435,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
try { try {
const res = await authFetch(`/api/caddy/routes/${id}`, { const res = await authFetch(`/api/caddy/routes/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ hostname: editHostname.trim(), upstream: editUpstream.trim(), tls: editTls, compress: editCompress }), body: JSON.stringify({ hostname: editHostname.trim(), upstream: editUpstream.trim(), tls: editTls, compress: editCompress, redirect: editRedirect.trim() }),
}); });
if (!res.ok) { if (!res.ok) {
const d = await res.json(); const d = await res.json();
@ -565,7 +568,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</div> </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"> <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" /> }, { 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' : ''}`}> <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"> <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>
<FieldRow label="Client ID (Application ID)" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"> <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>
<FieldRow <FieldRow
label="Client Secret" label="Client Secret"
@ -641,7 +644,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<SecretInput <SecretInput
value={azureClientSecret} value={azureClientSecret}
onChange={setAzureClientSecret} onChange={setAzureClientSecret}
alreadySet={azureSecretSet}
show={showAzureSecret} show={showAzureSecret}
onToggleShow={() => setShowAzureSecret(v => !v)} onToggleShow={() => setShowAzureSecret(v => !v)}
/> />
@ -654,7 +656,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<Input <Input
value={azureRedirectUri} value={azureRedirectUri}
onChange={setAzureRedirectUri} onChange={setAzureRedirectUri}
placeholder={effectiveRedirectUri || 'https://…/api/auth/azure/callback'}
monospace monospace
/> />
</FieldRow> </FieldRow>
@ -666,7 +667,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<Input <Input
value={azureAllowedGroup} value={azureAllowedGroup}
onChange={setAzureAllowedGroup} onChange={setAzureAllowedGroup}
placeholder="Leave blank to allow all tenant users"
monospace monospace
icon={<Users className="w-3.5 h-3.5" />} icon={<Users className="w-3.5 h-3.5" />}
/> />
@ -737,17 +737,15 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<Input <Input
value={checkmkApiUrl} value={checkmkApiUrl}
onChange={setCheckmkApiUrl} onChange={setCheckmkApiUrl}
placeholder="https://checkmk/<site>/check_mk/api/1.0"
monospace monospace
icon={<Globe className="w-3.5 h-3.5" />} icon={<Globe className="w-3.5 h-3.5" />}
/> />
</FieldRow> </FieldRow>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5"> <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 <Input
value={checkmkApiUser} value={checkmkApiUser}
onChange={setCheckmkApiUser} onChange={setCheckmkApiUser}
placeholder="automation"
monospace monospace
icon={<KeyRound className="w-3.5 h-3.5" />} icon={<KeyRound className="w-3.5 h-3.5" />}
/> />
@ -760,7 +758,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<SecretInput <SecretInput
value={checkmkApiSecret} value={checkmkApiSecret}
onChange={setCheckmkApiSecret} onChange={setCheckmkApiSecret}
alreadySet={checkmkSecretSet}
show={showCheckmkSecret} show={showCheckmkSecret}
onToggleShow={() => setShowCheckmkSecret(v => !v)} onToggleShow={() => setShowCheckmkSecret(v => !v)}
/> />
@ -769,7 +766,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<Input <Input
value={checkmkSyncInterval} value={checkmkSyncInterval}
onChange={setCheckmkSyncInterval} onChange={setCheckmkSyncInterval}
placeholder="60000"
monospace monospace
icon={<Clock className="w-3.5 h-3.5" />} icon={<Clock className="w-3.5 h-3.5" />}
/> />
@ -830,7 +826,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<Input <Input
value={semaphoreApiUrl} value={semaphoreApiUrl}
onChange={setSemaphoreApiUrl} onChange={setSemaphoreApiUrl}
placeholder="https://semaphore/api/v1alpha"
monospace monospace
icon={<Globe className="w-3.5 h-3.5" />} icon={<Globe className="w-3.5 h-3.5" />}
/> />
@ -844,7 +839,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<SecretInput <SecretInput
value={semaphoreApiToken} value={semaphoreApiToken}
onChange={setSemaphoreApiToken} onChange={setSemaphoreApiToken}
alreadySet={semaphoreTokenSet}
show={showSemaphoreToken} show={showSemaphoreToken}
onToggleShow={() => setShowSemaphoreToken(v => !v)} onToggleShow={() => setShowSemaphoreToken(v => !v)}
/> />
@ -853,7 +847,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<Input <Input
value={semaphoreProjectId} value={semaphoreProjectId}
onChange={setSemaphoreProjectId} onChange={setSemaphoreProjectId}
placeholder="1"
monospace monospace
icon={<KeyRound className="w-3.5 h-3.5" />} icon={<KeyRound className="w-3.5 h-3.5" />}
/> />
@ -939,19 +932,19 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
{caddyManaged && ( {caddyManaged && (
<div className={`space-y-5 transition-opacity duration-200 ${!caddyEnabled ? 'opacity-40 pointer-events-none' : ''}`}> <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"> <FieldRow label="Caddy Admin URL" hint="Default: http://127.0.0.1:2019">
<Input value={caddyAdminUrl} onChange={setCaddyAdminUrl} placeholder="http://localhost:2019" monospace icon={<Globe className="w-3.5 h-3.5" />} /> <Input value={caddyAdminUrl} onChange={setCaddyAdminUrl} monospace icon={<Globe className="w-3.5 h-3.5" />} />
</FieldRow> </FieldRow>
{/* Route list */} {/* Route list */}
{caddyEnabled && ( {caddyEnabled && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Proxy Routes</Label> <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' && ( {caddyStatus === 'unavailable' && (
<p className="text-[11px] font-mono text-amber-400 mb-2"> <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> </p>
)} )}
@ -965,14 +958,15 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
{caddyRoutes.map((r: CaddyRoute) => ( {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"> <div key={r.id} className="bg-slate-900/60 border border-slate-700/60 rounded-xl px-4 py-2.5">
{editingRouteId === r.id ? ( {editingRouteId === r.id ? (
<div className="space-y-2">
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Label>Hostname</Label> <Label>Hostname</Label>
<Input value={editHostname} onChange={setEditHostname} placeholder="hostname" monospace /> <Input value={editHostname} onChange={setEditHostname} monospace />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Label>Upstream</Label> <Label>Upstream</Label>
<Input value={editUpstream} onChange={setEditUpstream} placeholder="127.0.0.1:3000" monospace /> <Input value={editUpstream} onChange={setEditUpstream} monospace />
</div> </div>
<div className="flex flex-col items-center gap-1 pb-0.5"> <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> <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" /> <X className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
<Input value={editRedirect} onChange={setEditRedirect} monospace />
</div>
) : ( ) : (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0"> <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> <span className="text-[11px] font-mono text-slate-400 truncate">{r.upstream}</span>
{r.tls ? <span className="text-[9px] font-semibold text-sky-500 font-mono">TLS</span> : null} {r.tls ? <span className="text-[9px] font-semibold text-sky-500 font-mono">TLS</span> : null}
{r.compress ? <span className="text-[9px] font-semibold text-slate-500 font-mono">GZ</span> : null} {r.compress ? <span className="text-[9px] font-semibold text-slate-500 font-mono">GZ</span> : null}
{r.redirect ? <span className="text-[9px] font-mono text-slate-500 truncate">&#8627; {r.redirect}</span> : null}
</div> </div>
<div className="flex items-center gap-1 ml-3 shrink-0"> <div className="flex items-center gap-1 ml-3 shrink-0">
<button type="button" onClick={() => handleEditStart(r)} <button type="button" onClick={() => handleEditStart(r)}
@ -1022,14 +1019,15 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
))} ))}
{/* Add route form */} {/* 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"> <div className="flex-1 min-w-0">
<Label>Hostname</Label> <Label>Hostname</Label>
<Input value={newHostname} onChange={setNewHostname} placeholder="semaphore" monospace /> <Input value={newHostname} onChange={setNewHostname} monospace />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Label>Upstream</Label> <Label>Upstream</Label>
<Input value={newUpstream} onChange={setNewUpstream} placeholder="127.0.0.1:3000" monospace /> <Input value={newUpstream} onChange={setNewUpstream} monospace />
</div> </div>
<div className="flex flex-col items-center gap-1 pb-0.5"> <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> <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'} {addingRoute ? 'Adding…' : 'Add'}
</button> </button>
</div> </div>
<Input value={newRedirect} onChange={setNewRedirect} monospace />
</div>
</div> </div>
)} )}
</div> </div>
@ -1083,7 +1083,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-xl font-bold text-white font-mono leading-none"> <p className="text-xl font-bold text-white font-mono leading-none">
{dbInfo ? formatBytes(dbInfo.sizeBytes) : ''} {dbInfo ? formatBytes(dbInfo.sizeBytes) : '-'}
</p> </p>
<p className="text-[10px] text-slate-500 font-mono mt-1"> <p className="text-[10px] text-slate-500 font-mono mt-1">
{dbInfo ? new Date(dbInfo.lastModified).toLocaleDateString() : 'Loading…'} {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"> <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" /> <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"> <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> </p>
</div> </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"> <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 React, { useState, useMemo } from 'react';
import { User, Booking } from '../types'; 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 { interface UserDirectoryProps {
users: User[]; users: User[];
@ -8,6 +8,7 @@ interface UserDirectoryProps {
bookings: Booking[]; bookings: Booking[];
onDeleteUser: (id: string) => Promise<void>; onDeleteUser: (id: string) => Promise<void>;
onUpdateUser: (id: string, name: string, email: string) => Promise<void>; onUpdateUser: (id: string, name: string, email: string) => Promise<void>;
onSetRole: (id: string, role: string) => Promise<void>;
} }
const AVATAR_COLORS = [ 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 [search, setSearch] = useState('');
const [editingUser, setEditingUser] = useState<User | null>(null); const [editingUser, setEditingUser] = useState<User | null>(null);
const [deletingId, setDeletingId] = useState<string | 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 bookingCount = useMemo(() => {
const map = new Map<string, number>(); 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); } 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) { async function handleSaveEdit(name: string, email: string) {
if (!editingUser) return; if (!editingUser) return;
await onUpdateUser(editingUser.id, name, email); await onUpdateUser(editingUser.id, name, email);
@ -190,12 +206,19 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
</div> </div>
</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 */} {/* Search */}
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-2.5 text-slate-500"><Search className="w-4 h-4" /></span> <span className="absolute left-3 top-2.5 text-slate-500"><Search className="w-4 h-4" /></span>
<input <input
type="text" type="text"
placeholder="Search operators by name, email or role…"
value={search} value={search}
onChange={e => setSearch(e.target.value)} 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" 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>
<div className="mt-4 pt-3 border-t border-slate-800 flex items-center justify-between gap-2"> <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-2">
<div className="flex items-center gap-3 text-[10px] font-mono text-slate-400"> <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 */} {/* Action buttons */}
<div className="flex items-center gap-1 ml-1"> <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 <button
onClick={() => setEditingUser(user)} onClick={() => setEditingUser(user)}
className="p-1.5 rounded-lg text-slate-500 hover:text-cyan-400 hover:bg-slate-900 transition-all" 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\/20,
:root.light .bg-slate-950\/30, :root.light .bg-slate-950\/30,
:root.light .bg-slate-950\/40, :root.light .bg-slate-950\/40,
:root.light .bg-slate-950\/50,
:root.light .bg-slate-950\/60, :root.light .bg-slate-950\/60,
:root.light .bg-slate-950\/80 { :root.light .bg-slate-950\/80 {
background-color: rgba(241, 245, 249, 0.85) !important; background-color: rgba(241, 245, 249, 0.85) !important;
@ -730,6 +731,12 @@
/* ── Missing border opacity variants ─────────────────────────────── */ /* ── 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 */ /* slate-700 with opacity */
:root.light .border-slate-700\/40, :root.light .border-slate-700\/40,
:root.light .border-slate-700\/50, :root.light .border-slate-700\/50,

View File

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