Compare commits
28 Commits
f1200425af
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cc96f5b6ce | |||
| e0fd19f471 | |||
| 5c7ad3140a | |||
| c3931e7f36 | |||
| d78ade4629 | |||
| 84bad8c0e6 | |||
| 08a4df5503 | |||
| cb36caff2e | |||
| be007791dc | |||
| 515052fbda | |||
| 49cd0ae4f6 | |||
| a2d515992c | |||
| 2a2902d5bc | |||
| ac1cf8fec7 | |||
| e0332b05ad | |||
| bc677ff805 | |||
| 1dba721a9a | |||
| f6263ad2f3 | |||
| 6f621067b9 | |||
| d429b2d252 | |||
| 1526d25144 | |||
| 2857040803 | |||
| acadf8db7c | |||
| 250c347f58 | |||
| f66b1ca456 | |||
| 00cf5dd02d | |||
| 47e7b65613 | |||
| e5e7c571a4 |
143
ARCHITECTURE.md
143
ARCHITECTURE.md
@ -1,20 +1,10 @@
|
||||
# GhostGrid
|
||||
## Architecture Reference
|
||||
|
||||
**Version:** 1.1
|
||||
**Date:** June 8, 2026
|
||||
**Status:** Living document — single source of truth for the codebase
|
||||
|
||||
> Use this document as the starting context for any future task on GhostGrid. It describes the whole application: purpose, stack, file layout, data model, REST API, frontend structure, integrations, background jobs, security, and deployment.
|
||||
|
||||
### Revision History
|
||||
|
||||
| Version | Date | Changes |
|
||||
|---------|------|---------|
|
||||
| 1.2 | Jun 8, 2026 | Removed `caddy_prod_domain` / `caddy_dev_domain` settings; Caddy now routes only custom entries |
|
||||
| 1.1 | Jun 8, 2026 | Dropped the migration layer (fresh-install schema); renamed the `caddy_routes` table to `caddy` |
|
||||
| 1.0 | Jun 8, 2026 | Initial architecture reference generated from the codebase |
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
@ -125,7 +115,7 @@
|
||||
| Component | Technology | Purpose |
|
||||
|-----------|------------|---------|
|
||||
| Database | SQLite via `better-sqlite3` | Single file `ghostgrid.db`, WAL journal mode, synchronous queries |
|
||||
| Schema | Idempotent `CREATE TABLE IF NOT EXISTS` | 8 tables defined in full and created on boot (fresh-install model, no migrations) |
|
||||
| Schema | Idempotent `CREATE TABLE IF NOT EXISTS` | Baseline 8 tables created on boot via `server-db.ts`; additive changes to live DBs go in `server-migrations.ts` (see §4.4) |
|
||||
| Settings store | key/value `settings` table | Runtime config for all integrations, seeded with `INSERT OR IGNORE` |
|
||||
|
||||
---
|
||||
@ -205,7 +195,7 @@ Networking (optional, managed in-app)
|
||||
|
||||
## 4. Database Schema Design
|
||||
|
||||
SQLite file `ghostgrid.db` in `process.cwd()`, opened with `journal_mode = WAL`. Every table is defined in full and created idempotently on boot (`CREATE TABLE IF NOT EXISTS`) — the app assumes a fresh install, so there is no migration layer.
|
||||
SQLite file `ghostgrid.db` in `process.cwd()`, opened with `journal_mode = WAL`. Baseline tables are created idempotently on boot (`CREATE TABLE IF NOT EXISTS` in `server-db.ts`). Additive changes to live DBs (new columns, tables, default settings) are applied by the migration runner in `server-migrations.ts`. See §4.4.
|
||||
|
||||
### 4.1 Schema (as created in `server-db.ts`)
|
||||
|
||||
@ -241,8 +231,11 @@ CREATE TABLE IF NOT EXISTS labs (
|
||||
deviceIds TEXT NOT NULL, -- JSON string: string[]
|
||||
topology TEXT NOT NULL, -- JSON string: TopologyLink[]
|
||||
semaphoreSetupTemplateId TEXT NOT NULL DEFAULT '',
|
||||
semaphoreTeardownTemplateId TEXT NOT NULL DEFAULT ''
|
||||
semaphoreTeardownTemplateId TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'global', -- 'global' | 'personal'
|
||||
ownerId TEXT NOT NULL DEFAULT '' -- userId of creator; '' = legacy (pre-migration)
|
||||
);
|
||||
-- scope/ownerId were added after initial release via idempotent ALTER TABLE in server-db.ts
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bookings (
|
||||
id TEXT PRIMARY KEY,
|
||||
@ -287,12 +280,13 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS caddy (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hostname TEXT NOT NULL,
|
||||
upstream TEXT NOT NULL,
|
||||
tls INTEGER NOT NULL DEFAULT 1,
|
||||
compress INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hostname TEXT NOT NULL,
|
||||
upstream TEXT NOT NULL,
|
||||
tls INTEGER NOT NULL DEFAULT 1,
|
||||
compress INTEGER NOT NULL DEFAULT 1,
|
||||
redirect TEXT NOT NULL DEFAULT '', -- optional 'redir / <path>' for the bare root
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
|
||||
@ -304,7 +298,7 @@ CREATE TABLE IF NOT EXISTS caddy (
|
||||
| JSON columns | `labs.deviceIds` and `labs.topology` are JSON strings, parsed in the API layer |
|
||||
| Booleans | Booking flags (`notified`, `emailSent`, `ansible*Triggered`) are `INTEGER` 0/1, mapped to JS booleans on read |
|
||||
| Cascade | No FK cascades; referential cleanup is done in code (e.g. deleting a device scrubs it from every lab's `deviceIds`/`topology`) |
|
||||
| Schema changes | Fresh-install model — edit the `CREATE TABLE` block in `server-db.ts` directly; there is no migration helper |
|
||||
| Schema changes | Baseline schema in `server-db.ts` (`CREATE TABLE IF NOT EXISTS`). New columns / tables / default settings for live DBs go in `server-migrations.ts` as an appended migration object. See §4.4. |
|
||||
|
||||
### 4.3 Settings (key/value config)
|
||||
|
||||
@ -319,6 +313,29 @@ Seeded with `INSERT OR IGNORE` (defaults below). Secret keys are never returned
|
||||
|
||||
🔒 = in `SECRET_KEYS`, masked on read, only updated when a non-`__SET__` value is sent.
|
||||
|
||||
### 4.4 Migration System
|
||||
|
||||
Additive schema changes to live databases are handled by `server-migrations.ts`. The runner executes once at the start of `startServer()`, before routes or background jobs, and is synchronous (`better-sqlite3`).
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Creates (idempotently) a `_migrations` table: `id TEXT PRIMARY KEY, applied_at TEXT`.
|
||||
2. Iterates the `migrations` array in order. For each entry whose `id` is not in `_migrations`, runs `up(db)` inside a transaction and records the id on success.
|
||||
3. If `up()` throws, the transaction rolls back and the id is not recorded — the migration retries on the next start.
|
||||
4. Already-applied migrations are skipped forever.
|
||||
|
||||
**Rules for adding a migration:**
|
||||
|
||||
- Append to the end of the `migrations` array in `server-migrations.ts`. **Never reorder or remove entries.**
|
||||
- Use a unique, descriptive `id` — format: `NNNN_short_description` (e.g. `0001_bookings_add_color`).
|
||||
- Only additive DDL: `ALTER TABLE … ADD COLUMN`, `CREATE TABLE IF NOT EXISTS`, `INSERT OR IGNORE INTO settings`.
|
||||
- New default settings for **live DBs** go here. Day-0 seeds (fresh install) stay in `DEFAULT_SETTINGS` in `server-db.ts`.
|
||||
- New settings still need allow-listing in `PUT /api/settings` (and `SECRET_KEYS` if secret) — the migration only seeds the value.
|
||||
|
||||
**Fresh-install behavior:** `server-db.ts` creates all baseline tables first (at module import). `runMigrations` then applies every migration in the array — safe because all migrations are additive over the baseline.
|
||||
|
||||
**`_migrations` is excluded** from `/api/database/import` and `/api/database/info` (not in their explicit table lists) — migration state belongs to the current install, not the imported data.
|
||||
|
||||
---
|
||||
|
||||
## 5. API Design
|
||||
@ -355,9 +372,9 @@ All `/api/*` routes return JSON. Every route except the public auth/config endpo
|
||||
|
|
||||
+-- /labs
|
||||
| +-- GET / # List labs (parses deviceIds/topology JSON) [auth]
|
||||
| +-- POST / # Create lab [auth]
|
||||
| +-- PUT /{id} # Update lab [auth]
|
||||
| +-- DELETE /{id} # Delete + cancel upcoming bookings [auth]
|
||||
| +-- POST / # Create lab; sets ownerId=req.user [auth]
|
||||
| +-- PUT /{id} # Update lab; 403 if not owner/admin/legacy [auth]
|
||||
| +-- DELETE /{id} # Delete + cancel upcoming bookings; same 403 guard [auth]
|
||||
|
|
||||
+-- /bookings
|
||||
| +-- GET / # List bookings (int flags > booleans) [auth]
|
||||
@ -384,8 +401,9 @@ All `/api/*` routes return JSON. Every route except the public auth/config endpo
|
||||
|
|
||||
+-- /caddy
|
||||
+-- GET /status # Caddy admin API reachable? [auth]
|
||||
+-- GET /routes # { system, custom } routes [auth]
|
||||
+-- GET /routes # Custom routes (plain array) [auth]
|
||||
+-- POST /routes # Add custom route + push config [auth]
|
||||
+-- PUT /routes/{id} # Update custom route + push config [auth]
|
||||
+-- DELETE /routes/{id} # Remove custom route + push config [auth]
|
||||
```
|
||||
|
||||
@ -398,8 +416,10 @@ Auth model
|
||||
+-- Storage: browser localStorage (ghostgrid_token, ghostgrid_user)
|
||||
+-- Middleware
|
||||
| +-- requireAuth — verifies JWT, sets req.user; applied to all data routes
|
||||
| +-- requireAdmin — checks users.role === 'admin' ⚠ DEFINED BUT NOT WIRED
|
||||
+-- Roles: role column defaults to 'User'; no route currently enforces admin
|
||||
| +-- requireAdmin — checks users.role === 'admin' ⚠ DEFINED BUT NOT WIRED to routes
|
||||
+-- Roles: role column defaults to 'User'
|
||||
+-- Lab ownership: PUT/DELETE /api/labs/:id enforce inline ownership check
|
||||
| (owner || admin || legacy-lab with ownerId=''); 403 otherwise
|
||||
```
|
||||
|
||||
**Local flow:** `register` (bcrypt hash, role `User`) / `login` (bcrypt compare) > issue JWT > client stores token + user.
|
||||
@ -472,11 +492,54 @@ Manual: POST /api/semaphore/trigger/{bookingId} body { type: 'setup'|'teardown
|
||||
```
|
||||
buildCaddyfile():
|
||||
{ local_certs } # global block
|
||||
per custom route { [encode] [tls internal] reverse_proxy <upstream> }
|
||||
per custom route { [encode] [tls internal] [redir / <redirect>] reverse_proxy <upstream> { … } }
|
||||
redirect set → `redir / <path>` redirects only the bare root '/'
|
||||
(other paths pass through; e.g. CheckMK served at /<site>/check_mk/)
|
||||
every reverse_proxy block carries standard forwarding headers:
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up Host {host}
|
||||
upstream prefixed with https:// → block also gets a
|
||||
transport http { tls_insecure_skip_verify } block
|
||||
(for self-signed TLS backends like Semaphore)
|
||||
|
||||
importCaddyfileRoutes(): reads /etc/caddy/Caddyfile on first Caddy enable
|
||||
parses hostname/upstream blocks → seeds caddy table as custom routes
|
||||
(no-op if caddy table already has entries or file not found)
|
||||
|
||||
pushCaddyConfig(): POST <caddy_admin_url>/load (Content-Type: text/caddyfile)
|
||||
called on startup, after settings save, after route add/delete
|
||||
(failures logged as warnings, non-fatal; skipped if caddy_enabled !== 'true')
|
||||
(failures logged as warnings, non-fatal; skipped if caddy_enabled !== 'true'
|
||||
or if this instance is not the Caddy manager)
|
||||
|
||||
Ownership — one Caddy serves the whole container (admin API on :2019); POST /load
|
||||
replaces the ENTIRE config. Only the instance with env CADDY_MANAGER=true (production)
|
||||
pushes, seeds routes, and accepts route edits (POST/PUT/DELETE → 403 otherwise). The
|
||||
other instance shows the Caddy section read-only (/api/auth/config → caddyManaged:false)
|
||||
and never pushes — otherwise its own (partial) config would clobber the owner's. The
|
||||
owner's caddy table therefore holds ALL routes (both GhostGrid domains + every service).
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.4 First-start Initialization
|
||||
|
||||
Runs in `startServer()` on every startup — each step is idempotent.
|
||||
|
||||
```
|
||||
Default admin user (only on a blank database):
|
||||
if users table is empty:
|
||||
INSERT user (name='admin', role='admin', email='admin@ghostgrid.local', password=bcrypt('admin'))
|
||||
→ log "[Init] Default admin user created"
|
||||
|
||||
Caddy route import (re-deploy safety net, Caddy manager only):
|
||||
if CADDY_MANAGER === 'true' AND caddy_enabled === 'true' AND caddy table is empty:
|
||||
importCaddyfileRoutes() → seed routes from /etc/caddy/Caddyfile
|
||||
(also runs in PUT /api/settings on the disabled → enabled transition)
|
||||
|
||||
Default settings:
|
||||
INSERT OR IGNORE all DEFAULT_SETTINGS keys from server-db.ts
|
||||
→ existing values in the settings table are never overwritten
|
||||
```
|
||||
|
||||
---
|
||||
@ -553,6 +616,9 @@ Device Inventory
|
||||
|
||||
Lab Templates + Topology
|
||||
+-- Lab CRUD; Semaphore setup/teardown template selection
|
||||
+-- Scope toggle (Global / Personal) per lab; Personal labs visible only to owner + admins
|
||||
+-- List sectioned: "My Topologies" / "Global Topologies" / "Others' Personal" (admin only)
|
||||
+-- Edit/Delete buttons hidden for labs the current user cannot modify
|
||||
+-- Topology link editor (fromDevice > toDevice, link type)
|
||||
+-- TopologyPanel: SVG layout by node count (1 / 2 / 3 / circular)
|
||||
|
||||
@ -560,7 +626,9 @@ Settings
|
||||
+-- Microsoft Entra ID (OAuth SSO, redirect-URI helper, allowed group)
|
||||
+-- CheckMK (API URL/user/secret, sync interval, "Run sync now")
|
||||
+-- Ansible Semaphore (API URL/token/project, "Test connection")
|
||||
+-- Caddy (admin URL, custom route management)
|
||||
+-- Caddy (admin URL, custom route management;
|
||||
auto-seeded from /etc/caddy/Caddyfile on first enable;
|
||||
https:// upstream → TLS proxy, certificate not verified)
|
||||
+-- Secret inputs use the __SET__ sentinel (blank = keep existing)
|
||||
```
|
||||
|
||||
@ -575,7 +643,7 @@ The single contract between frontend and backend — imported by **both** `serve
|
||||
| `DeviceType` | `'Switch' \| 'Access-Point' \| 'Firewall' \| 'Controller' \| (string & {})` — presets + free-form |
|
||||
| `Device` | `status: 'online' \| 'offline' \| 'unknown'`; `emergencySheet` markdown; optional `cmkHostname`, `lastCheckedAt` |
|
||||
| `TopologyLink` | `{ fromDevice, toDevice, type }` (e.g. `LACP-Trunk`, `Uplink`, `OOB-Management`) |
|
||||
| `LabTemplate` | `deviceIds: string[]`, `topology: TopologyLink[]`, optional Semaphore template IDs |
|
||||
| `LabTemplate` | `deviceIds: string[]`, `topology: TopologyLink[]`, optional Semaphore template IDs; `scope: 'global' \| 'personal'`, `ownerId: string` (userId of creator, `''` for legacy rows) |
|
||||
| `Booking` | `status: 'active' \| 'upcoming' \| 'completed' \| 'cancelled'`; ansible trigger flags + job IDs |
|
||||
| `LogEntry` | `type: 'maintenance' \| 'booking' \| 'status' \| 'system'` |
|
||||
| `User` | `{ id, name, role, email }` (never password on the client) |
|
||||
@ -634,6 +702,7 @@ Backup : ghostgrid.db + ghostgrid.db-wal + ghostgrid.db-shm
|
||||
| `APP_URL` | `http://localhost:<PORT>` | Base URL for deriving the Azure redirect URI |
|
||||
| `PORT` | `3000` | HTTP listen port |
|
||||
| `NODE_ENV` | — | `production` switches to static `dist/` serving |
|
||||
| `CADDY_MANAGER` | unset | `true` makes this instance the sole Caddy owner (push/seed/edit). Set on production only — one Caddy per container |
|
||||
| `CHECKMK_API_URL` / `CHECKMK_API_USER` / `CHECKMK_API_SECRET` | — | Fallbacks if not set in the Settings UI |
|
||||
|
||||
---
|
||||
@ -655,6 +724,8 @@ Backup : ghostgrid.db + ghostgrid.db-wal + ghostgrid.db-shm
|
||||
| +-- role column ('User'/'admin') exists |
|
||||
| +-- ⚠ requireAdmin defined but NOT applied — any |
|
||||
| authenticated user can read/write settings + users |
|
||||
| +-- Lab ownership enforced on PUT/DELETE /api/labs/:id |
|
||||
| (owner || admin || legacy ownerId=''); 403 otherwise |
|
||||
+-------------------------------------------------------------+
|
||||
| Secret Handling |
|
||||
| +-- Integration secrets stored in settings table |
|
||||
@ -678,8 +749,10 @@ Backup : ghostgrid.db + ghostgrid.db-wal + ghostgrid.db-shm
|
||||
```
|
||||
GhostGrid/
|
||||
+-- server.ts # Express app: all routes, auth, integrations, background jobs
|
||||
+-- server-db.ts # SQLite connection, full schema, settings/Caddy helpers
|
||||
+-- index.html # Vite HTML entry (#root > src/main.tsx)
|
||||
+-- server-db.ts # SQLite connection, full schema, settings/Caddy/log helpers (uid, addLog)
|
||||
+-- index.html # Vite HTML entry (#root > src/main.tsx); links /favicon.svg
|
||||
+-- public/
|
||||
| +-- favicon.svg # app favicon (GhostGrid logo; served at site root by Vite)
|
||||
+-- vite.config.ts # Vite + React + Tailwind; '@' alias > repo root
|
||||
+-- tsconfig.json # noEmit, react-jsx, bundler resolution
|
||||
+-- package.json # scripts + deps (package name "react-example" is vestigial)
|
||||
@ -734,7 +807,7 @@ GhostGrid/
|
||||
| CheckMK/Semaphore outage | Integration loops catch errors, log them, and retry next cycle; non-fatal |
|
||||
| Caddy admin API unreachable | `pushCaddyConfig()` failures are logged as warnings; routes apply when Caddy starts |
|
||||
| Data loss | Back up `ghostgrid.db` + `-wal`/`-shm`; each instance has its own DB |
|
||||
| Schema evolution | Edit the `CREATE TABLE` block in `server-db.ts` (fresh-install model, no migrations); new settings need seed + allow-list (+ `SECRET_KEYS` if secret) |
|
||||
| Schema evolution | For fresh installs, edit the `CREATE TABLE` block in `server-db.ts`. For live DBs (new columns, tables, default settings), append a migration to `server-migrations.ts` (see §4.4). New settings still need allow-listing in `PUT /api/settings` (+ `SECRET_KEYS` if secret). |
|
||||
|
||||
---
|
||||
|
||||
@ -802,7 +875,9 @@ Express (server.ts) ──► better-sqlite3 (ghostgrid.db, WAL)
|
||||
- `labs.deviceIds` / `labs.topology` are JSON strings in SQLite, parsed in the API.
|
||||
- Booking boolean flags are 0/1 integers in SQLite, mapped on read.
|
||||
- A new settings key must be: **seeded** in `server-db.ts`, **allow-listed** in `PUT /api/settings`, and (if secret) added to `SECRET_KEYS`.
|
||||
- Schema changes go straight into the `CREATE TABLE` block in `server-db.ts` — fresh-install model, no migration helper.
|
||||
- Baseline schema in `server-db.ts`. Additive schema changes (new columns, tables, default settings) go in `server-migrations.ts` as an appended migration object — never edit existing entries (see §4.4).
|
||||
- The SPA catch-all (`app.get('*')`) + static serving are registered **last** in `startServer()`, after every `/api` route — otherwise GET `/api/*` falls through to `index.html`. All `/api` responses carry `Cache-Control: no-store`.
|
||||
- One Caddy per container; `POST /load` replaces the whole config. Only the `CADDY_MANAGER=true` instance may push/seed/edit routes — never let the non-manager push.
|
||||
- All user-facing strings are in **English**.
|
||||
|
||||
---
|
||||
|
||||
@ -180,7 +180,7 @@ sudo -u ghostgrid cat /opt/ghostgrid/.ssh/id_ed25519.pub
|
||||
Add the public key in Gitea:
|
||||
|
||||
```text
|
||||
Repository → Settings → Deploy Keys → Add Deploy Key
|
||||
Repository > Settings > Deploy Keys > Add Deploy Key
|
||||
```
|
||||
|
||||
Keep the deploy key read-only.
|
||||
@ -317,7 +317,7 @@ After installation or deployment, verify the following:
|
||||
7. Verify the deployment flow:
|
||||
|
||||
```text
|
||||
local change → push to dev → deploy.sh dev → test → merge to main → deploy.sh main
|
||||
local change > push to dev > deploy.sh dev > test > merge to main > deploy.sh main
|
||||
```
|
||||
|
||||
## Manual Setup of the Staging Instance
|
||||
|
||||
@ -16,7 +16,7 @@ git fetch --prune origin
|
||||
git checkout "$BRANCH"
|
||||
git pull --ff-only origin "$BRANCH"
|
||||
npm ci
|
||||
npm run build
|
||||
VITE_DEPLOY_ENV="$BRANCH" npm run build
|
||||
sudo systemctl restart "$SVC"
|
||||
echo "Deployed $BRANCH ($SVC). Status:"
|
||||
systemctl --no-pager status "$SVC" | head -n 5
|
||||
|
||||
@ -183,12 +183,15 @@ fi
|
||||
run "sudo -u ghostgrid git clone --branch ${REPO_BRANCH} '${CLONE_URL}' ${APP_DIR}"
|
||||
run "sudo -u ghostgrid git clone --branch ${DEV_BRANCH} '${CLONE_URL}' ${DEV_DIR}"
|
||||
run "chmod 600 ${APP_DIR}/.git/config ${DEV_DIR}/.git/config"
|
||||
run "chmod +x ${APP_DIR}/deploy/deploy.sh ${DEV_DIR}/deploy/deploy.sh"
|
||||
msg_ok "Repositories cloned (main + dev)"
|
||||
|
||||
msg_info "Creating .env file for each instance"
|
||||
for d in "${APP_DIR}" "${DEV_DIR}"; do
|
||||
SECRET="$(openssl rand -hex 32)"
|
||||
run "printf 'JWT_SECRET=\"%s\"\n' '${SECRET}' > ${d}/.env && chown ghostgrid:ghostgrid ${d}/.env && chmod 600 ${d}/.env"
|
||||
# Only the production instance owns Caddy and shows "Production" in the UI.
|
||||
[[ "$d" == "${APP_DIR}" ]] && run "printf 'DEPLOY_ENV=production\n' >> ${d}/.env"
|
||||
done
|
||||
msg_ok ".env files created (main + dev)"
|
||||
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="theme-color" content="#0b0f19" />
|
||||
<title>GhostGrid</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
24
public/favicon.svg
Normal file
24
public/favicon.svg
Normal file
@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="22" fill="#0b0f19"/>
|
||||
<!-- ghost body -->
|
||||
<path d="M 24,78 C 18,65 14,35 34,22 C 48,12 62,15 68,26" stroke="#06b6d4" stroke-width="4" stroke-linecap="round" fill="none"/>
|
||||
<path d="M 24,78 C 26,83 31,81 35,74 C 38,68 41,74 45,77 C 48,79 50,70 52,65" stroke="#06b6d4" stroke-width="4" stroke-linecap="round" fill="none"/>
|
||||
<!-- eyes -->
|
||||
<rect x="38" y="32" width="6" height="13" rx="3" fill="#00f0ff"/>
|
||||
<rect x="52" y="32" width="6" height="13" rx="3" fill="#00f0ff"/>
|
||||
<!-- network grid -->
|
||||
<line x1="56" y1="38" x2="88" y2="38" stroke="#06b6d4" stroke-width="2.5"/>
|
||||
<line x1="46" y1="62" x2="84" y2="62" stroke="#06b6d4" stroke-width="2.5"/>
|
||||
<line x1="56" y1="20" x2="56" y2="80" stroke="#06b6d4" stroke-width="2.5"/>
|
||||
<line x1="68" y1="15" x2="68" y2="76" stroke="#0891b2" stroke-width="2"/>
|
||||
<line x1="80" y1="26" x2="80" y2="62" stroke="#06b6d4" stroke-width="2"/>
|
||||
<!-- nodes -->
|
||||
<circle cx="56" cy="26" r="4" fill="#00f0ff"/>
|
||||
<circle cx="68" cy="26" r="4" fill="#00f0ff"/>
|
||||
<circle cx="80" cy="26" r="4" fill="#00f0ff"/>
|
||||
<circle cx="56" cy="38" r="4" fill="#00f0ff"/>
|
||||
<circle cx="80" cy="38" r="5" fill="#38bdf8"/>
|
||||
<circle cx="88" cy="38" r="4" fill="#00f0ff"/>
|
||||
<circle cx="68" cy="62" r="5" fill="#38bdf8"/>
|
||||
<circle cx="80" cy="62" r="4" fill="#00f0ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
72
server-db.ts
72
server-db.ts
@ -1,7 +1,10 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
|
||||
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}`);
|
||||
const db = new Database(DB_FILE);
|
||||
@ -86,15 +89,20 @@ db.exec(`
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS caddy (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hostname TEXT NOT NULL,
|
||||
upstream TEXT NOT NULL,
|
||||
tls INTEGER NOT NULL DEFAULT 1,
|
||||
compress INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hostname TEXT NOT NULL,
|
||||
upstream TEXT NOT NULL,
|
||||
tls INTEGER NOT NULL DEFAULT 1,
|
||||
compress INTEGER NOT NULL DEFAULT 1,
|
||||
redirect TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
// Idempotent migrations — SQLite throws on duplicate column; the catch makes them safe to re-run.
|
||||
try { db.prepare("ALTER TABLE labs ADD COLUMN scope TEXT NOT NULL DEFAULT 'global'").run(); } catch {}
|
||||
try { db.prepare("ALTER TABLE labs ADD COLUMN ownerId TEXT NOT NULL DEFAULT ''").run(); } catch {}
|
||||
|
||||
// Seed default settings — INSERT OR IGNORE writes a key only if it is absent.
|
||||
const DEFAULT_SETTINGS: Record<string, string> = {
|
||||
azure_enabled: 'false',
|
||||
@ -113,7 +121,7 @@ const DEFAULT_SETTINGS: Record<string, string> = {
|
||||
semaphore_api_token: '',
|
||||
semaphore_project_id: '',
|
||||
caddy_enabled: 'false',
|
||||
caddy_admin_url: 'http://localhost:2019',
|
||||
caddy_admin_url: 'http://127.0.0.1:2019',
|
||||
};
|
||||
|
||||
const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
|
||||
@ -133,24 +141,64 @@ export function getAllSettings(): Record<string, string> {
|
||||
return Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
}
|
||||
|
||||
const insertLog = db.prepare(
|
||||
'INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
|
||||
/**
|
||||
* Append a logbook entry. `deviceId`/`userId` default to NULL; `timestamp`
|
||||
* defaults to now (pass one to share a single timestamp across a batch).
|
||||
* Returns the generated log id.
|
||||
*/
|
||||
export function addLog(
|
||||
type: string,
|
||||
message: string,
|
||||
opts: { deviceId?: string | null; userId?: string | null; timestamp?: string } = {},
|
||||
): string {
|
||||
const id = uid('log');
|
||||
insertLog.run(id, opts.timestamp ?? new Date().toISOString(), type, message, opts.deviceId ?? null, opts.userId ?? null);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** A reverse-proxy route as stored (booleans are SQLite 0/1 integers). */
|
||||
export interface CaddyRoute {
|
||||
id: number;
|
||||
hostname: string;
|
||||
upstream: string;
|
||||
tls: number;
|
||||
compress: number;
|
||||
redirect: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Fields a caller supplies to create or update a route (JS booleans). */
|
||||
export interface CaddyRouteInput {
|
||||
hostname: string;
|
||||
upstream: string;
|
||||
tls: boolean;
|
||||
compress: boolean;
|
||||
redirect?: string;
|
||||
}
|
||||
|
||||
export function getCaddyRoutes(): CaddyRoute[] {
|
||||
return db.prepare('SELECT * FROM caddy ORDER BY id ASC').all() as CaddyRoute[];
|
||||
}
|
||||
|
||||
export function addCaddyRoute(hostname: string, upstream: string, tls: boolean, compress: boolean): CaddyRoute {
|
||||
export function getCaddyRouteById(id: number): CaddyRoute | undefined {
|
||||
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute | undefined;
|
||||
}
|
||||
|
||||
export function addCaddyRoute(route: CaddyRouteInput): CaddyRoute {
|
||||
const { lastInsertRowid } = db.prepare(
|
||||
'INSERT INTO caddy (hostname, upstream, tls, compress) VALUES (?, ?, ?, ?)'
|
||||
).run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0);
|
||||
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(lastInsertRowid) as CaddyRoute;
|
||||
'INSERT INTO caddy (hostname, upstream, tls, compress, redirect) VALUES (?, ?, ?, ?, ?)',
|
||||
).run(route.hostname, route.upstream, route.tls ? 1 : 0, route.compress ? 1 : 0, route.redirect ?? '');
|
||||
return getCaddyRouteById(Number(lastInsertRowid))!;
|
||||
}
|
||||
|
||||
export function updateCaddyRoute(id: number, route: CaddyRouteInput): CaddyRoute {
|
||||
db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ?, redirect = ? WHERE id = ?')
|
||||
.run(route.hostname, route.upstream, route.tls ? 1 : 0, route.compress ? 1 : 0, route.redirect ?? '', id);
|
||||
return getCaddyRouteById(id)!;
|
||||
}
|
||||
|
||||
export function deleteCaddyRoute(id: number): void {
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
428
server.ts
428
server.ts
@ -1,18 +1,24 @@
|
||||
import 'dotenv/config';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { createServer as createViteServer } from 'vite';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import DatabaseConstructor from 'better-sqlite3';
|
||||
import { ConfidentialClientApplication } from '@azure/msal-node';
|
||||
import db, { getSetting, setSetting, getAllSettings, getCaddyRoutes, addCaddyRoute, deleteCaddyRoute } from './server-db';
|
||||
import db, { uid, addLog, getSetting, setSetting, getAllSettings, getCaddyRoutes, addCaddyRoute, updateCaddyRoute, deleteCaddyRoute, getCaddyRouteById, DB_FILE } from './server-db';
|
||||
import { runMigrations } from './server-migrations';
|
||||
import { Device, LabTemplate, Booking, LogEntry, User, QuickLink } from './src/types';
|
||||
|
||||
const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'ghostgrid-dev-secret-change-in-production';
|
||||
const JWT_EXPIRY = '24h';
|
||||
|
||||
// DEPLOY_ENV=production marks the primary instance: it owns Caddy (pushes config,
|
||||
// seeds routes, accepts route edits) and shows "Production" in the UI. The dev
|
||||
// instance must never push to Caddy — POST /load replaces the entire config.
|
||||
const IS_PRODUCTION = process.env.DEPLOY_ENV === 'production';
|
||||
|
||||
interface JwtPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
@ -80,16 +86,72 @@ function buildCaddyfile(): string {
|
||||
lines.push(`${route.hostname} {`);
|
||||
if (route.compress) lines.push(' encode zstd gzip');
|
||||
if (route.tls) lines.push(' tls internal');
|
||||
lines.push(` reverse_proxy ${route.upstream}`);
|
||||
if (route.redirect) {
|
||||
// Redirect only the bare root ('/') to the given path — other paths pass
|
||||
// through to the backend unchanged (e.g. CheckMK at /<site>/check_mk/).
|
||||
const target = route.redirect.startsWith('/') ? route.redirect : `/${route.redirect}`;
|
||||
lines.push(` redir / ${target}`);
|
||||
}
|
||||
lines.push(` reverse_proxy ${route.upstream} {`);
|
||||
// Standard forwarding headers for every backend. Caddy already sets the
|
||||
// X-Forwarded-* family and the Host header by default; these make them
|
||||
// explicit and add X-Real-IP (nginx convention) for backends that expect it.
|
||||
lines.push(' header_up X-Forwarded-Proto {scheme}');
|
||||
lines.push(' header_up X-Real-IP {remote_host}');
|
||||
lines.push(' header_up Host {host}');
|
||||
if (/^https:\/\//i.test(route.upstream)) {
|
||||
// HTTPS upstream - connect over TLS and skip certificate
|
||||
// verification, since such backends typically use a self-signed cert.
|
||||
lines.push(' transport http {');
|
||||
lines.push(' tls_insecure_skip_verify');
|
||||
lines.push(' }');
|
||||
}
|
||||
lines.push(' }');
|
||||
lines.push('}', '');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function importCaddyfileRoutes(userId?: string): void {
|
||||
if (getCaddyRoutes().length > 0) return;
|
||||
const caddyfilePath = '/etc/caddy/Caddyfile';
|
||||
if (!fs.existsSync(caddyfilePath)) return;
|
||||
const lines = fs.readFileSync(caddyfilePath, 'utf-8').split('\n');
|
||||
const imported: string[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i].trim();
|
||||
const headerMatch = line.match(/^(\S+)\s*\{$/);
|
||||
if (headerMatch && headerMatch[1] !== '{') {
|
||||
const hostname = headerMatch[1];
|
||||
const blockLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && lines[i].trim() !== '}') {
|
||||
blockLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
const block = blockLines.join('\n');
|
||||
const upstreamMatch = block.match(/reverse_proxy\s+(\S+)/);
|
||||
if (upstreamMatch) {
|
||||
const upstream = upstreamMatch[1];
|
||||
const tls = /tls\s+internal/.test(block);
|
||||
const compress = /encode/.test(block);
|
||||
addCaddyRoute({ hostname, upstream, tls, compress });
|
||||
imported.push(`${hostname} → ${upstream}`);
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (imported.length > 0) {
|
||||
addLog('system', `Caddy: imported ${imported.length} route(s) from Caddyfile — ${imported.join(', ')}`, { userId });
|
||||
}
|
||||
}
|
||||
|
||||
async function pushCaddyConfig(): Promise<void> {
|
||||
if (!IS_PRODUCTION) return;
|
||||
if (getSetting('caddy_enabled') !== 'true') return;
|
||||
const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019';
|
||||
const adminUrl = getSetting('caddy_admin_url') || 'http://127.0.0.1:2019';
|
||||
const body = buildCaddyfile();
|
||||
const res = await fetch(`${adminUrl}/load`, {
|
||||
method: 'POST',
|
||||
@ -103,11 +165,32 @@ async function pushCaddyConfig(): Promise<void> {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
runMigrations(db);
|
||||
|
||||
const app = express();
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
|
||||
const { cnt } = db.prepare('SELECT COUNT(*) as cnt FROM users').get() as { cnt: number };
|
||||
if (cnt === 0) {
|
||||
const passwordHash = bcrypt.hashSync('admin', 10);
|
||||
db.prepare('INSERT INTO users (id, name, role, email, password_hash) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(uid('u'), 'admin', 'admin', 'admin@ghostgrid.local', passwordHash);
|
||||
console.log('[Init] Default admin user created — login: admin@ghostgrid.local / admin');
|
||||
}
|
||||
|
||||
if (IS_PRODUCTION && getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) {
|
||||
importCaddyfileRoutes();
|
||||
}
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// API responses must never be cached by the browser — otherwise a stale
|
||||
// (or HTML fallback) response can get served from cache via a 304.
|
||||
app.use('/api', (_req, res, next) => {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
next();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// AUTH API
|
||||
// -------------------------------------------------------------
|
||||
@ -156,9 +239,7 @@ async function startServer() {
|
||||
const token = jwt.sign({ userId: row.id, email: row.email }, JWT_SECRET, { expiresIn: JWT_EXPIRY });
|
||||
const user: User = { id: row.id, name: row.name, role: row.role, email: row.email };
|
||||
|
||||
const logId = uid("log");
|
||||
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
.run(logId, new Date().toISOString(), 'system', `${row.name} logged in.`, null, row.id);
|
||||
addLog('system', `${row.name} logged in.`, { userId: row.id });
|
||||
|
||||
res.json({ token, user });
|
||||
} catch (err: any) {
|
||||
@ -184,14 +265,16 @@ async function startServer() {
|
||||
const clientId = getSetting('azure_client_id');
|
||||
const tenantId = getSetting('azure_tenant_id');
|
||||
const secret = getSetting('azure_client_secret');
|
||||
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`;
|
||||
const appUrl = process.env.APP_URL || `http://127.0.0.1:${PORT}`;
|
||||
const effectiveRedirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
|
||||
const cmkApiUrl = getSetting('checkmk_api_url') || process.env.CHECKMK_API_URL || '';
|
||||
res.json({
|
||||
azureEnabled: enabled && Boolean(clientId) && Boolean(tenantId) && Boolean(secret),
|
||||
effectiveRedirectUri,
|
||||
checkmkEnabled: getSetting('checkmk_enabled') === 'true',
|
||||
semaphoreEnabled: getSetting('semaphore_enabled') === 'true',
|
||||
checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''),
|
||||
isProduction: IS_PRODUCTION,
|
||||
});
|
||||
});
|
||||
|
||||
@ -201,7 +284,7 @@ async function startServer() {
|
||||
if (!msalClient) {
|
||||
return res.redirect('/?auth_error=Azure+login+not+configured');
|
||||
}
|
||||
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`;
|
||||
const appUrl = process.env.APP_URL || `http://127.0.0.1:${PORT}`;
|
||||
const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
|
||||
try {
|
||||
const authCodeUrl = await msalClient.getAuthCodeUrl({
|
||||
@ -229,7 +312,7 @@ async function startServer() {
|
||||
if (!msalClient) {
|
||||
return res.redirect('/?auth_error=Azure+login+not+configured');
|
||||
}
|
||||
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`;
|
||||
const appUrl = process.env.APP_URL || `http://127.0.0.1:${PORT}`;
|
||||
const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
|
||||
try {
|
||||
const result = await msalClient.acquireTokenByCode({
|
||||
@ -257,6 +340,7 @@ async function startServer() {
|
||||
user = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User;
|
||||
}
|
||||
const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { expiresIn: JWT_EXPIRY });
|
||||
addLog('system', `${user.name} logged in via Microsoft.`, { userId: user.id });
|
||||
res.redirect(`/?token=${encodeURIComponent(token)}`);
|
||||
} catch (err: any) {
|
||||
console.error('[Azure Auth] acquireTokenByCode error:', err);
|
||||
@ -283,11 +367,15 @@ async function startServer() {
|
||||
'semaphore_enabled', 'semaphore_api_url', 'semaphore_api_token', 'semaphore_project_id',
|
||||
'caddy_enabled', 'caddy_admin_url'];
|
||||
const updates = req.body as Record<string, string>;
|
||||
const caddyWasEnabled = getSetting('caddy_enabled') === 'true';
|
||||
for (const key of allowed) {
|
||||
if (key in updates && updates[key] !== '__SET__') {
|
||||
setSetting(key, String(updates[key]));
|
||||
}
|
||||
}
|
||||
if (!caddyWasEnabled && updates.caddy_enabled === 'true') {
|
||||
importCaddyfileRoutes(req.user!.userId);
|
||||
}
|
||||
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after settings save:', err.message));
|
||||
res.json(maskSettings(getAllSettings()));
|
||||
} catch (err: any) {
|
||||
@ -321,6 +409,29 @@ async function startServer() {
|
||||
db.prepare('UPDATE users SET name = COALESCE(?, name), email = COALESCE(?, email) WHERE id = ?')
|
||||
.run(name ?? null, email ?? null, id);
|
||||
const updated = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User;
|
||||
const changes: string[] = [];
|
||||
if (name && name !== existing.name) changes.push(`name "${existing.name}" → "${name}"`);
|
||||
if (email && email !== existing.email) changes.push(`email "${existing.email}" → "${email}"`);
|
||||
if (changes.length > 0) {
|
||||
addLog('system', `User ${updated.email} updated: ${changes.join(', ')}.`, { userId: req.user!.userId });
|
||||
}
|
||||
res.json(updated);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/users/:id/role', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const id = req.params.id;
|
||||
const { role } = req.body as { role: string };
|
||||
const safeRole = role?.toLowerCase() === 'admin' ? 'admin' : 'User';
|
||||
const existing = db.prepare('SELECT id, name, email, role FROM users WHERE id = ?').get(id) as User | undefined;
|
||||
if (!existing) return res.status(404).json({ error: 'User not found.' });
|
||||
if (existing.role === safeRole) return res.json(existing);
|
||||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(safeRole, id);
|
||||
const updated = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User;
|
||||
addLog('system', `User ${updated.email} role changed to ${safeRole}.`, { userId: req.user!.userId });
|
||||
res.json(updated);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -367,11 +478,9 @@ async function startServer() {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, hostname, ip, location || '', notes || '', type, status || 'unknown', emergencySheet || '', lastCheckedAt || null);
|
||||
|
||||
const logId = uid("log");
|
||||
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
.run(logId, new Date().toISOString(), 'maintenance',
|
||||
`Provisioned a new host candidate "${hostname}" (${ip}) inside location ${location || 'Inventory'}.`,
|
||||
id, req.user!.userId);
|
||||
addLog('maintenance',
|
||||
`Provisioned a new host candidate "${hostname}" (${ip}) inside location ${location || 'Inventory'}.`,
|
||||
{ deviceId: id, userId: req.user!.userId });
|
||||
|
||||
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
|
||||
res.status(201).json(device);
|
||||
@ -390,11 +499,10 @@ async function startServer() {
|
||||
WHERE id = ?
|
||||
`).run(hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt ?? null, id);
|
||||
|
||||
const logId = uid("log");
|
||||
const operatorText = operatorName ? `${operatorName} finished ` : 'Updated ';
|
||||
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
.run(logId, new Date().toISOString(), 'maintenance',
|
||||
`${operatorText}refining the device specifications for "${hostname}".`, id, req.user!.userId);
|
||||
addLog('maintenance',
|
||||
`${operatorText}refining the device specifications for "${hostname}".`,
|
||||
{ deviceId: id, userId: req.user!.userId });
|
||||
|
||||
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
|
||||
res.json(device);
|
||||
@ -423,11 +531,9 @@ async function startServer() {
|
||||
);
|
||||
}
|
||||
|
||||
const logId = uid("log");
|
||||
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
.run(logId, new Date().toISOString(), 'maintenance',
|
||||
`Permanently removed the host device "${dev.hostname || id}" from the inventory records.`,
|
||||
null, req.user!.userId);
|
||||
addLog('maintenance',
|
||||
`Permanently removed the host device "${dev.hostname || id}" from the inventory records.`,
|
||||
{ userId: req.user!.userId });
|
||||
|
||||
res.json({ success: true, message: 'Device deleted successfully and cleaned from topologies.' });
|
||||
} catch (err: any) {
|
||||
@ -447,6 +553,8 @@ async function startServer() {
|
||||
deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology),
|
||||
semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '',
|
||||
semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '',
|
||||
scope: (r.scope === 'personal' ? 'personal' : 'global') as 'global' | 'personal',
|
||||
ownerId: r.ownerId ?? '',
|
||||
}));
|
||||
res.json(labs);
|
||||
} catch (err: any) {
|
||||
@ -456,43 +564,54 @@ async function startServer() {
|
||||
|
||||
app.post('/api/labs', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId } = req.body;
|
||||
const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId, scope } = req.body;
|
||||
if (!name || !deviceIds || !Array.isArray(deviceIds)) {
|
||||
return res.status(400).json({ error: 'Missing name or associated device configurations.' });
|
||||
}
|
||||
|
||||
const safeScope: 'global' | 'personal' = scope === 'personal' ? 'personal' : 'global';
|
||||
const ownerId = req.user!.userId;
|
||||
const id = uid("lab");
|
||||
db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '');
|
||||
db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId, scope, ownerId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', safeScope, ownerId);
|
||||
|
||||
const logId = uid("log");
|
||||
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(logId, new Date().toISOString(), 'maintenance',
|
||||
`Released a new lab template profile "${name}" (${location || 'Staging Area'}) for engineering teams.`,
|
||||
req.user!.userId);
|
||||
addLog('maintenance',
|
||||
`Released a new lab template profile "${name}" (${location || 'Staging Area'}) for engineering teams.`,
|
||||
{ userId: req.user!.userId });
|
||||
|
||||
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
|
||||
res.status(201).json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '' });
|
||||
res.status(201).json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', scope: r.scope, ownerId: r.ownerId });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/labs/:id', requireAuth, (req, res) => {
|
||||
app.put('/api/labs/:id', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const id = req.params.id;
|
||||
const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId } = req.body;
|
||||
const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId, scope } = req.body;
|
||||
|
||||
db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ?, semaphoreSetupTemplateId = ?, semaphoreTeardownTemplateId = ? WHERE id = ?`)
|
||||
.run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', id);
|
||||
const existing = db.prepare('SELECT ownerId, scope FROM labs WHERE id = ?').get(id) as any;
|
||||
if (!existing) return res.status(404).json({ error: 'Lab template not found.' });
|
||||
|
||||
const logId = uid("log");
|
||||
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(logId, new Date().toISOString(), 'maintenance',
|
||||
`Modified the active topology mapping schema for the "${name}" lab template.`, req.user!.userId);
|
||||
const reqUser = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as any;
|
||||
const isAdmin = reqUser?.role?.toLowerCase() === 'admin';
|
||||
const isOwner = existing.ownerId === req.user!.userId;
|
||||
const isLegacy = existing.ownerId === '';
|
||||
if (!isOwner && !isAdmin && !isLegacy) {
|
||||
return res.status(403).json({ error: 'You do not have permission to edit this topology.' });
|
||||
}
|
||||
|
||||
const safeScope: 'global' | 'personal' = scope === 'personal' ? 'personal' : 'global';
|
||||
db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ?, semaphoreSetupTemplateId = ?, semaphoreTeardownTemplateId = ?, scope = ? WHERE id = ?`)
|
||||
.run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', safeScope, id);
|
||||
|
||||
addLog('maintenance',
|
||||
`Modified the active topology mapping schema for the "${name}" lab template.`,
|
||||
{ userId: req.user!.userId });
|
||||
|
||||
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
|
||||
res.json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '' });
|
||||
res.json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', scope: r.scope, ownerId: r.ownerId });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
@ -504,13 +623,20 @@ async function startServer() {
|
||||
const lab = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
|
||||
if (!lab) return res.status(404).json({ error: 'Lab template not found.' });
|
||||
|
||||
const reqUser = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as any;
|
||||
const isAdmin = reqUser?.role?.toLowerCase() === 'admin';
|
||||
const isOwner = lab.ownerId === req.user!.userId;
|
||||
const isLegacy = lab.ownerId === '';
|
||||
if (!isOwner && !isAdmin && !isLegacy) {
|
||||
return res.status(403).json({ error: 'You do not have permission to delete this topology.' });
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM labs WHERE id = ?').run(id);
|
||||
db.prepare(`UPDATE bookings SET status = 'cancelled' WHERE labId = ? AND status = 'upcoming'`).run(id);
|
||||
|
||||
const logId = uid("log");
|
||||
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(logId, new Date().toISOString(), 'booking',
|
||||
`Withdrew the lab testing template "${lab.name || id}".`, req.user!.userId);
|
||||
addLog('booking',
|
||||
`Withdrew the lab testing template "${lab.name || id}".`,
|
||||
{ userId: req.user!.userId });
|
||||
|
||||
res.json({ success: true, message: 'Lab template deleted and future reservations cancelled.' });
|
||||
} catch (err: any) {
|
||||
@ -552,14 +678,13 @@ async function startServer() {
|
||||
.run(id, labId, userId, startDateTime, endDateTime, notes || '', status || 'upcoming');
|
||||
|
||||
const lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(labId) as { name: string } | undefined;
|
||||
const logId = uid("log");
|
||||
const operatorText = operatorName || 'An operator';
|
||||
const startF = new Date(startDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
const endF = new Date(endDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(logId, new Date().toISOString(), 'booking',
|
||||
`${operatorText} booked the lab template "${lab?.name || 'Unknown'}" from ${startF} to ${endF}.`, userId);
|
||||
addLog('booking',
|
||||
`${operatorText} booked the lab template "${lab?.name || 'Unknown'}" from ${startF} to ${endF}.`,
|
||||
{ userId });
|
||||
|
||||
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
|
||||
res.status(201).json({
|
||||
@ -583,11 +708,9 @@ async function startServer() {
|
||||
|
||||
if (status === 'cancelled') {
|
||||
const lab = db.prepare('SELECT name, semaphoreTeardownTemplateId FROM labs WHERE id = ?').get(booking.labId) as { name: string; semaphoreTeardownTemplateId: string } | undefined;
|
||||
const logId = uid("log");
|
||||
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(logId, new Date().toISOString(), 'booking',
|
||||
`${operatorName || 'An operator'} canceled the reservation for lab template "${lab?.name || 'Unknown'}" and released associated nodes back into the pool.`,
|
||||
req.user!.userId);
|
||||
addLog('booking',
|
||||
`${operatorName || 'An operator'} canceled the reservation for lab template "${lab?.name || 'Unknown'}" and released associated nodes back into the pool.`,
|
||||
{ userId: req.user!.userId });
|
||||
|
||||
// Trigger teardown if booking had already started and teardown not yet triggered
|
||||
const now = new Date();
|
||||
@ -628,10 +751,9 @@ async function startServer() {
|
||||
db.prepare('DELETE FROM bookings WHERE id = ?').run(id);
|
||||
const lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(booking.labId) as { name: string } | undefined;
|
||||
|
||||
const logId = uid("log");
|
||||
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(logId, new Date().toISOString(), 'booking',
|
||||
`Permanently deleted the reservation records for lab "${lab?.name || 'Unknown'}".`, req.user!.userId);
|
||||
addLog('booking',
|
||||
`Permanently deleted the reservation records for lab "${lab?.name || 'Unknown'}".`,
|
||||
{ userId: req.user!.userId });
|
||||
|
||||
res.json({ success: true, message: 'Reservation and its logs have been resolved correctly.' });
|
||||
} catch (err: any) {
|
||||
@ -658,9 +780,7 @@ async function startServer() {
|
||||
return res.status(400).json({ error: 'Missing log message or classification type.' });
|
||||
}
|
||||
|
||||
const id = uid("log");
|
||||
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
.run(id, new Date().toISOString(), type, message, deviceId || null, userId || null);
|
||||
const id = addLog(type, message, { deviceId: deviceId || null, userId: userId || null });
|
||||
|
||||
const log = db.prepare('SELECT * FROM logs WHERE id = ?').get(id) as LogEntry;
|
||||
res.status(201).json(log);
|
||||
@ -731,21 +851,89 @@ async function startServer() {
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// VITE / STATIC SERVING
|
||||
// DATABASE API
|
||||
// -------------------------------------------------------------
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const vite = await createViteServer({
|
||||
server: { middlewareMode: true },
|
||||
appType: 'spa',
|
||||
});
|
||||
app.use(vite.middlewares);
|
||||
} else {
|
||||
const distPath = path.join(process.cwd(), 'dist');
|
||||
app.use(express.static(distPath));
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
app.get('/api/database/info', requireAuth, (_req, res) => {
|
||||
try {
|
||||
const stats = fs.statSync(DB_FILE);
|
||||
const tables = ['users', 'devices', 'labs', 'bookings', 'logs', 'links', 'settings', 'caddy'];
|
||||
const counts: Record<string, number> = {};
|
||||
for (const t of tables) {
|
||||
counts[t] = (db.prepare(`SELECT COUNT(*) as n FROM "${t}"`).get() as { n: number }).n;
|
||||
}
|
||||
res.json({
|
||||
sizeBytes: stats.size,
|
||||
lastModified: stats.mtime.toISOString(),
|
||||
tables: counts,
|
||||
path: path.basename(DB_FILE),
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/database/backup', requireAuth, async (_req, res) => {
|
||||
const tempPath = `${DB_FILE}.backup-${Date.now()}`;
|
||||
try {
|
||||
await db.backup(tempPath);
|
||||
const filename = `ghostgrid-backup-${new Date().toISOString().slice(0, 10)}.db`;
|
||||
res.download(tempPath, filename, () => {
|
||||
fs.unlink(tempPath, () => {});
|
||||
});
|
||||
} catch (err: any) {
|
||||
fs.unlink(tempPath, () => {});
|
||||
if (!res.headersSent) res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/database/import', requireAuth,
|
||||
express.raw({ type: 'application/octet-stream', limit: '50mb' }),
|
||||
(req, res) => {
|
||||
const tempPath = `${DB_FILE}.import-${Date.now()}`;
|
||||
try {
|
||||
const buf = req.body as Buffer;
|
||||
if (!Buffer.isBuffer(buf) || buf.length < 16) {
|
||||
return res.status(400).json({ error: 'No file data received.' });
|
||||
}
|
||||
// Validate SQLite magic header: "SQLite format 3\0"
|
||||
if (buf.slice(0, 16).toString('latin1') !== 'SQLite format 3\x00') {
|
||||
return res.status(400).json({ error: 'Not a valid SQLite database file.' });
|
||||
}
|
||||
fs.writeFileSync(tempPath, buf);
|
||||
|
||||
let importDb: InstanceType<typeof DatabaseConstructor> | null = null;
|
||||
try {
|
||||
importDb = new DatabaseConstructor(tempPath, { readonly: true });
|
||||
const tables = ['users', 'devices', 'labs', 'bookings', 'logs', 'links', 'settings', 'caddy'];
|
||||
|
||||
db.transaction(() => {
|
||||
for (const table of tables) {
|
||||
const schemaCols = (db.prepare(`PRAGMA table_info("${table}")`).all() as { name: string }[]).map(c => c.name);
|
||||
db.prepare(`DELETE FROM "${table}"`).run();
|
||||
let rows: Record<string, unknown>[] = [];
|
||||
try { rows = importDb!.prepare(`SELECT * FROM "${table}"`).all() as Record<string, unknown>[]; } catch { continue; }
|
||||
if (rows.length === 0) continue;
|
||||
const importCols = Object.keys(rows[0]);
|
||||
const cols = schemaCols.filter(c => importCols.includes(c));
|
||||
if (cols.length === 0) continue;
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO "${table}" (${cols.map(c => `"${c}"`).join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`
|
||||
);
|
||||
for (const row of rows) stmt.run(cols.map(c => row[c]));
|
||||
}
|
||||
})();
|
||||
|
||||
res.json({ ok: true });
|
||||
} finally {
|
||||
importDb?.close();
|
||||
fs.unlink(tempPath, () => {});
|
||||
}
|
||||
} catch (err: any) {
|
||||
fs.unlink(tempPath, () => {});
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// CYCLIC CHECKMK STATUS SYNC
|
||||
@ -771,8 +959,7 @@ async function startServer() {
|
||||
const CHECKMK_API_SECRET = getSetting('checkmk_api_secret') || process.env.CHECKMK_API_SECRET;
|
||||
|
||||
if (!CHECKMK_API_URL || !CHECKMK_API_SECRET) {
|
||||
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
||||
.run(uid('log'), now, 'system', 'CheckMK sync skipped — API URL or secret not configured. Check Settings.');
|
||||
addLog('system', 'CheckMK sync skipped — API URL or secret not configured. Check Settings.', { timestamp: now });
|
||||
return;
|
||||
}
|
||||
|
||||
@ -800,8 +987,7 @@ async function startServer() {
|
||||
} catch (err: any) {
|
||||
const msg = `CheckMK sync failed — could not fetch host list: ${err?.message ?? err}`;
|
||||
console.error('[CheckMK]', msg);
|
||||
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
||||
.run(uid('log'), now, 'system', msg);
|
||||
addLog('system', msg, { timestamp: now });
|
||||
return;
|
||||
}
|
||||
|
||||
@ -817,8 +1003,7 @@ async function startServer() {
|
||||
if (!cmkHost) {
|
||||
if (dev.status !== 'unknown') {
|
||||
db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ?, cmkHostname = ? WHERE id = ?').run('unknown', now, '', dev.id);
|
||||
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(uid('log'), now, 'status', `CheckMK: ${dev.hostname} (${dev.ip}) not found in monitoring — status set to unknown.`, dev.id);
|
||||
addLog('status', `CheckMK: ${dev.hostname} (${dev.ip}) not found in monitoring — status set to unknown.`, { deviceId: dev.id, timestamp: now });
|
||||
}
|
||||
counts.unknown++;
|
||||
continue;
|
||||
@ -835,22 +1020,20 @@ async function startServer() {
|
||||
const newStatus = state === 0 ? 'online' : state === 1 || state === 2 ? 'offline' : 'unknown';
|
||||
db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ?, cmkHostname = ? WHERE id = ?').run(newStatus, now, cmkHost, dev.id);
|
||||
if (dev.status !== newStatus) {
|
||||
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(uid('log'), now, 'status', `CheckMK: ${dev.hostname} (${dev.ip}) status changed to ${newStatus} (was: ${dev.status}).`, dev.id);
|
||||
addLog('status', `CheckMK: ${dev.hostname} (${dev.ip}) status changed to ${newStatus} (was: ${dev.status}).`, { deviceId: dev.id, timestamp: now });
|
||||
}
|
||||
counts[newStatus as 'online' | 'offline' | 'unknown']++;
|
||||
} catch (err: any) {
|
||||
const msg = `CheckMK: status sync failed for ${dev.hostname} (${dev.ip}) — ${err?.message ?? err}`;
|
||||
console.error('[CheckMK]', msg);
|
||||
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(uid('log'), now, 'system', msg, dev.id);
|
||||
addLog('system', msg, { deviceId: dev.id, timestamp: now });
|
||||
counts.unknown++;
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
||||
.run(uid('log'), now, 'system',
|
||||
`CheckMK sync completed — ${counts.online} online, ${counts.offline} offline, ${counts.unknown} unknown (${rows.length} devices total).`);
|
||||
addLog('system',
|
||||
`CheckMK sync completed — ${counts.online} online, ${counts.offline} offline, ${counts.unknown} unknown (${rows.length} devices total).`,
|
||||
{ timestamp: now });
|
||||
}
|
||||
|
||||
async function scheduleSync() {
|
||||
@ -876,14 +1059,12 @@ async function startServer() {
|
||||
// as CheckMK. Template IDs are configured per lab template.
|
||||
// -------------------------------------------------------------
|
||||
async function triggerSemaphoreTask(templateId: number, extraVars: Record<string, string>): Promise<number | null> {
|
||||
const now = new Date().toISOString();
|
||||
const apiUrl = getSetting('semaphore_api_url');
|
||||
const token = getSetting('semaphore_api_token');
|
||||
const projectId = getSetting('semaphore_project_id');
|
||||
|
||||
if (!apiUrl || !token || !projectId) {
|
||||
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
||||
.run(uid('log'), now, 'system', 'Semaphore trigger skipped — API URL, token, or project ID not configured. Check Settings.');
|
||||
addLog('system', 'Semaphore trigger skipped — API URL, token, or project ID not configured. Check Settings.');
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -906,14 +1087,12 @@ async function startServer() {
|
||||
}
|
||||
const data = await res.json() as { id?: number };
|
||||
const jobId = data?.id ?? null;
|
||||
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
||||
.run(uid('log'), now, 'booking', `Semaphore: triggered template #${templateId} > job #${jobId} (booking ${extraVars.booking_id}).`);
|
||||
addLog('booking', `Semaphore: triggered template #${templateId} > job #${jobId} (booking ${extraVars.booking_id}).`);
|
||||
return jobId;
|
||||
} catch (err: any) {
|
||||
const msg = `Semaphore trigger failed for template #${templateId} — ${err?.message ?? err}`;
|
||||
console.error('[Semaphore]', msg);
|
||||
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
|
||||
.run(uid('log'), now, 'system', msg);
|
||||
addLog('system', msg);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -1029,7 +1208,7 @@ async function startServer() {
|
||||
// -------------------------------------------------------------
|
||||
app.get('/api/caddy/status', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019';
|
||||
const adminUrl = getSetting('caddy_admin_url') || 'http://127.0.0.1:2019';
|
||||
const r = await fetch(`${adminUrl}/config/`, { signal: AbortSignal.timeout(2000) });
|
||||
res.json({ available: r.ok });
|
||||
} catch {
|
||||
@ -1039,7 +1218,7 @@ async function startServer() {
|
||||
|
||||
app.get('/api/caddy/routes', requireAuth, (_req, res) => {
|
||||
try {
|
||||
res.json({ system: [], custom: getCaddyRoutes() });
|
||||
res.json(getCaddyRoutes());
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
@ -1047,11 +1226,15 @@ async function startServer() {
|
||||
|
||||
app.post('/api/caddy/routes', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { hostname, upstream, tls, compress } = req.body as {
|
||||
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean;
|
||||
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
|
||||
const { hostname, upstream, tls, compress, redirect } = req.body as {
|
||||
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirect?: string;
|
||||
};
|
||||
if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' });
|
||||
const route = addCaddyRoute(hostname.trim(), upstream.trim(), tls !== false, compress !== false);
|
||||
if (getCaddyRoutes().some(r => r.hostname === hostname.trim()))
|
||||
return res.status(409).json({ error: `Route for ${hostname.trim()} already exists.` });
|
||||
const route = addCaddyRoute({ hostname: hostname.trim(), upstream: upstream.trim(), tls: tls !== false, compress: compress !== false, redirect: (redirect ?? '').trim() });
|
||||
addLog('system', `Caddy route added: ${hostname.trim()} → ${upstream.trim()}`, { userId: req.user!.userId });
|
||||
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route add:', err.message));
|
||||
res.json(route);
|
||||
} catch (err: any) {
|
||||
@ -1059,11 +1242,34 @@ async function startServer() {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/caddy/routes/:id', requireAuth, async (req, res) => {
|
||||
try {
|
||||
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
|
||||
const id = Number(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: 'Invalid route id.' });
|
||||
const { hostname, upstream, tls, compress, redirect } = req.body as {
|
||||
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirect?: string;
|
||||
};
|
||||
if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' });
|
||||
const route = updateCaddyRoute(id, { hostname: hostname.trim(), upstream: upstream.trim(), tls: tls !== false, compress: compress !== false, redirect: (redirect ?? '').trim() });
|
||||
addLog('system', `Caddy route updated: ${hostname.trim()} → ${upstream.trim()}`, { userId: req.user!.userId });
|
||||
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route update:', err.message));
|
||||
res.json(route);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
|
||||
const id = Number(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: 'Invalid route id.' });
|
||||
const existing = getCaddyRouteById(id);
|
||||
deleteCaddyRoute(id);
|
||||
if (existing) {
|
||||
addLog('system', `Caddy route deleted: ${existing.hostname} → ${existing.upstream}`, { userId: req.user!.userId });
|
||||
}
|
||||
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route delete:', err.message));
|
||||
res.status(204).send();
|
||||
} catch (err: any) {
|
||||
@ -1073,6 +1279,24 @@ async function startServer() {
|
||||
|
||||
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config on startup:', err.message));
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// VITE / STATIC SERVING — registered LAST so the SPA catch-all ('*')
|
||||
// never shadows the /api routes registered above it.
|
||||
// -------------------------------------------------------------
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const vite = await createViteServer({
|
||||
server: { middlewareMode: true },
|
||||
appType: 'spa',
|
||||
});
|
||||
app.use(vite.middlewares);
|
||||
} else {
|
||||
const distPath = path.join(process.cwd(), 'dist');
|
||||
app.use(express.static(distPath));
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`[Server] Core Server running at http://0.0.0.0:${PORT}`);
|
||||
});
|
||||
|
||||
21
src/App.tsx
21
src/App.tsx
@ -55,6 +55,8 @@ export default function App() {
|
||||
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null);
|
||||
const [checkmkEnabled, setCheckmkEnabled] = useState(false);
|
||||
const [checkmkBaseUrl, setCheckmkBaseUrl] = useState('');
|
||||
const [isProduction, setIsProduction] = useState(false);
|
||||
const [semaphoreEnabled, setSemaphoreEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
@ -143,7 +145,7 @@ export default function App() {
|
||||
if (bookingsRes.ok) setBookings(await bookingsRes.json());
|
||||
if (logsRes.ok) setLogs(await logsRes.json());
|
||||
if (linksRes.ok) setLinks(await linksRes.json());
|
||||
if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); setCheckmkBaseUrl(cfg.checkmkBaseUrl || ''); }
|
||||
if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); setCheckmkBaseUrl(cfg.checkmkBaseUrl || ''); setIsProduction(!!cfg.isProduction); setSemaphoreEnabled(!!cfg.semaphoreEnabled); }
|
||||
} catch (err) {
|
||||
console.error('[App] Failed to load data:', err);
|
||||
} finally {
|
||||
@ -298,7 +300,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
// Lab handlers
|
||||
const handleAddLab = async (newLab: Omit<LabTemplate, 'id'>) => {
|
||||
const handleAddLab = async (newLab: Omit<LabTemplate, 'id' | 'ownerId'>) => {
|
||||
try {
|
||||
const res = await authFetch('/api/labs', { method: 'POST', body: JSON.stringify(newLab) });
|
||||
if (res.ok) {
|
||||
@ -360,6 +362,17 @@ export default function App() {
|
||||
} catch (err: any) { throw err; }
|
||||
};
|
||||
|
||||
const handleSetUserRole = async (id: string, role: string) => {
|
||||
try {
|
||||
const res = await authFetch(`/api/users/${id}/role`, { method: 'PATCH', body: JSON.stringify({ role }) });
|
||||
if (res.ok) {
|
||||
const updated: User = await res.json();
|
||||
setUsers(prev => prev.map(u => u.id === id ? updated : u));
|
||||
if (updated.id === currentUser?.id) setCurrentUser(updated);
|
||||
} else { const d = await res.json(); throw new Error(d.error); }
|
||||
} catch (err: any) { throw err; }
|
||||
};
|
||||
|
||||
// Quick-link handlers (shared link dashboard)
|
||||
const handleAddLink = async (newLink: Omit<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => {
|
||||
try {
|
||||
@ -484,6 +497,7 @@ export default function App() {
|
||||
theme={theme}
|
||||
onThemeToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
|
||||
onLogout={handleLogout}
|
||||
isProduction={isProduction}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col md:flex-row">
|
||||
@ -595,6 +609,8 @@ export default function App() {
|
||||
<LabTemplates
|
||||
labs={labs}
|
||||
devices={devices}
|
||||
currentUser={currentUser!}
|
||||
semaphoreEnabled={semaphoreEnabled}
|
||||
onAddLab={handleAddLab}
|
||||
onUpdateLab={handleUpdateLab}
|
||||
onDeleteLab={handleDeleteLab}
|
||||
@ -617,6 +633,7 @@ export default function App() {
|
||||
bookings={bookings}
|
||||
onDeleteUser={handleDeleteUser}
|
||||
onUpdateUser={handleUpdateUser}
|
||||
onSetRole={handleSetUserRole}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'logs' && (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { Booking, LabTemplate, Device, User } from '../types';
|
||||
import {
|
||||
Calendar, Zap, CheckCircle2, AlertCircle, AlertTriangle, ChevronLeft, ChevronRight, Database,
|
||||
@ -172,11 +172,23 @@ export default function BookingCalendar({
|
||||
return { startMs: start.getTime(), endMs: end.getTime(), start, end };
|
||||
}, [quickDuration]);
|
||||
|
||||
const bookableLabs = useMemo(() => labs.filter(l =>
|
||||
l.scope === 'global' ||
|
||||
l.ownerId === currentUser.id ||
|
||||
currentUser.role?.toLowerCase() === 'admin'
|
||||
), [labs, currentUser.id, currentUser.role]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedLabId && !bookableLabs.find(l => l.id === selectedLabId)) {
|
||||
setSelectedLabId(bookableLabs[0]?.id || '');
|
||||
}
|
||||
}, [bookableLabs]);
|
||||
|
||||
// A lab is quick-bookable when every device is free (regardless of online status).
|
||||
const availableLabs = useMemo(() => labs.filter(lab =>
|
||||
const availableLabs = useMemo(() => bookableLabs.filter(lab =>
|
||||
lab.deviceIds.length > 0 &&
|
||||
lab.deviceIds.every(dId => !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs))
|
||||
), [labs, devices, bookings, quickWindow]);
|
||||
), [bookableLabs, devices, bookings, quickWindow]);
|
||||
|
||||
const availableDevices = useMemo(() => devices.filter(dev =>
|
||||
!isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs)
|
||||
@ -271,7 +283,7 @@ export default function BookingCalendar({
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 font-mono">
|
||||
{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} →{' '}
|
||||
{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} >{' '}
|
||||
{new Date(Date.now() + quickDuration * 3600_000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
@ -576,9 +588,20 @@ export default function BookingCalendar({
|
||||
onChange={(e) => setSelectedLabId(e.target.value)}
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
{labs.map((l) => (
|
||||
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
|
||||
))}
|
||||
{bookableLabs.filter(l => l.scope === 'global').length > 0 && (
|
||||
<optgroup label="Global Topologies">
|
||||
{bookableLabs.filter(l => l.scope === 'global').map((l) => (
|
||||
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{bookableLabs.filter(l => l.scope === 'personal').length > 0 && (
|
||||
<optgroup label="My Personal Topologies">
|
||||
{bookableLabs.filter(l => l.scope === 'personal').map((l) => (
|
||||
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
@ -656,7 +679,6 @@ export default function BookingCalendar({
|
||||
<textarea
|
||||
required
|
||||
rows={3}
|
||||
placeholder="e.g. Validating STP failover convergence times..."
|
||||
value={bookingNotes}
|
||||
onChange={(e) => setBookingNotes(e.target.value)}
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500"
|
||||
|
||||
@ -255,7 +255,6 @@ export default function DeviceInventory({
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by hostname, IP address, rack location..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none focus:border-emerald-500 transition-colors placeholder:text-slate-500"
|
||||
@ -497,7 +496,6 @@ Pick a box from the list to see its specs and break-glass playbook.
|
||||
value={formData.hostname}
|
||||
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono"
|
||||
placeholder="SW-CORE-03"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@ -508,7 +506,6 @@ Pick a box from the list to see its specs and break-glass playbook.
|
||||
value={formData.ip}
|
||||
onChange={(e) => setFormData({ ...formData, ip: e.target.value })}
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono"
|
||||
placeholder="172.16.x.x"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -522,7 +519,6 @@ Pick a box from the list to see its specs and break-glass playbook.
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
||||
placeholder="Server Room R02, Rack C4..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@ -554,7 +550,6 @@ Pick a box from the list to see its specs and break-glass playbook.
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as DeviceType })}
|
||||
className="w-full mt-2 bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
||||
placeholder="Enter new device class (e.g. Router, Load-Balancer)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -567,7 +562,6 @@ Pick a box from the list to see its specs and break-glass playbook.
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
||||
placeholder="Serial numbers, module slots, connected uplinks, license status..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ interface HeaderProps {
|
||||
theme: 'dark' | 'light';
|
||||
onThemeToggle: () => void;
|
||||
onLogout: () => void;
|
||||
isProduction: boolean;
|
||||
}
|
||||
|
||||
export default function Header({
|
||||
@ -22,6 +23,7 @@ export default function Header({
|
||||
theme,
|
||||
onThemeToggle,
|
||||
onLogout,
|
||||
isProduction,
|
||||
}: HeaderProps) {
|
||||
const [showMailInbox, setShowMailInbox] = useState(false);
|
||||
const [showBellDropdown, setShowBellDropdown] = useState(false);
|
||||
@ -63,8 +65,8 @@ export default function Header({
|
||||
|
||||
{/* System Indicator */}
|
||||
<div className="hidden md:flex items-center gap-2 px-3 py-1 bg-slate-800/60 rounded-full border border-slate-700/50 text-xs font-mono text-slate-300">
|
||||
<span className={`w-2 h-2 rounded-full animate-pulse ${import.meta.env.PROD ? 'bg-emerald-500' : 'bg-amber-400'}`} />
|
||||
<span>System: {import.meta.env.PROD ? 'Production' : 'Development'}</span>
|
||||
<span className={`w-2 h-2 rounded-full animate-pulse ${isProduction ? 'bg-emerald-500' : 'bg-amber-400'}`} />
|
||||
<span>System: {isProduction ? 'Production' : 'Development'}</span>
|
||||
</div>
|
||||
|
||||
{/* Mail Inbox */}
|
||||
|
||||
@ -4,17 +4,19 @@
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { LabTemplate, Device, TopologyLink } from '../types';
|
||||
import { LabTemplate, Device, TopologyLink, User } from '../types';
|
||||
import TopologyPanel from './TopologyPanel';
|
||||
import {
|
||||
Server, Plus, Edit3, Trash, User, MapPin,
|
||||
Layers, ChevronRight, Save, X, Check, Pencil, Terminal,
|
||||
Server, Plus, Edit3, Trash, User as UserIcon, MapPin,
|
||||
Layers, ChevronRight, X, Check, Pencil, Terminal, Lock, Globe,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface LabTemplatesProps {
|
||||
labs: LabTemplate[];
|
||||
devices: Device[];
|
||||
onAddLab: (lab: Omit<LabTemplate, 'id'>) => void;
|
||||
currentUser: User;
|
||||
semaphoreEnabled: boolean;
|
||||
onAddLab: (lab: Omit<LabTemplate, 'id' | 'ownerId'>) => void;
|
||||
onUpdateLab: (lab: LabTemplate) => void;
|
||||
onDeleteLab: (id: string) => void;
|
||||
onOpenDeviceDetails: (device: Device) => void;
|
||||
@ -23,6 +25,8 @@ interface LabTemplatesProps {
|
||||
export default function LabTemplates({
|
||||
labs,
|
||||
devices,
|
||||
currentUser,
|
||||
semaphoreEnabled,
|
||||
onAddLab,
|
||||
onUpdateLab,
|
||||
onDeleteLab,
|
||||
@ -49,6 +53,7 @@ export default function LabTemplates({
|
||||
deviceIds: string[];
|
||||
semaphoreSetupTemplateId: string;
|
||||
semaphoreTeardownTemplateId: string;
|
||||
scope: 'global' | 'personal';
|
||||
}>({
|
||||
name: '',
|
||||
description: '',
|
||||
@ -57,6 +62,7 @@ export default function LabTemplates({
|
||||
deviceIds: [],
|
||||
semaphoreSetupTemplateId: '',
|
||||
semaphoreTeardownTemplateId: '',
|
||||
scope: 'global',
|
||||
});
|
||||
|
||||
// Calculate filtered devices associated with selected lab
|
||||
@ -75,6 +81,7 @@ export default function LabTemplates({
|
||||
deviceIds: [],
|
||||
semaphoreSetupTemplateId: '',
|
||||
semaphoreTeardownTemplateId: '',
|
||||
scope: 'global',
|
||||
});
|
||||
setIsEditing(true);
|
||||
};
|
||||
@ -91,6 +98,7 @@ export default function LabTemplates({
|
||||
deviceIds: [...lab.deviceIds],
|
||||
semaphoreSetupTemplateId: lab.semaphoreSetupTemplateId || '',
|
||||
semaphoreTeardownTemplateId: lab.semaphoreTeardownTemplateId || '',
|
||||
scope: lab.scope ?? 'global',
|
||||
});
|
||||
setIsEditing(true);
|
||||
};
|
||||
@ -137,20 +145,107 @@ export default function LabTemplates({
|
||||
topology: tempLinks,
|
||||
semaphoreSetupTemplateId: formData.semaphoreSetupTemplateId,
|
||||
semaphoreTeardownTemplateId: formData.semaphoreTeardownTemplateId,
|
||||
scope: formData.scope,
|
||||
};
|
||||
|
||||
if (formMode === 'add') {
|
||||
onAddLab(savedLabData);
|
||||
} else if (formMode === 'edit' && formData.id) {
|
||||
const existing = labs.find(l => l.id === formData.id);
|
||||
onUpdateLab({
|
||||
...savedLabData,
|
||||
id: formData.id
|
||||
id: formData.id,
|
||||
ownerId: existing?.ownerId ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const isAdmin = currentUser.role?.toLowerCase() === 'admin';
|
||||
const canEdit = (lab: LabTemplate) => isAdmin || lab.ownerId === currentUser.id || lab.ownerId === '';
|
||||
|
||||
const myPersonalLabs = labs.filter(l => l.scope === 'personal' && l.ownerId === currentUser.id);
|
||||
const globalLabs = labs.filter(l => l.scope === 'global');
|
||||
const othersPersonal = isAdmin ? labs.filter(l => l.scope === 'personal' && l.ownerId !== currentUser.id) : [];
|
||||
|
||||
const renderLabCard = (lab: LabTemplate) => {
|
||||
const isSelected = selectedLab?.id === lab.id;
|
||||
const editable = canEdit(lab);
|
||||
return (
|
||||
<div
|
||||
key={lab.id}
|
||||
onClick={() => setSelectedLab(lab)}
|
||||
className={`p-4 rounded-xl border transition-all cursor-pointer relative ${
|
||||
isSelected
|
||||
? 'bg-slate-900 border-emerald-500'
|
||||
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="font-bold text-sm text-white">{lab.name}</h3>
|
||||
<div className="flex gap-1.5" onClick={e => e.stopPropagation()}>
|
||||
{editable && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleOpenEdit(lab)}
|
||||
className="text-slate-400 hover:text-indigo-400 p-0.5"
|
||||
title="Edit template configuration"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Are you sure you want to delete lab template "${lab.name}"? Active/completed scheduling logs are retained in the system.`)) {
|
||||
onDeleteLab(lab.id);
|
||||
}
|
||||
}}
|
||||
className="text-slate-400 hover:text-rose-400 p-0.5"
|
||||
title="Delete template"
|
||||
>
|
||||
<Trash className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">
|
||||
{lab.description}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-slate-800/80 grid grid-cols-2 gap-1 text-[10px] text-slate-350">
|
||||
<div className="flex items-center gap-1">
|
||||
<UserIcon className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||
<span className="truncate">{lab.contactPerson}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||
<span className="truncate">{lab.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-mono text-emerald-400 bg-emerald-950/50 px-2 py-0.5 rounded border border-emerald-900/50">
|
||||
{lab.deviceIds.length} connected devices
|
||||
</span>
|
||||
{lab.scope === 'personal' ? (
|
||||
<span className="text-[10px] font-mono text-indigo-400 bg-indigo-950/50 px-2 py-0.5 rounded border border-indigo-900/50 flex items-center gap-1">
|
||||
<Lock className="w-2.5 h-2.5" /> Personal
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] font-mono text-slate-400 bg-slate-950/50 px-2 py-0.5 rounded border border-slate-800/50 flex items-center gap-1">
|
||||
<Globe className="w-2.5 h-2.5" /> Global
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className={`w-4 h-4 text-slate-500 transition-transform ${isSelected ? 'translate-x-1 text-emerald-400' : ''}`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="lab-templates-container">
|
||||
|
||||
@ -174,68 +269,29 @@ export default function LabTemplates({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Labs templates list */}
|
||||
{/* Labs templates list — sectioned */}
|
||||
<div className="space-y-3 flex-1 overflow-y-auto max-h-[550px] pr-1">
|
||||
{labs.map((lab) => {
|
||||
const isSelected = selectedLab?.id === lab.id;
|
||||
return (
|
||||
<div
|
||||
key={lab.id}
|
||||
onClick={() => setSelectedLab(lab)}
|
||||
className={`p-4 rounded-xl border transition-all cursor-pointer relative ${
|
||||
isSelected
|
||||
? 'bg-slate-900 border-emerald-500'
|
||||
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="font-bold text-sm text-white">{lab.name}</h3>
|
||||
<div className="flex gap-1.5" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => handleOpenEdit(lab)}
|
||||
className="text-slate-400 hover:text-indigo-400 p-0.5"
|
||||
title="Edit template configuration"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Are you sure you want to delete lab template "${lab.name}"? Active/completed scheduling logs are retained in the system.`)) {
|
||||
onDeleteLab(lab.id);
|
||||
}
|
||||
}}
|
||||
className="text-slate-400 hover:text-rose-400 p-0.5"
|
||||
title="Delete template"
|
||||
>
|
||||
<Trash className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">
|
||||
{lab.description}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-slate-800/80 grid grid-cols-2 gap-1 text-[10px] text-slate-350">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||
<span className="truncate">{lab.contactPerson}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||
<span className="truncate">{lab.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 flex items-center justify-between">
|
||||
<span className="text-[10px] font-mono text-emerald-400 bg-emerald-950/50 px-2 py-0.5 rounded border border-emerald-900/50">
|
||||
{lab.deviceIds.length} connected devices
|
||||
</span>
|
||||
<ChevronRight className={`w-4 h-4 text-slate-500 transition-transform ${isSelected ? 'translate-x-1 text-emerald-400' : ''}`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{myPersonalLabs.length > 0 && (
|
||||
<>
|
||||
<p className="text-[10px] font-mono uppercase tracking-widest text-indigo-400 px-1">My Topologies</p>
|
||||
{myPersonalLabs.map(renderLabCard)}
|
||||
</>
|
||||
)}
|
||||
{globalLabs.length > 0 && (
|
||||
<>
|
||||
<p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Global Topologies</p>
|
||||
{globalLabs.map(renderLabCard)}
|
||||
</>
|
||||
)}
|
||||
{othersPersonal.length > 0 && (
|
||||
<>
|
||||
<p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Others' Personal</p>
|
||||
{othersPersonal.map(renderLabCard)}
|
||||
</>
|
||||
)}
|
||||
{labs.length === 0 && (
|
||||
<p className="text-xs text-slate-500 text-center py-8">No topology templates yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -254,7 +310,7 @@ export default function LabTemplates({
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="bg-slate-900 p-2 rounded-lg border border-slate-805 text-[10px] flex items-center gap-1">
|
||||
<User className="w-3.5 h-3.5 text-slate-400" />
|
||||
<UserIcon className="w-3.5 h-3.5 text-slate-400" />
|
||||
<div>
|
||||
<p className="text-slate-400 leading-none">Primary Contact</p>
|
||||
<p className="text-white font-semibold mt-0.5">{selectedLab.contactPerson}</p>
|
||||
@ -347,7 +403,6 @@ export default function LabTemplates({
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
||||
placeholder="e.g. Campus Core OSPF Backup Route"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@ -358,7 +413,6 @@ export default function LabTemplates({
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
||||
placeholder="e.g. Server Room R01, Cabinet B"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -373,7 +427,6 @@ export default function LabTemplates({
|
||||
value={formData.contactPerson}
|
||||
onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })}
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
||||
placeholder="e.g. Jane Doe"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@ -384,11 +437,39 @@ export default function LabTemplates({
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
||||
placeholder="Purpose, VLAN mappings, target device models..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scope toggle */}
|
||||
<div className="border-t border-slate-800 pt-3">
|
||||
<label className="block text-slate-300 font-semibold mb-1.5">Visibility</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, scope: 'global' })}
|
||||
className={`py-2 rounded-lg text-xs font-semibold border transition-all flex items-center justify-center gap-1.5 ${
|
||||
formData.scope === 'global'
|
||||
? 'bg-slate-800 border-slate-500 text-slate-200'
|
||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5" /> Global — visible to all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, scope: 'personal' })}
|
||||
className={`py-2 rounded-lg text-xs font-semibold border transition-all flex items-center justify-center gap-1.5 ${
|
||||
formData.scope === 'personal'
|
||||
? 'bg-indigo-900/30 border-indigo-500 text-indigo-300'
|
||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5" /> Personal — only you
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hardware checklist */}
|
||||
<div className="border-t border-slate-800 pt-3">
|
||||
<label className="block text-slate-300 font-bold mb-1.5">1. Assign Dedicated Hardware Nodes</label>
|
||||
@ -458,7 +539,6 @@ export default function LabTemplates({
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-slate-950 text-slate-250 border border-slate-805 p-1 rounded font-mono text-[11px]"
|
||||
placeholder="e.g. LACP Port-Channel 1"
|
||||
value={linkType}
|
||||
onChange={(e) => setLinkType(e.target.value)}
|
||||
/>
|
||||
@ -536,7 +616,7 @@ export default function LabTemplates({
|
||||
</div>
|
||||
|
||||
{/* Ansible Semaphore Automation */}
|
||||
<div className="border-t border-slate-800 pt-3">
|
||||
{semaphoreEnabled && <div className="border-t border-slate-800 pt-3">
|
||||
<label className="block text-slate-300 font-bold mb-1.5 flex items-center gap-1.5">
|
||||
<Terminal className="w-3.5 h-3.5 text-orange-400" />
|
||||
3. Ansible Automation (optional)
|
||||
@ -551,7 +631,6 @@ export default function LabTemplates({
|
||||
value={formData.semaphoreSetupTemplateId}
|
||||
onChange={(e) => setFormData({ ...formData, semaphoreSetupTemplateId: e.target.value })}
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 font-mono text-xs focus:outline-none focus:border-orange-500/60"
|
||||
placeholder="e.g. 3"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@ -562,11 +641,10 @@ export default function LabTemplates({
|
||||
value={formData.semaphoreTeardownTemplateId}
|
||||
onChange={(e) => setFormData({ ...formData, semaphoreTeardownTemplateId: e.target.value })}
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 font-mono text-xs focus:outline-none focus:border-orange-500/60"
|
||||
placeholder="e.g. 4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Form submit handlers */}
|
||||
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
|
||||
@ -581,7 +659,7 @@ export default function LabTemplates({
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs animate-none"
|
||||
>
|
||||
Save Lab Template
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@ -168,7 +168,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
||||
<span className="absolute left-3 top-2.5 text-slate-550"><Search className="w-4 h-4" /></span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search links by name, host, category…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none focus:border-emerald-600"
|
||||
@ -263,7 +262,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
||||
if (e.key === 'Escape') { setEditingDescId(null); }
|
||||
}}
|
||||
className="w-full mt-3 bg-slate-950 text-slate-200 text-[11px] border border-emerald-600 rounded px-2 py-1 resize-none focus:outline-none leading-relaxed"
|
||||
placeholder="Add a description…"
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
@ -325,7 +323,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
||||
required autoFocus
|
||||
value={draft.title}
|
||||
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
|
||||
placeholder="e.g. CheckMK Monitoring"
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
|
||||
/>
|
||||
</div>
|
||||
@ -336,7 +333,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
||||
required
|
||||
value={draft.url}
|
||||
onChange={(e) => setDraft({ ...draft, url: e.target.value })}
|
||||
placeholder="https://checkmk.internal"
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600 font-mono"
|
||||
/>
|
||||
</div>
|
||||
@ -347,7 +343,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
||||
list="link-categories"
|
||||
value={draft.category}
|
||||
onChange={(e) => setDraft({ ...draft, category: e.target.value })}
|
||||
placeholder="e.g. Monitoring, Automation, Docs"
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
|
||||
/>
|
||||
<datalist id="link-categories">
|
||||
@ -361,7 +356,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
||||
rows={2}
|
||||
value={draft.description}
|
||||
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||
placeholder="What is this tool for?"
|
||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -20,7 +20,7 @@ interface LogbookProps {
|
||||
|
||||
export default function Logbook({ logs, devices, users, currentUser, onAddLog }: LogbookProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('non-system');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
|
||||
// Custom Maintenance Log state
|
||||
const [showAddLog, setShowAddLog] = useState(false);
|
||||
@ -35,7 +35,6 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
||||
const matchesSearch = log.message.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesType =
|
||||
typeFilter === 'all' ? true :
|
||||
typeFilter === 'non-system' ? log.type !== 'system' :
|
||||
log.type === typeFilter;
|
||||
return matchesSearch && matchesType;
|
||||
});
|
||||
@ -118,7 +117,6 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter audit log entries..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-slate-950 text-slate-101 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none"
|
||||
@ -126,7 +124,7 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0 text-xs font-medium flex-wrap">
|
||||
{[
|
||||
{ key: 'non-system', label: 'All' },
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'booking', label: 'Booking' },
|
||||
{ key: 'maintenance',label: 'Maintenance' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
@ -240,7 +238,6 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
||||
value={logMessage}
|
||||
onChange={(e) => setLogMessage(e.target.value)}
|
||||
className="w-full bg-slate-950 text-slate-200 border border-slate-800 rounded p-2 focus:outline-none"
|
||||
placeholder="Describe SFP fiber replacements, port trunking alterations, routing table diagnostic evaluations..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -102,7 +102,6 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
||||
placeholder="user@airit.rocks"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -119,7 +118,6 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 pr-10 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -106,7 +106,6 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
||||
placeholder="Max Mustermann"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -122,7 +121,6 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -139,7 +137,6 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 pr-10 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
||||
placeholder="Min. 8 characters"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@ -174,7 +171,6 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
||||
? 'border-red-700 focus:ring-red-500/50'
|
||||
: 'border-slate-700 focus:ring-cyan-500/50 focus:border-cyan-500/50'
|
||||
}`}
|
||||
placeholder="Repeat password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import { User } from '../types';
|
||||
import {
|
||||
Shield, Activity, Save, CheckCircle, AlertCircle, Eye, EyeOff,
|
||||
Settings2, KeyRound, Globe, Clock, ChevronRight, Copy, Users, RefreshCw, Terminal,
|
||||
Network, Trash2, Plus,
|
||||
Network, Trash2, Plus, HardDrive, Download, Upload, AlertTriangle, Plug, Server, Pencil, X,
|
||||
} from 'lucide-react';
|
||||
|
||||
const SECRET_SENTINEL = '__SET__';
|
||||
@ -35,6 +35,14 @@ interface CaddyRoute {
|
||||
upstream: string;
|
||||
tls: number;
|
||||
compress: number;
|
||||
redirect: string;
|
||||
}
|
||||
|
||||
interface DbInfo {
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
tables: Record<string, number>;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
@ -101,10 +109,9 @@ function Input({ value, onChange, placeholder, monospace, icon }: {
|
||||
);
|
||||
}
|
||||
|
||||
function SecretInput({ value, onChange, alreadySet, show, onToggleShow }: {
|
||||
function SecretInput({ value, onChange, show, onToggleShow }: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
alreadySet: boolean;
|
||||
show: boolean;
|
||||
onToggleShow: () => void;
|
||||
}) {
|
||||
@ -117,7 +124,6 @@ function SecretInput({ value, onChange, alreadySet, show, onToggleShow }: {
|
||||
type={show ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={alreadySet ? '•••••••• (leave blank to keep)' : 'Secret'}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg pl-9 pr-10 py-2.5 text-xs font-mono text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
||||
/>
|
||||
<button
|
||||
@ -149,6 +155,7 @@ function SectionCard({ accentColor, children }: {
|
||||
export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState<'integrations' | 'system'>('integrations');
|
||||
const [error, setError] = useState('');
|
||||
const [successMsg, setSuccessMsg] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
@ -166,7 +173,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
|
||||
const [checkmkEnabled, setCheckmkEnabled] = useState(false);
|
||||
const [checkmkApiUrl, setCheckmkApiUrl] = useState('');
|
||||
const [checkmkApiUser, setCheckmkApiUser] = useState('automation');
|
||||
const [checkmkApiUser, setCheckmkApiUser] = useState('');
|
||||
const [checkmkApiSecret, setCheckmkApiSecret] = useState('');
|
||||
const [checkmkSecretSet, setCheckmkSecretSet] = useState(false);
|
||||
const [checkmkSyncInterval, setCheckmkSyncInterval] = useState('60000');
|
||||
@ -183,23 +190,48 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
const [semaphoreTestResult, setSemaphoreTestResult] = useState<string | null>(null);
|
||||
|
||||
const [caddyEnabled, setCaddyEnabled] = useState(false);
|
||||
const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://localhost:2019');
|
||||
const [caddyManaged, setCaddyManaged] = useState(true);
|
||||
const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://127.0.0.1:2019');
|
||||
const [caddyStatus, setCaddyStatus] = useState<'unknown' | 'available' | 'unavailable'>('unknown');
|
||||
const [caddyRoutes, setCaddyRoutes] = useState<{ system: { hostname: string; upstream: string }[]; custom: CaddyRoute[] } | null>(null);
|
||||
const [caddyRoutes, setCaddyRoutes] = useState<CaddyRoute[]>([]);
|
||||
const [addingRoute, setAddingRoute] = useState(false);
|
||||
const [newHostname, setNewHostname] = useState('');
|
||||
const [newUpstream, setNewUpstream] = useState('');
|
||||
const [newTls, setNewTls] = useState(true);
|
||||
const [newCompress, setNewCompress] = useState(true);
|
||||
const [newRedirect, setNewRedirect] = useState('');
|
||||
|
||||
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
|
||||
const [editHostname, setEditHostname] = useState('');
|
||||
const [editUpstream, setEditUpstream] = useState('');
|
||||
const [editTls, setEditTls] = useState(true);
|
||||
const [editCompress, setEditCompress] = useState(true);
|
||||
const [editRedirect, setEditRedirect] = useState('');
|
||||
const [savingRoute, setSavingRoute] = useState(false);
|
||||
|
||||
const [dbInfo, setDbInfo] = useState<DbInfo | null>(null);
|
||||
const [backingUp, setBackingUp] = useState(false);
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importConfirmed, setImportConfirmed] = useState(false);
|
||||
const [importResult, setImportResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
loadDbInfo();
|
||||
fetch('/api/auth/config')
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri); })
|
||||
.then(d => {
|
||||
if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri);
|
||||
setCaddyManaged(d.isProduction !== false);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (caddyEnabled) loadCaddyRoutes();
|
||||
}, [caddyEnabled]);
|
||||
|
||||
async function loadSettings() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
@ -221,7 +253,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
setAzureAllowedGroup(data.azure_allowed_group || '');
|
||||
setCheckmkEnabled(data.checkmk_enabled === 'true');
|
||||
setCheckmkApiUrl(data.checkmk_api_url || '');
|
||||
setCheckmkApiUser(data.checkmk_api_user || 'automation');
|
||||
setCheckmkApiUser(data.checkmk_api_user || '');
|
||||
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
|
||||
setCheckmkApiSecret('');
|
||||
setCheckmkSyncInterval(data.checkmk_sync_interval_ms || '60000');
|
||||
@ -231,8 +263,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
setSemaphoreApiToken('');
|
||||
setSemaphoreProjectId(data.semaphore_project_id || '');
|
||||
setCaddyEnabled(data.caddy_enabled === 'true');
|
||||
setCaddyAdminUrl(data.caddy_admin_url || 'http://localhost:2019');
|
||||
if (data.caddy_enabled === 'true') loadCaddyRoutes();
|
||||
setCaddyAdminUrl(data.caddy_admin_url || 'http://127.0.0.1:2019');
|
||||
} catch {
|
||||
setError('Network error loading settings.');
|
||||
} finally {
|
||||
@ -277,7 +308,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
setAzureClientSecret('');
|
||||
setCheckmkApiSecret('');
|
||||
setSemaphoreApiToken('');
|
||||
loadCaddyRoutes();
|
||||
await loadCaddyRoutes();
|
||||
setSuccessMsg('Settings saved successfully.');
|
||||
setTimeout(() => setSuccessMsg(''), 4000);
|
||||
} catch {
|
||||
@ -317,7 +348,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
return;
|
||||
}
|
||||
const templates = await res.json() as any[];
|
||||
setSemaphoreTestResult(`Connected — ${templates.length} task template${templates.length !== 1 ? 's' : ''} found.`);
|
||||
setSemaphoreTestResult(`Connected - ${templates.length} task template${templates.length !== 1 ? 's' : ''} found.`);
|
||||
} catch {
|
||||
setSemaphoreTestResult('Error: Network error connecting to Semaphore.');
|
||||
} finally {
|
||||
@ -335,20 +366,14 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
|
||||
async function loadCaddyRoutes() {
|
||||
try {
|
||||
const [statusRes, routesRes] = await Promise.all([
|
||||
authFetch('/api/caddy/status'),
|
||||
authFetch('/api/caddy/routes'),
|
||||
]);
|
||||
if (statusRes.ok) {
|
||||
const s = await statusRes.json();
|
||||
setCaddyStatus(s.available ? 'available' : 'unavailable');
|
||||
}
|
||||
if (routesRes.ok) {
|
||||
setCaddyRoutes(await routesRes.json());
|
||||
}
|
||||
} catch {
|
||||
setCaddyStatus('unavailable');
|
||||
}
|
||||
const res = await authFetch('/api/caddy/routes');
|
||||
if (res.ok) setCaddyRoutes(await res.json());
|
||||
} catch {}
|
||||
// Status check runs separately - purely informational, never blocks the list
|
||||
authFetch('/api/caddy/status')
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.then(s => setCaddyStatus(s?.available ? 'available' : 'unavailable'))
|
||||
.catch(() => setCaddyStatus('unavailable'));
|
||||
}
|
||||
|
||||
async function handleAddRoute() {
|
||||
@ -357,7 +382,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
try {
|
||||
const res = await authFetch('/api/caddy/routes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ hostname: newHostname.trim(), upstream: newUpstream.trim(), tls: newTls, compress: newCompress }),
|
||||
body: JSON.stringify({ hostname: newHostname.trim(), upstream: newUpstream.trim(), tls: newTls, compress: newCompress, redirect: newRedirect.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json();
|
||||
@ -368,6 +393,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
setNewUpstream('');
|
||||
setNewTls(true);
|
||||
setNewCompress(true);
|
||||
setNewRedirect('');
|
||||
await loadCaddyRoutes();
|
||||
} catch {
|
||||
setError('Network error adding route.');
|
||||
@ -390,6 +416,107 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditStart(r: CaddyRoute) {
|
||||
setEditingRouteId(r.id);
|
||||
setEditHostname(r.hostname);
|
||||
setEditUpstream(r.upstream);
|
||||
setEditTls(r.tls === 1);
|
||||
setEditCompress(r.compress === 1);
|
||||
setEditRedirect(r.redirect || '');
|
||||
}
|
||||
|
||||
function handleEditCancel() {
|
||||
setEditingRouteId(null);
|
||||
}
|
||||
|
||||
async function handleEditSave(id: number) {
|
||||
if (!editHostname.trim() || !editUpstream.trim()) return;
|
||||
setSavingRoute(true);
|
||||
try {
|
||||
const res = await authFetch(`/api/caddy/routes/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ hostname: editHostname.trim(), upstream: editUpstream.trim(), tls: editTls, compress: editCompress, redirect: editRedirect.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json();
|
||||
setError(d.error || 'Failed to update route.');
|
||||
return;
|
||||
}
|
||||
setEditingRouteId(null);
|
||||
await loadCaddyRoutes();
|
||||
} catch {
|
||||
setError('Network error updating route.');
|
||||
} finally {
|
||||
setSavingRoute(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDbInfo() {
|
||||
try {
|
||||
const res = await authFetch('/api/database/info');
|
||||
if (res.ok) setDbInfo(await res.json());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleBackup() {
|
||||
setBackingUp(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await authFetch('/api/database/backup');
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
setError((d as any).error || 'Backup failed.');
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `ghostgrid-backup-${new Date().toISOString().slice(0, 10)}.db`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
setError('Network error during backup.');
|
||||
} finally {
|
||||
setBackingUp(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!importFile || !importConfirmed) return;
|
||||
setImporting(true);
|
||||
setImportResult(null);
|
||||
try {
|
||||
const res = await authFetch('/api/database/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: importFile,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
setImportResult({ ok: false, msg: (d as any).error || 'Import failed.' });
|
||||
return;
|
||||
}
|
||||
setImportResult({ ok: true, msg: 'Database imported successfully. Reload the page to reflect all changes.' });
|
||||
setImportFile(null);
|
||||
setImportConfirmed(false);
|
||||
loadDbInfo();
|
||||
loadSettings();
|
||||
} catch {
|
||||
setImportResult({ ok: false, msg: 'Network error during import.' });
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@ -402,15 +529,29 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-mono text-slate-600 mb-3">
|
||||
<Settings2 className="w-3 h-3" />
|
||||
<span>SYSTEM</span>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
<span className="text-slate-400">SETTINGS</span>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-mono text-slate-600 mb-3">
|
||||
<Settings2 className="w-3 h-3" />
|
||||
<span>SYSTEM</span>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
<span className="text-slate-400">SETTINGS</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-white tracking-tight">Settings</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">Configure integrations and authentication providers.</p>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-white tracking-tight">Settings</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">Configure integrations and authentication providers.</p>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="shrink-0 flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 disabled:bg-slate-800 disabled:text-slate-600 disabled:border disabled:border-slate-700 text-white font-semibold text-sm px-5 py-2.5 rounded-xl transition-all shadow-lg shadow-cyan-950/50"
|
||||
>
|
||||
{saving ? (
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
{saving ? 'Saving…' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Feedback banners */}
|
||||
@ -427,8 +568,31 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integration cards: three columns on large screens */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
|
||||
{/* Section tabs - switch between Integrations and System to keep the page light */}
|
||||
<div className="inline-flex items-center gap-0.5 p-0.5 bg-slate-900/50 border border-slate-800 rounded-lg w-fit">
|
||||
{([
|
||||
{ id: 'integrations', label: 'Integrations', icon: <Plug className="w-3.5 h-3.5" /> },
|
||||
{ id: 'system', label: 'System', icon: <Server className="w-3.5 h-3.5" /> },
|
||||
] as const).map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveSection(tab.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
activeSection === tab.id
|
||||
? 'bg-cyan-950/40 border border-cyan-900/50 text-cyan-400'
|
||||
: 'border border-transparent text-slate-400 hover:text-slate-200 hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Integrations: Azure in column one, monitoring & automation stacked in column two ── */}
|
||||
{activeSection === 'integrations' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
|
||||
{/* ── Microsoft Entra ID ── */}
|
||||
<SectionCard accentColor="bg-gradient-to-r from-blue-600 to-indigo-600">
|
||||
@ -468,10 +632,10 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
|
||||
<div className={`grid grid-cols-1 sm:grid-cols-2 gap-5 transition-opacity duration-200 ${!azureEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
|
||||
<FieldRow label="Tenant ID" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
|
||||
<Input value={azureTenantId} onChange={setAzureTenantId} placeholder="Tenant ID" monospace />
|
||||
<Input value={azureTenantId} onChange={setAzureTenantId} monospace />
|
||||
</FieldRow>
|
||||
<FieldRow label="Client ID (Application ID)" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
|
||||
<Input value={azureClientId} onChange={setAzureClientId} placeholder="Client ID" monospace />
|
||||
<Input value={azureClientId} onChange={setAzureClientId} monospace />
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label="Client Secret"
|
||||
@ -480,7 +644,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
<SecretInput
|
||||
value={azureClientSecret}
|
||||
onChange={setAzureClientSecret}
|
||||
alreadySet={azureSecretSet}
|
||||
show={showAzureSecret}
|
||||
onToggleShow={() => setShowAzureSecret(v => !v)}
|
||||
/>
|
||||
@ -493,7 +656,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
<Input
|
||||
value={azureRedirectUri}
|
||||
onChange={setAzureRedirectUri}
|
||||
placeholder={effectiveRedirectUri || 'https://…/api/auth/azure/callback'}
|
||||
monospace
|
||||
/>
|
||||
</FieldRow>
|
||||
@ -505,7 +667,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
<Input
|
||||
value={azureAllowedGroup}
|
||||
onChange={setAzureAllowedGroup}
|
||||
placeholder="Leave blank to allow all tenant users"
|
||||
monospace
|
||||
icon={<Users className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
@ -532,6 +693,9 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* Monitoring & automation, stacked together in the second column */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* ── CheckMK ── */}
|
||||
<SectionCard accentColor="bg-gradient-to-r from-emerald-600 to-teal-600">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -573,17 +737,15 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
<Input
|
||||
value={checkmkApiUrl}
|
||||
onChange={setCheckmkApiUrl}
|
||||
placeholder="https://checkmk/<site>/check_mk/api/1.0"
|
||||
monospace
|
||||
icon={<Globe className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
</FieldRow>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
<FieldRow label="Automation User" hint="Setup > Users > Automation user (e.g. automation)">
|
||||
<FieldRow label="Automation User" hint="Setup > Users > Automation user">
|
||||
<Input
|
||||
value={checkmkApiUser}
|
||||
onChange={setCheckmkApiUser}
|
||||
placeholder="automation"
|
||||
monospace
|
||||
icon={<KeyRound className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
@ -596,7 +758,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
<SecretInput
|
||||
value={checkmkApiSecret}
|
||||
onChange={setCheckmkApiSecret}
|
||||
alreadySet={checkmkSecretSet}
|
||||
show={showCheckmkSecret}
|
||||
onToggleShow={() => setShowCheckmkSecret(v => !v)}
|
||||
/>
|
||||
@ -605,7 +766,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
<Input
|
||||
value={checkmkSyncInterval}
|
||||
onChange={setCheckmkSyncInterval}
|
||||
placeholder="60000"
|
||||
monospace
|
||||
icon={<Clock className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
@ -666,7 +826,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
<Input
|
||||
value={semaphoreApiUrl}
|
||||
onChange={setSemaphoreApiUrl}
|
||||
placeholder="https://semaphore/api/v1alpha"
|
||||
monospace
|
||||
icon={<Globe className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
@ -680,7 +839,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
<SecretInput
|
||||
value={semaphoreApiToken}
|
||||
onChange={setSemaphoreApiToken}
|
||||
alreadySet={semaphoreTokenSet}
|
||||
show={showSemaphoreToken}
|
||||
onToggleShow={() => setShowSemaphoreToken(v => !v)}
|
||||
/>
|
||||
@ -689,7 +847,6 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
<Input
|
||||
value={semaphoreProjectId}
|
||||
onChange={setSemaphoreProjectId}
|
||||
placeholder="1"
|
||||
monospace
|
||||
icon={<KeyRound className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
@ -716,7 +873,14 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
</div>{/* end grid */}
|
||||
</div>{/* end second column */}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── System ── */}
|
||||
{activeSection === 'system' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
|
||||
{/* ── Caddy Reverse Proxy ── */}
|
||||
<SectionCard accentColor="bg-gradient-to-r from-sky-600 to-cyan-600">
|
||||
@ -739,67 +903,131 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className={`text-[10px] font-semibold font-mono ${caddyEnabled ? 'text-sky-400' : 'text-slate-600'}`}>
|
||||
{caddyEnabled ? 'ENABLED' : 'DISABLED'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCaddyEnabled(v => !v); if (!caddyEnabled) loadCaddyRoutes(); }}
|
||||
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${caddyEnabled ? 'bg-sky-600 shadow-[0_0_10px_rgba(2,132,199,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${caddyEnabled ? 'left-5' : 'left-0.5'}`} />
|
||||
</button>
|
||||
{caddyManaged ? (
|
||||
<>
|
||||
<span className={`text-[10px] font-semibold font-mono ${caddyEnabled ? 'text-sky-400' : 'text-slate-600'}`}>
|
||||
{caddyEnabled ? 'ENABLED' : 'DISABLED'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCaddyEnabled((v: boolean) => !v)}
|
||||
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${caddyEnabled ? 'bg-sky-600 shadow-[0_0_10px_rgba(2,132,199,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${caddyEnabled ? 'left-5' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[10px] font-semibold font-mono text-slate-500">MANAGED BY PRODUCTION</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-slate-800/60" />
|
||||
|
||||
{!caddyManaged && (
|
||||
<p className="text-[11px] font-mono text-slate-500 leading-relaxed">
|
||||
Caddy is centrally managed by the production instance (ghostgrid). Manage proxy routes there.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{caddyManaged && (
|
||||
<div className={`space-y-5 transition-opacity duration-200 ${!caddyEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
|
||||
<FieldRow label="Caddy Admin URL" hint="Default: http://localhost:2019">
|
||||
<Input value={caddyAdminUrl} onChange={setCaddyAdminUrl} placeholder="http://localhost:2019" monospace icon={<Globe className="w-3.5 h-3.5" />} />
|
||||
<FieldRow label="Caddy Admin URL" hint="Default: http://127.0.0.1:2019">
|
||||
<Input value={caddyAdminUrl} onChange={setCaddyAdminUrl} monospace icon={<Globe className="w-3.5 h-3.5" />} />
|
||||
</FieldRow>
|
||||
|
||||
{/* Route list */}
|
||||
{caddyEnabled && (
|
||||
<div className="space-y-2">
|
||||
<Label>Proxy Routes</Label>
|
||||
<Hint>Prefix the upstream with https:// for TLS backends - the certificate is not verified.</Hint>
|
||||
|
||||
{caddyStatus === 'unavailable' && (
|
||||
<p className="text-[11px] font-mono text-amber-400 mb-2">
|
||||
Caddy Admin API not reachable — routes will be applied when Caddy starts.
|
||||
Caddy Admin API not reachable - routes will be applied when Caddy starts.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{caddyRoutes.length === 0 && (
|
||||
<p className="text-[11px] font-mono text-slate-500 mb-2">
|
||||
No proxy routes configured yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Custom routes */}
|
||||
{caddyRoutes?.custom.map(r => (
|
||||
<div key={r.id} className="flex items-center justify-between bg-slate-900/60 border border-slate-700/60 rounded-xl px-4 py-2.5">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-[11px] font-mono text-white truncate">{r.hostname}</span>
|
||||
<span className="text-slate-600 text-[11px]">></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.compress ? <span className="text-[9px] font-semibold text-slate-500 font-mono">GZ</span> : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteRoute(r.id)}
|
||||
className="ml-3 shrink-0 p-1.5 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-950/30 transition-all"
|
||||
title="Remove route"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{caddyRoutes.map((r: CaddyRoute) => (
|
||||
<div key={r.id} className="bg-slate-900/60 border border-slate-700/60 rounded-xl px-4 py-2.5">
|
||||
{editingRouteId === r.id ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Label>Hostname</Label>
|
||||
<Input value={editHostname} onChange={setEditHostname} monospace />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Label>Upstream</Label>
|
||||
<Input value={editUpstream} onChange={setEditUpstream} monospace />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 pb-0.5">
|
||||
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">TLS</span>
|
||||
<button type="button" onClick={() => setEditTls(v => !v)}
|
||||
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${editTls ? 'bg-sky-600' : 'bg-slate-800 border border-slate-700'}`}>
|
||||
<span className={`absolute top-0.5 w-3 h-3 bg-white rounded-full shadow transition-all duration-200 ${editTls ? 'left-4' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 pb-0.5">
|
||||
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">GZ</span>
|
||||
<button type="button" onClick={() => setEditCompress(v => !v)}
|
||||
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${editCompress ? 'bg-sky-600' : 'bg-slate-800 border border-slate-700'}`}>
|
||||
<span className={`absolute top-0.5 w-3 h-3 bg-white rounded-full shadow transition-all duration-200 ${editCompress ? 'left-4' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={() => handleEditSave(r.id)} disabled={savingRoute || !editHostname.trim() || !editUpstream.trim()}
|
||||
className="flex items-center gap-1 bg-sky-950/60 hover:bg-sky-900/40 border border-sky-900/50 text-sky-400 text-xs font-semibold px-2.5 py-2 rounded-lg transition-all disabled:opacity-50 shrink-0">
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={handleEditCancel}
|
||||
className="p-2 rounded-lg text-slate-500 hover:text-slate-300 hover:bg-slate-800 transition-all shrink-0">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<Input value={editRedirect} onChange={setEditRedirect} monospace />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-[11px] font-mono text-white truncate">{r.hostname}</span>
|
||||
<span className="text-slate-600 text-[11px]">></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.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 className="flex items-center gap-1 ml-3 shrink-0">
|
||||
<button type="button" onClick={() => handleEditStart(r)}
|
||||
className="p-1.5 rounded-lg text-slate-600 hover:text-sky-400 hover:bg-sky-950/30 transition-all" title="Edit route">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={() => handleDeleteRoute(r.id)}
|
||||
className="p-1.5 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-950/30 transition-all" title="Remove route">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add route form */}
|
||||
<div className="flex items-end gap-2 pt-1">
|
||||
<div className="space-y-2 pt-1">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Label>Hostname</Label>
|
||||
<Input value={newHostname} onChange={setNewHostname} placeholder="semaphore" monospace />
|
||||
<Input value={newHostname} onChange={setNewHostname} monospace />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Label>Upstream</Label>
|
||||
<Input value={newUpstream} onChange={setNewUpstream} placeholder="127.0.0.1:3000" monospace />
|
||||
<Input value={newUpstream} onChange={setNewUpstream} monospace />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 pb-0.5">
|
||||
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">TLS</span>
|
||||
@ -831,27 +1059,162 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
{addingRoute ? 'Adding…' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
<Input value={newRedirect} onChange={setNewRedirect} monospace />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Database ── */}
|
||||
<SectionCard accentColor="bg-gradient-to-r from-violet-600 to-purple-600">
|
||||
|
||||
{/* Header: icon + title + file size */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-violet-950/60 border border-violet-900/40 rounded-xl">
|
||||
<HardDrive className="w-4 h-4 text-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white">Database</h2>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">SQLite · {dbInfo?.path ?? 'ghostgrid.db'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xl font-bold text-white font-mono leading-none">
|
||||
{dbInfo ? formatBytes(dbInfo.sizeBytes) : '-'}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-500 font-mono mt-1">
|
||||
{dbInfo ? new Date(dbInfo.lastModified).toLocaleDateString() : 'Loading…'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-slate-800/60" />
|
||||
|
||||
{/* Proportional usage bar + table stats */}
|
||||
{dbInfo ? (() => {
|
||||
const tableEntries = Object.entries(dbInfo.tables) as [string, number][];
|
||||
const total = tableEntries.reduce((sum, [, n]) => sum + n, 0);
|
||||
const palette: Record<string, string> = {
|
||||
users: 'bg-blue-500', devices: 'bg-emerald-500', labs: 'bg-orange-500',
|
||||
bookings: 'bg-cyan-500', logs: 'bg-slate-400', links: 'bg-violet-500',
|
||||
settings: 'bg-slate-600', caddy: 'bg-sky-500',
|
||||
};
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex h-1.5 rounded-full overflow-hidden bg-slate-800 gap-px">
|
||||
{total === 0
|
||||
? <div className="flex-1 bg-slate-700" />
|
||||
: tableEntries.filter(([, n]) => n > 0).map(([t, n]) => (
|
||||
<div
|
||||
key={t}
|
||||
title={`${t}: ${n}`}
|
||||
className={`${palette[t] ?? 'bg-slate-500'} transition-all`}
|
||||
style={{ width: `${(n / total) * 100}%` }}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{tableEntries.map(([t, n]) => (
|
||||
<div key={t} className="bg-slate-900/50 border border-slate-700/60 rounded-lg px-2 py-1.5">
|
||||
<div className={`w-1.5 h-1.5 rounded-full mb-1 ${palette[t] ?? 'bg-slate-500'}`} />
|
||||
<p className="text-[8px] font-semibold text-slate-600 uppercase tracking-wide truncate">{t}</p>
|
||||
<p className="text-sm font-bold text-white font-mono">{n}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})() : (
|
||||
<div className="h-16 flex items-center justify-center">
|
||||
<span className="w-4 h-4 border-2 border-slate-700 border-t-violet-400 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-px bg-slate-800/60" />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
|
||||
{/* Backup */}
|
||||
<div className="space-y-2">
|
||||
<Label>Backup</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackup}
|
||||
disabled={backingUp}
|
||||
className="w-full flex items-center justify-center gap-2 bg-violet-950/60 hover:bg-violet-900/40 border border-violet-900/50 text-violet-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Download className={`w-3.5 h-3.5 ${backingUp ? 'animate-pulse' : ''}`} />
|
||||
{backingUp ? 'Creating backup…' : 'Download Backup'}
|
||||
</button>
|
||||
<Hint>Downloads a consistent snapshot of the SQLite database.</Hint>
|
||||
</div>
|
||||
|
||||
{/* Import */}
|
||||
<div className="space-y-2">
|
||||
<Label>Import</Label>
|
||||
<div className="flex items-start gap-2 bg-amber-950/40 border border-amber-900/50 rounded-xl px-3 py-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-amber-300 leading-relaxed">
|
||||
<strong>Import overwrites the entire database</strong> - this cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<label className="w-full flex items-center gap-2 cursor-pointer bg-slate-900 border border-slate-700 hover:border-slate-600 rounded-lg px-3 py-2 text-xs text-slate-400 hover:text-slate-200 transition-all">
|
||||
<Upload className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate">{importFile ? importFile.name : 'Select .db file…'}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".db"
|
||||
className="sr-only"
|
||||
onChange={e => {
|
||||
setImportFile(e.target.files?.[0] ?? null);
|
||||
setImportConfirmed(false);
|
||||
setImportResult(null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{importFile && (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={importConfirmed}
|
||||
onChange={e => setImportConfirmed(e.target.checked)}
|
||||
className="w-3.5 h-3.5 rounded accent-violet-500"
|
||||
/>
|
||||
<span className="text-[11px] text-slate-400">I confirm this will overwrite all existing data</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
disabled={importing || !importConfirmed}
|
||||
className="w-full flex items-center justify-center gap-2 bg-red-950/60 hover:bg-red-900/40 border border-red-900/50 text-red-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{importing
|
||||
? <span className="w-3.5 h-3.5 border-2 border-red-400/30 border-t-red-400 rounded-full animate-spin" />
|
||||
: <Upload className="w-3.5 h-3.5" />
|
||||
}
|
||||
{importing ? 'Importing…' : 'Import Database'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{importResult && (
|
||||
<p className={`text-[11px] font-mono ${importResult.ok ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{importResult.msg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* Save bar */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-800/60">
|
||||
<p className="text-[11px] text-slate-600">Changes are applied after saving and a server restart.</p>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 disabled:bg-slate-800 disabled:text-slate-600 disabled:border disabled:border-slate-700 text-white font-semibold text-sm px-5 py-2.5 rounded-xl transition-all shadow-lg shadow-cyan-950/50"
|
||||
>
|
||||
{saving ? (
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
{saving ? 'Saving…' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { User, Booking } from '../types';
|
||||
import { Users, Search, Mail, Calendar, Activity, Trash2, Pencil, X, Save, AlertCircle } from 'lucide-react';
|
||||
import { Users, Search, Mail, Calendar, Activity, Trash2, Pencil, X, Save, AlertCircle, ShieldCheck, Shield } from 'lucide-react';
|
||||
|
||||
interface UserDirectoryProps {
|
||||
users: User[];
|
||||
@ -8,6 +8,7 @@ interface UserDirectoryProps {
|
||||
bookings: Booking[];
|
||||
onDeleteUser: (id: string) => Promise<void>;
|
||||
onUpdateUser: (id: string, name: string, email: string) => Promise<void>;
|
||||
onSetRole: (id: string, role: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
@ -117,10 +118,13 @@ function EditModal({ user, onClose, onSave }: EditModalProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserDirectory({ users, currentUser, bookings, onDeleteUser, onUpdateUser }: UserDirectoryProps) {
|
||||
export default function UserDirectory({ users, currentUser, bookings, onDeleteUser, onUpdateUser, onSetRole }: UserDirectoryProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [togglingRoleId, setTogglingRoleId] = useState<string | null>(null);
|
||||
const [roleError, setRoleError] = useState<string | null>(null);
|
||||
const isCurrentUserAdmin = currentUser.role.toLowerCase() === 'admin';
|
||||
|
||||
const bookingCount = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
@ -152,6 +156,18 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
|
||||
try { await onDeleteUser(id); } finally { setDeletingId(null); }
|
||||
}
|
||||
|
||||
async function handleToggleRole(user: User) {
|
||||
setTogglingRoleId(user.id);
|
||||
setRoleError(null);
|
||||
try {
|
||||
await onSetRole(user.id, user.role.toLowerCase() === 'admin' ? 'User' : 'admin');
|
||||
} catch (err: any) {
|
||||
setRoleError(err.message || 'Failed to change role.');
|
||||
} finally {
|
||||
setTogglingRoleId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveEdit(name: string, email: string) {
|
||||
if (!editingUser) return;
|
||||
await onUpdateUser(editingUser.id, name, email);
|
||||
@ -190,12 +206,19 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{roleError && (
|
||||
<div className="flex items-center gap-2 bg-red-950/50 border border-red-900/50 rounded-lg px-3 py-2 text-xs text-red-300">
|
||||
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||
{roleError}
|
||||
<button onClick={() => setRoleError(null)} className="ml-auto text-red-400 hover:text-white"><X className="w-3 h-3" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-2.5 text-slate-500"><Search className="w-4 h-4" /></span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search operators by name, email or role…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full bg-[#1E293B] text-slate-100 border border-slate-800 rounded-xl pl-9 pr-4 py-2 text-xs focus:outline-none focus:border-emerald-600"
|
||||
@ -240,7 +263,10 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-slate-800 flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-mono text-slate-500 uppercase tracking-wider">Operator</span>
|
||||
{user.role.toLowerCase() === 'admin'
|
||||
? <span className="flex items-center gap-1 text-[10px] font-mono text-amber-400 uppercase tracking-wider"><ShieldCheck className="w-3 h-3" />Admin</span>
|
||||
: <span className="text-[10px] font-mono text-slate-500 uppercase tracking-wider">User</span>
|
||||
}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-3 text-[10px] font-mono text-slate-400">
|
||||
@ -256,6 +282,20 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
{isCurrentUserAdmin && !isMe && (
|
||||
<button
|
||||
onClick={() => handleToggleRole(user)}
|
||||
disabled={togglingRoleId === user.id}
|
||||
className={`p-1.5 rounded-lg transition-all disabled:opacity-40 ${user.role.toLowerCase() === 'admin' ? 'text-amber-400 hover:text-slate-400 hover:bg-slate-900' : 'text-slate-500 hover:text-amber-400 hover:bg-slate-900'}`}
|
||||
title={user.role.toLowerCase() === 'admin' ? 'Remove admin' : 'Make admin'}
|
||||
>
|
||||
{togglingRoleId === user.id
|
||||
? <span className="w-3.5 h-3.5 border-2 border-slate-600 border-t-amber-400 rounded-full animate-spin inline-block" />
|
||||
: user.role.toLowerCase() === 'admin'
|
||||
? <ShieldCheck className="w-3.5 h-3.5" />
|
||||
: <Shield className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEditingUser(user)}
|
||||
className="p-1.5 rounded-lg text-slate-500 hover:text-cyan-400 hover:bg-slate-900 transition-all"
|
||||
|
||||
@ -54,7 +54,7 @@
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
/* ── Backgrounds: all dark hex variants → card/inner */
|
||||
/* ── Backgrounds: all dark hex variants > card/inner */
|
||||
:root.light .bg-\[\#0B0F19\],
|
||||
:root.light .bg-\[\#0b0f19\] {
|
||||
background-color: var(--bg) !important;
|
||||
@ -109,6 +109,7 @@
|
||||
:root.light .bg-slate-950\/20,
|
||||
:root.light .bg-slate-950\/30,
|
||||
:root.light .bg-slate-950\/40,
|
||||
:root.light .bg-slate-950\/50,
|
||||
:root.light .bg-slate-950\/60,
|
||||
:root.light .bg-slate-950\/80 {
|
||||
background-color: rgba(241, 245, 249, 0.85) !important;
|
||||
@ -730,6 +731,12 @@
|
||||
|
||||
/* ── Missing border opacity variants ─────────────────────────────── */
|
||||
|
||||
/* slate-800 with opacity */
|
||||
:root.light .border-slate-800\/50,
|
||||
:root.light .border-slate-800\/40 {
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
/* slate-700 with opacity */
|
||||
:root.light .border-slate-700\/40,
|
||||
:root.light .border-slate-700\/50,
|
||||
@ -872,3 +879,42 @@
|
||||
:root.light .group:hover .group-hover\:text-slate-300 {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* ── Settings → Caddy section: sky accent ─────────────────────────── */
|
||||
/* sky-* is used only by the Caddy card; map its dark tokens to light. */
|
||||
:root.light .bg-sky-950\/60,
|
||||
:root.light .bg-sky-950\/40,
|
||||
:root.light .bg-sky-900\/40 {
|
||||
background-color: #e0f2fe !important;
|
||||
border-color: #7dd3fc !important;
|
||||
color: #0369a1 !important;
|
||||
}
|
||||
|
||||
:root.light .border-sky-900\/50,
|
||||
:root.light .border-sky-900\/40 {
|
||||
border-color: #7dd3fc !important;
|
||||
}
|
||||
|
||||
:root.light .text-sky-400,
|
||||
:root.light .text-sky-500 {
|
||||
color: #0284c7 !important;
|
||||
}
|
||||
|
||||
:root.light .bg-sky-950\/30,
|
||||
:root.light .hover\:bg-sky-950\/30:hover {
|
||||
background-color: #e0f2fe !important;
|
||||
}
|
||||
|
||||
:root.light .hover\:bg-sky-900\/40:hover {
|
||||
background-color: #bae6fd !important;
|
||||
}
|
||||
|
||||
:root.light .hover\:text-sky-400:hover {
|
||||
color: #0284c7 !important;
|
||||
}
|
||||
|
||||
/* Delete-icon hover (red-950/30 is the only red opacity not yet mapped) */
|
||||
:root.light .bg-red-950\/30,
|
||||
:root.light .hover\:bg-red-950\/30:hover {
|
||||
background-color: #fee2e2 !important;
|
||||
}
|
||||
|
||||
@ -36,6 +36,8 @@ export interface LabTemplate {
|
||||
topology: TopologyLink[];
|
||||
semaphoreSetupTemplateId?: string;
|
||||
semaphoreTeardownTemplateId?: string;
|
||||
scope: 'global' | 'personal';
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export interface Booking {
|
||||
|
||||
Reference in New Issue
Block a user