chore(release): merge dev into main
This commit is contained in:
@ -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}
|
||||||
@ -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,7 +875,7 @@ 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.
|
- 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**.
|
||||||
|
|||||||
@ -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)"
|
||||||
|
|
||||||
|
|||||||
78
server-db.ts
78
server-db.ts
@ -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 {
|
|
||||||
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default db;
|
export default db;
|
||||||
40
server-migrations.ts
Normal file
40
server-migrations.ts
Normal 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
247
server.ts
@ -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();
|
||||||
|
|||||||
21
src/App.tsx
21
src/App.tsx
@ -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' && (
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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.VITE_DEPLOY_ENV === 'dev' || import.meta.env.DEV) ? 'bg-amber-400' : 'bg-emerald-500'}`} />
|
<span className={`w-2 h-2 rounded-full animate-pulse ${isProduction ? 'bg-emerald-500' : 'bg-amber-400'}`} />
|
||||||
<span>System: {(import.meta.env.VITE_DEPLOY_ENV === 'dev' || import.meta.env.DEV) ? 'Development' : 'Production'}</span>
|
<span>System: {isProduction ? 'Production' : 'Development'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mail Inbox */}
|
{/* Mail Inbox */}
|
||||||
|
|||||||
@ -4,17 +4,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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;
|
||||||
@ -23,6 +25,8 @@ interface LabTemplatesProps {
|
|||||||
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,20 +145,107 @@ 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">
|
||||||
|
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -117,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"
|
||||||
@ -239,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>
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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">↳ {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">
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user