Compare commits

...

50 Commits

Author SHA1 Message Date
a2d515992c fix(logbook): 'All' filter shows every log including system entries
Drop the 'non-system' default filter; 'All' now means all log types.
2026-06-09 13:09:04 +02:00
2a2902d5bc feat(ui): distinguish dev/prod via VITE_DEPLOY_ENV
Both instances run with NODE_ENV=production, so import.meta.env.PROD was
always true and the header always showed 'Production'. deploy.sh now passes
VITE_DEPLOY_ENV=<branch> into the build and Header reads it to label the
system indicator dev vs prod correctly.
2026-06-09 13:09:03 +02:00
ac1cf8fec7 docs(architecture): sync Caddy manager gate in first-start + ownership invariant 2026-06-09 13:09:01 +02:00
e0332b05ad feat(caddy): single owner via CADDY_MANAGER env flag
One Caddy serves the whole container and POST /load replaces the entire
config, so two instances pushing would clobber each other. Now only the
instance with CADDY_MANAGER=true (production) pushes, seeds routes from the
Caddyfile, and accepts route mutations (others get 403). /api/auth/config
exposes caddyManaged so the non-owner Settings UI shows the Caddy section
read-only. The installer sets the flag on the production .env only.
2026-06-09 12:47:20 +02:00
bc677ff805 feat(caddy): add standard forwarding headers to every reverse_proxy
Every generated reverse_proxy block now emits header_up for
X-Forwarded-Proto, X-Real-IP and Host. Caddy already sets the X-Forwarded-*
family and Host by default; this makes them explicit and adds X-Real-IP
(nginx convention) for backends that expect it. The https:// transport block
is preserved alongside the headers.
2026-06-09 11:39:45 +02:00
1dba721a9a feat(ui): light-mode sky palette for Caddy card, favicon, doc sync
- index.css: add :root.light overrides for the sky-* accent used only by the
  Caddy settings card (buttons, badges, hovers) + the missing red-950/30 hover
- favicon: add public/favicon.svg (GhostGrid logo) and link it in index.html
- ARCHITECTURE.md: GET /caddy/routes returns a plain array, document the Caddy
  startup import, https:// upstream, favicon/public dir, and the SPA-catch-all-last
  + Cache-Control: no-store invariant
2026-06-08 14:51:36 +02:00
f6263ad2f3 feat(caddy): support HTTPS upstreams via https:// prefix
When a route's upstream starts with https://, buildCaddyfile emits a
transport http { tls_insecure_skip_verify } block so Caddy connects over TLS
and accepts the self-signed certificate typical of backends like Semaphore.
Added a UI hint explaining the https:// prefix.
2026-06-08 14:43:29 +02:00
6f621067b9 fix(server): register SPA catch-all last so /api GET routes are reachable
The static/SPA fallback (app.get('*')) was registered before the Caddy
routes, so every GET /api/caddy/* request was swallowed by the catch-all and
returned index.html instead of JSON. POST/PUT/DELETE still worked because
app.get('*') only matches GET — which is why adding routes worked but the
list was always empty. Move the static block to just before app.listen, and
add Cache-Control: no-store on /api so stale HTML can't be served via 304.
2026-06-08 14:37:33 +02:00
d429b2d252 refactor(caddy): flatten routes to a plain array like bookings
GET /api/caddy/routes now returns the route array directly instead of
{ system, custom }. Frontend state is CaddyRoute[] initialised to [],
rendered with a simple .map() and an empty-state message — mirroring how
bookings are loaded and displayed.
2026-06-08 14:08:57 +02:00
1526d25144 fix(caddy): decouple status check from routes fetch, use useEffect for load trigger
Routes now load immediately from DB without waiting for the Caddy Admin API
status check (which can take up to 2s timeout). A dedicated useEffect on
caddyEnabled replaces the unreliable fire-and-forget call inside loadSettings.
2026-06-08 13:59:03 +02:00
2857040803 docs(architecture): remove revision history, add PUT /caddy/routes/{id} to API reference 2026-06-08 13:40:36 +02:00
acadf8db7c fix(caddy): prevent duplicate routes and make status/routes fetches independent
POST /api/caddy/routes now returns 409 if the hostname already exists,
preventing duplicate DB entries that cause Caddy's "ambiguous site definition" error.

loadCaddyRoutes uses Promise.allSettled so a failure in the status check
can no longer silently prevent the routes list from loading.
2026-06-08 13:37:22 +02:00
250c347f58 feat(caddy): import Caddyfile routes on startup if table is empty
On every startup, if Caddy is already enabled but the caddy table has no
routes (e.g. after a re-deploy), importCaddyfileRoutes() is called so the
static GhostGrid entries from /etc/caddy/Caddyfile are seeded automatically.
Also ensures deploy.sh is executable via proxmox-ghostgrid.sh.
2026-06-08 13:17:48 +02:00
f66b1ca456 feat(caddy): route edit, system log entries, fix routes load timing
Add inline edit for custom routes (Pencil icon → inline form with all fields).
Log route add/update/delete/import to the logs table (type: system) so
operations appear in the Logbook. Fix loadCaddyRoutes() called without await
after settings save, causing a race between the success message and route list.
2026-06-08 13:04:01 +02:00
00cf5dd02d feat(caddy): auto-import Caddyfile on first enable; seed default admin user
When Caddy is enabled for the first time (caddy routes table empty),
importCaddyfileRoutes() reads /etc/caddy/Caddyfile and seeds all
hostname/upstream blocks as custom routes — no manual entry needed after deploy.

On first startup with an empty users table, a default admin user is created
(admin@ghostgrid.local / admin) so the system is immediately usable.
2026-06-08 10:09:26 +02:00
47e7b65613 chore: replace arrow glyphs with ASCII and tidy whitespace 2026-06-08 09:31:44 +02:00
e5e7c571a4 feat(settings): add database panel with info, backup and import
Add a Database section under Settings (split into Integrations/System
tabs) showing SQLite file size, last-modified date, a proportional
table-usage bar and per-table row counts. Supports downloading a
consistent backup and importing a .db file that overwrites the entire
database, with an explicit overwrite warning and confirmation.

Backend adds GET /api/database/info, GET /api/database/backup and
POST /api/database/import; DB_FILE is now exported from server-db.
2026-06-08 09:31:35 +02:00
f1200425af refactor(caddy): remove redundant GhostGrid domain fields, keep only custom routes
caddy_prod_domain and caddy_dev_domain are already handled by the Proxmox deploy
process. The Caddy integration is a generic TLS proxy for additional services
(Semaphore, Netbox, etc.) — the custom routes list is the sole mechanism.
2026-06-08 08:45:24 +02:00
7afb4829bc refactor(ui): comprehensive light mode fixes and dashboard cleanup
- Light mode: fix 40+ missing CSS overrides (solid emerald/cyan bg-950,
  300-level text colours, border opacity variants, hover states, violet
  accent, bg-slate-900/30 and /90, bg-rose-950/30, red-950/50)
- Light mode: fix broad bg-gradient-to-br override to only target dark
  banner cards (from-[#1E293B]), preserving coloured user avatar gradients
- Light mode: BookingDetailsModal JSON panel switched to GitHub-Light style
  (bg #f6f8fa) including <pre> override so the general 'pre' rule cannot
  darken it back
- Dashboard: simplify banner (flat card, no gradient/watermark/time-widget)
- Dashboard: reduce visual noise (shorter titles, remove LIVE animated badge,
  remove italic notes quote, neutral checklist items, no footer jargon)
- Dashboard: normalise section-icon colours to slate-400 except Active (emerald)
- Dashboard: replace non-standard Tailwind classes (slate-101/350/905/1000,
  indigo-405, emerald-990) with valid equivalents
- Dashboard: standardise button style to rounded-lg + text-xs across
  Active Reservations and Upcoming cards; add visible borders on Cancel/Purge
2026-06-05 11:08:34 +02:00
33c7b2ba65 fix(light-mode): JSON panel header dark, orange Ansible card overrides
- JSON panel header (bg-slate-900) no longer flips to light gray inside
  the dark terminal block — scoped override keeps it #161b22
- Title text and copy button styled consistently for dark context
- Orange Ansible status card gets proper light-mode colors (orange-50 bg,
  orange-200 border, orange-600 text)
- application/json badge tweaked to text-indigo-400 for consistency
2026-06-05 10:09:52 +02:00
aa5c2332e8 fix(ui): use semi-transparent indigo badge for application/json label 2026-06-05 10:03:46 +02:00
de4aef3d19 fix(logbook): remove 'All incl. System' filter, Ansible triggers as booking logs
Ansible trigger successes now logged as type 'booking' so they appear
in the default filter view. Removed the redundant 'All incl. System'
filter button.
2026-06-05 10:02:01 +02:00
7758bcaa02 refactor(ui): remove mock Ansible panel, settings in 3-column grid
BookingDetailsModal: remove static playbook template and fake simulator,
keep only the JSON REST response panel. Settings: drop max-w-2xl,
wrap integration cards in lg:grid-cols-3 so Azure, CheckMK and
Semaphore sit side by side on wide screens.
2026-06-05 09:54:54 +02:00
c428b12352 fix(semaphore): update modal status immediately after manual trigger
Local state tracks in-session triggers so the UI flips to 'Triggered'
without waiting for the parent to re-fetch bookings.
2026-06-05 09:44:33 +02:00
70399a00ec feat(semaphore): trigger Ansible tasks at booking start/end via Semaphore
- Background scheduler checks every 30s for bookings that need setup or teardown
- Per-lab Semaphore template IDs stored on the labs table
- Booking flags track which jobs have been triggered and their Semaphore job IDs
- Immediate teardown triggered when an active booking is cancelled
- Settings UI section for Semaphore API URL, token, and project ID
- Lab template form fields for setup/teardown template IDs
- BookingDetailsModal shows live Ansible job status with manual trigger buttons
2026-06-05 09:39:58 +02:00
11eb06c5ad fix(logbook): system filter as proper type button, default hides system entries 2026-06-05 09:20:52 +02:00
ea9e6c1d46 feat: CheckMK host link in inventory, system logs hidden by default in logbook 2026-06-05 09:16:08 +02:00
20308b53d6 fix(checkmk): correct columns query param format for /objects/host endpoint 2026-06-05 08:57:52 +02:00
744468c13d debug(checkmk): log full host response, add columns param to get state 2026-06-04 15:32:10 +02:00
985178ea84 fix(checkmk): per-host state lookup via /objects/host/{name}, remove batch collection call 2026-06-04 15:27:21 +02:00
15c4e3f6ac debug(checkmk): log raw monitoring response, remove query param, try multiple container keys 2026-06-04 15:22:00 +02:00
7731a1a9af fix(checkmk): add query filter to monitoring endpoint, precise permission guidance in log 2026-06-04 15:17:28 +02:00
9fba11ccd6 fix(checkmk): detect empty monitoring collection, log permission hint + host_config probe 2026-06-04 15:04:19 +02:00
789fe1f8e0 fix(checkmk): add diagnostic log to compare config vs monitoring host IDs 2026-06-04 14:57:19 +02:00
a58b321a50 fix: replace dynamic auth import with static import to silence Vite warning 2026-06-04 14:49:25 +02:00
626871213d fix(checkmk): use monitoring collection endpoint, batch state fetch, clearer Settings hints 2026-06-04 14:48:15 +02:00
59f11356ec fix(checkmk): readable error messages in Logbook, strip HTML from API errors 2026-06-04 14:30:25 +02:00
b223e6dfe9 fix: red error banner readable in light mode 2026-06-04 14:26:32 +02:00
e13e11ce6a feat: log login events in logbook; improve CheckMK error reporting
Successful logins now write a system-type logbook entry.
CheckMK sync reports configuration errors, host-fetch failures,
and per-device sync failures as logbook entries instead of silently
dropping them; inherits effective_attributes IP fallback.
2026-06-04 14:21:05 +02:00
1289e2476c fix: restore configurable redirect URI field – required when APP_URL is not set 2026-06-04 14:09:55 +02:00
f12f92aea8 feat: CheckMK global IP-based integration with enable toggle
Replace per-device CheckMK URL field with a global, IP-based lookup.
The sync job fetches all host configs from CheckMK once per cycle,
matches each device by IP address, and updates its status accordingly.
Devices not found in CheckMK are reset to 'unknown'.

- Add checkmk_enabled / checkmk_api_user settings; toggle in Settings
  mirrors the Entra ID pattern (fields dim when disabled)
- Sync job uses self-scheduling setTimeout so interval changes apply
  without a server restart; POST /api/checkmk/sync for manual triggers
- Status changes and a per-cycle summary are written to the Logbook
- Remove checkMkUrl from Device type, form, list view, and detail panel;
  status badge and CheckMK panel only render when CheckMK is enabled
- Booking offline warning suppressed when CheckMK is disabled
- Topology status dot color driven purely by device.status
2026-06-04 14:07:54 +02:00
e9fb79041e feat: inline label editing for topology links 2026-06-04 13:51:30 +02:00
5769b38f74 feat: env badge in header, inline desc edit, clean up deps
- Header now shows Production/Development with color-coded dot via import.meta.env.PROD
- LinkDashboard: click-to-edit description inline (blur/Enter to save, Escape to cancel)
- LoginPage: fix Azure button label to English
- Remove unused motion and autoprefixer dependencies
2026-06-04 13:42:13 +02:00
b7a3d2086d feat: allow booking offline devices, keep reachability warning 2026-06-04 13:40:32 +02:00
97e1b1a665 feat: Entra ID group restriction, remove redirect URI field, user delete + email edit 2026-06-04 13:10:56 +02:00
c879f84843 fix: lighter input styles in Settings, show required redirect URI for Azure 2026-06-03 16:35:21 +02:00
550acd27b2 fix(light-mode): add blue token overrides for Settings page 2026-06-03 16:21:40 +02:00
34c9822e42 style(settings): polish Settings page – accent gradients, status badges, better field layout 2026-06-03 16:18:36 +02:00
f7999cbe55 fix: remove role gate from Settings, all strings in English 2026-06-03 16:08:05 +02:00
d364aea4c1 feat: Entra ID login + settings page for integrations
- Add SQLite settings table with getSetting/setSetting/getAllSettings helpers
- Implement Azure OAuth2 authorization code flow via @azure/msal-node
- Add public GET /api/auth/config endpoint for frontend activation check
- Add admin-only GET/PUT /api/settings API with masked secret fields
- CheckMK sync reads credentials from DB settings (env vars as fallback)
- New Settings.tsx: Entra ID and CheckMK configuration cards
- LoginPage: "Sign in with Microsoft" button, shown only when Azure is active
- App.tsx: OAuth callback handling (?token=/?auth_error=), Settings tab for admins
2026-06-03 16:02:47 +02:00
26 changed files with 4083 additions and 722 deletions

849
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,849 @@
# GhostGrid
## Architecture Reference
**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.
---
## 1. Executive Summary
**GhostGrid** is an internal, offline-capable **network-lab and device-inventory tool** for managing hardware lab environments. Teams use it to keep a device inventory with live status, define lab templates (devices + topology), book labs for a time window, and automatically run Ansible playbooks at booking start/end. It pulls live device status from CheckMK and can manage Caddy reverse-proxy routes and Microsoft Entra ID SSO from its own UI.
### Key Design Decisions
| Aspect | Choice | Rationale |
|--------|--------|-----------|
| Scope | Single-tenant internal tool | Small team / lab operations, not multi-tenant SaaS |
| Process model | **One Node.js process** serving API + frontend | Simple to deploy, no orchestration needed |
| Backend | Express 4 + TypeScript | Minimal, well-understood, fast to iterate |
| Frontend | React 19 + Vite 6 | Modern SPA, no router dependency (tab state) |
| Database | **SQLite** (`better-sqlite3`, WAL) | Zero-ops, single file, synchronous, perfect for air-gapped LAN |
| Styling | Tailwind CSS v4 | Utility-first, dark/light theme via class toggle |
| Auth | Local JWT + optional Azure Entra ID OAuth | Self-contained, SSO optional |
| Offline | Fonts bundled via `@fontsource` | No CDN / external runtime assets |
| Integrations | CheckMK, Ansible Semaphore, Caddy | All configured at runtime in the Settings UI (stored in DB) |
| Deployment | Proxmox LXC + systemd, two instances | Manual `git pull && build && restart` model |
**Core constraint:** runs **fully offline**. No external code, assets, or CDN resources are loaded at runtime.
---
## 2. System Architecture
### 2.1 High-Level Architecture
```
+-----------------------------------------------------------------------------+
| GHOSTGRID PLATFORM |
+-----------------------------------------------------------------------------+
| +---------------------------------------------------------------------+ |
| | PRESENTATION LAYER | |
| | +-----------------------------+ +----------------------------+ | |
| | | React 19 SPA (Vite) | | Browser localStorage | | |
| | | - Tab-based navigation | | - ghostgrid_token (JWT) | | |
| | | - Tailwind dark/light | | - ghostgrid_user | | |
| | +-----------------------------+ +----------------------------+ | |
| +---------------------------------------------------------------------+ |
| | authFetch > Bearer <JWT> |
| +---------------------------------------------------------------------+ |
| | APPLICATION LAYER (server.ts) | |
| | Single Express process — serves API + frontend | |
| | +-----------+ +-----------+ +-----------+ +-----------+ +--------+ | |
| | | Auth | | Devices | | Labs | | Bookings | | Logs | | |
| | | (JWT/MSAL)| | CRUD | | CRUD | | CRUD | | | | |
| | +-----------+ +-----------+ +-----------+ +-----------+ +--------+ | |
| | +-----------+ +-----------+ +-----------+ +-----------+ | |
| | | Users | | Links | | Settings | | Caddy | | |
| | +-----------+ +-----------+ +-----------+ +-----------+ | |
| | +---------------------------------------------------------------+ | |
| | | Background jobs (self-rescheduling setTimeout loops) | | |
| | | - CheckMK status sync (default 60s) | | |
| | | - Semaphore setup/teardown trigger (30s) | | |
| | +---------------------------------------------------------------+ | |
| +---------------------------------------------------------------------+ |
| | |
| +---------------------------------------------------------------------+ |
| | DATA LAYER (server-db.ts) | |
| | +-------------------------------------------------------------+ | |
| | | SQLite — ghostgrid.db (better-sqlite3, WAL) | | |
| | | users · devices · labs · bookings · logs · links | | |
| | | settings · caddy | | |
| | +-------------------------------------------------------------+ | |
| +---------------------------------------------------------------------+ |
+-----------------------------------------------------------------------------+
| EXTERNAL INTEGRATIONS |
| +-------------+ +------------------+ +-----------+ +----------------+ |
| | CheckMK | | Ansible Semaphore| | Caddy | | Microsoft | |
| | REST API | | REST API | | Admin API | | Entra ID (MSAL)| |
| | (status) | | (playbook tasks) | | (/load) | | (OAuth 2.0) | |
| +-------------+ +------------------+ +-----------+ +----------------+ |
+-----------------------------------------------------------------------------+
```
### 2.2 Component Breakdown
#### 2.2.1 Presentation Layer
| Component | Technology | Purpose |
|-----------|------------|---------|
| Web UI | React 19 + TypeScript | Dashboard, booking calendar, inventory, topology, settings |
| Build/dev server | Vite 6 | Bundles the SPA; mounted as Express middleware in dev |
| Session store | Browser `localStorage` | Persists JWT + user between reloads |
#### 2.2.2 Application Layer (`server.ts` — single process)
| Route group | Responsibility |
|---------|----------------|
| Auth | Local register/login (JWT), Azure Entra ID OAuth, `/me`, public `/config` |
| Devices | Inventory CRUD; delete also scrubs the device from labs |
| Labs | Lab-template CRUD; `deviceIds`/`topology` stored as JSON |
| Bookings | Reservation CRUD; cancellation can trigger Semaphore teardown |
| Logs | Audit/maintenance journal (read + manual create) |
| Users | Team list, edit, delete (self/last-user guarded) |
| Links | Shared quick-links dashboard CRUD |
| Settings | Integration config (Azure, CheckMK, Semaphore, Caddy); secrets masked |
| CheckMK | Manual sync trigger |
| Semaphore | Template-list proxy, manual setup/teardown trigger |
| Caddy | Status, route CRUD, Caddyfile push |
| Background jobs | CheckMK sync loop + Semaphore trigger loop |
| Static serving | Vite middleware (dev) / static `dist/` + SPA fallback (prod) |
#### 2.2.3 Data Layer (`server-db.ts`)
| 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) |
| Settings store | key/value `settings` table | Runtime config for all integrations, seeded with `INSERT OR IGNORE` |
---
## 3. Technology Stack
### 3.1 Backend Stack
```
Node.js 20 LTS (TypeScript ~5.8, ES modules)
+-- Web Framework
| +-- express 4.21 (HTTP server, routing, JSON middleware)
| +-- vite 6 (createServer) (dev middleware mode, SPA)
+-- Auth & Security
| +-- jsonwebtoken 9 (JWT sign/verify, 24h expiry)
| +-- bcryptjs 2.4 (password hashing, cost 10)
| +-- @azure/msal-node 5 (Entra ID OAuth 2.0 auth-code flow)
+-- Data Access
| +-- better-sqlite3 12 (synchronous SQLite driver, WAL)
+-- Utilities
| +-- dotenv 17 (.env loading)
| +-- (global fetch) (CheckMK / Semaphore / Caddy HTTP calls)
+-- Build / Run
+-- tsx 4 (dev: run server.ts directly)
+-- esbuild 0.25 (bundle server > dist/server.cjs)
```
### 3.2 Frontend Stack
```
React 19 + TypeScript
+-- State Management
| +-- React hooks only (useState/useEffect in App.tsx — no Redux/Zustand)
| +-- localStorage (token + user persistence via src/lib/auth.ts)
+-- UI / Styling
| +-- tailwindcss 4 (@tailwindcss/vite plugin)
| +-- lucide-react 0.546 (icon set)
+-- Fonts (self-hosted, offline)
| +-- @fontsource/inter
| +-- @fontsource/jetbrains-mono
+-- Networking
| +-- fetch + authFetch() (thin wrapper injecting Authorization header)
+-- Build Tools
+-- vite 6 + @vitejs/plugin-react
```
### 3.3 Data Layer
```
SQLite (single file: ghostgrid.db, WAL mode)
+-- users (local + Azure-provisioned accounts)
+-- devices (inventory + CheckMK-synced status)
+-- labs (templates: deviceIds[] + topology[] as JSON)
+-- bookings (reservations + Ansible trigger flags/jobs)
+-- logs (audit/maintenance journal)
+-- links (shared quick-links dashboard)
+-- settings (key/value runtime config for integrations)
+-- caddy (custom reverse-proxy routes)
```
### 3.4 Infrastructure
```
Deployment
+-- Proxmox LXC (Debian 12, unprivileged)
+-- systemd services (ghostgrid + ghostgrid-dev)
+-- Two parallel instances (main:3000, dev:3001), separate DBs
+-- deploy/proxmox-ghostgrid.sh (one-shot installer)
+-- deploy/deploy.sh <branch> (git pull + build + restart)
Networking (optional, managed in-app)
+-- Caddy reverse proxy (local_certs, tls internal)
+-- Caddyfile pushed to Caddy Admin API /load
```
---
## 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.
### 4.1 Schema (as created in `server-db.ts`)
```sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'User',
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL -- bcrypt; '' for Azure-provisioned users
);
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
hostname TEXT NOT NULL,
ip TEXT NOT NULL,
location TEXT NOT NULL,
notes TEXT,
type TEXT NOT NULL, -- Switch | Firewall | Access-Point | Controller | custom
status TEXT NOT NULL, -- online | offline | unknown
emergencySheet TEXT NOT NULL, -- markdown
lastCheckedAt TEXT,
checkMkUrl TEXT NOT NULL DEFAULT '',
cmkHostname TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS labs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
contactPerson TEXT NOT NULL,
location TEXT NOT NULL,
deviceIds TEXT NOT NULL, -- JSON string: string[]
topology TEXT NOT NULL, -- JSON string: TopologyLink[]
semaphoreSetupTemplateId TEXT NOT NULL DEFAULT '',
semaphoreTeardownTemplateId TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS bookings (
id TEXT PRIMARY KEY,
labId TEXT NOT NULL,
userId TEXT NOT NULL,
startDateTime TEXT NOT NULL,
endDateTime TEXT NOT NULL,
notes TEXT,
status TEXT NOT NULL, -- active | upcoming | completed | cancelled
notified INTEGER NOT NULL DEFAULT 0,
emailSent INTEGER NOT NULL DEFAULT 0,
ansibleSetupTriggered INTEGER NOT NULL DEFAULT 0,
ansibleTeardownTriggered INTEGER NOT NULL DEFAULT 0,
ansibleSetupJobId TEXT NOT NULL DEFAULT '',
ansibleTeardownJobId TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS logs (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
type TEXT NOT NULL, -- maintenance | booking | status | system
message TEXT NOT NULL,
deviceId TEXT,
userId TEXT
);
CREATE TABLE IF NOT EXISTS links (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'emerald',
createdBy TEXT,
createdAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
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'))
);
```
### 4.2 Data-Model Notes
| Topic | Detail |
|-------|--------|
| IDs | App-generated strings: `` `${prefix}-${Date.now()}-${rand}` `` (`dev-…`, `lab-…`, `book-…`, `log-…`, `u-…`, `link-…`); `caddy` uses an autoincrement integer |
| 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 |
### 4.3 Settings (key/value config)
Seeded with `INSERT OR IGNORE` (defaults below). Secret keys are never returned raw — `maskSettings()` replaces them with the `__SET__` sentinel.
| Group | Keys (default) |
|-------|----------------|
| Azure | `azure_enabled`(false), `azure_client_id`, `azure_tenant_id`, `azure_client_secret`🔒, `azure_redirect_uri`, `azure_allowed_group` |
| CheckMK | `checkmk_enabled`(false), `checkmk_api_url`, `checkmk_api_user`(automation), `checkmk_api_secret`🔒, `checkmk_sync_interval_ms`(60000) |
| Semaphore | `semaphore_enabled`(false), `semaphore_api_url`, `semaphore_api_token`🔒, `semaphore_project_id` |
| Caddy | `caddy_enabled`(false), `caddy_admin_url`(http://localhost:2019) |
🔒 = in `SECRET_KEYS`, masked on read, only updated when a non-`__SET__` value is sent.
---
## 5. API Design
All `/api/*` routes return JSON. Every route except the public auth/config endpoints requires `requireAuth` (JWT bearer).
### 5.1 REST API Structure
```
/api
+-- /auth
| +-- POST /register # Create local account > { token, user } [public]
| +-- POST /login # Authenticate > { token, user } [public]
| +-- GET /me # Current user from token [auth]
| +-- GET /config # { azureEnabled, effectiveRedirectUri, [public]
| | # checkmkEnabled, checkmkBaseUrl }
| +-- GET /azure # Start Azure OAuth (redirect to Microsoft) [public]
| +-- GET /azure/callback # OAuth callback > redirect /?token=… [public]
|
+-- /settings
| +-- GET / # All settings (secrets masked as __SET__) [auth]
| +-- PUT / # Update allow-listed keys; re-push Caddy [auth]
|
+-- /users
| +-- GET / # List users [auth]
| +-- PUT /{id} # Update name/email (dupe-email guarded) [auth]
| +-- DELETE /{id} # Delete (not self, not last user) [auth]
|
+-- /devices
| +-- GET / # List devices [auth]
| +-- POST / # Create device (+ maintenance log) [auth]
| +-- PUT /{id} # Update device (+ maintenance log) [auth]
| +-- DELETE /{id} # Delete + scrub from all labs [auth]
|
+-- /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]
|
+-- /bookings
| +-- GET / # List bookings (int flags > booleans) [auth]
| +-- POST / # Create booking (+ log, alertGenerated) [auth]
| +-- PUT /{id} # Update status; cancel>teardown trigger [auth]
| +-- DELETE /{id} # Delete booking [auth]
|
+-- /logs
| +-- GET / # All logs, newest first [auth]
| +-- POST / # Manual log entry [auth]
|
+-- /links
| +-- GET / # Quick links (ordered category, title) [auth]
| +-- POST / # Create link [auth]
| +-- PUT /{id} # Update link [auth]
| +-- DELETE /{id} # Delete link [auth]
|
+-- /checkmk
| +-- POST /sync # Trigger CheckMK status sync now [auth]
|
+-- /semaphore
| +-- GET /templates # Proxy Semaphore task-template list [auth]
| +-- POST /trigger/{bookingId} # Manual setup|teardown for a booking [auth]
|
+-- /caddy
+-- GET /status # Caddy admin API reachable? [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]
```
### 5.2 Authentication & Authorization
```
Auth model
+-- Token: JWT (HS256, secret = JWT_SECRET), payload { userId, email }, expiry 24h
+-- Transport: Authorization: Bearer <jwt> (no cookies > no CSRF surface)
+-- 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
```
**Local flow:** `register` (bcrypt hash, role `User`) / `login` (bcrypt compare) > issue JWT > client stores token + user.
**Azure Entra ID (OAuth 2.0 auth-code flow):**
```
1. GET /api/auth/config > frontend learns azureEnabled, shows SSO button
2. GET /api/auth/azure > MSAL getAuthCodeUrl > 302 to Microsoft
3. GET /api/auth/azure/callback> acquireTokenByCode
> optional azure_allowed_group membership check
> upsert user (auto-provision, empty password)
> 302 /?token=<jwt>
4. App.tsx reads ?token= / ?auth_error=, verifies via /api/auth/me, persists
```
---
## 6. Integrations & Background Jobs
All three integrations are configured at runtime via the Settings UI (stored in the `settings` table). The background loops re-read settings each cycle, so CheckMK interval changes take effect without a restart.
### 6.1 CheckMK — Device Status Sync
```
Loop: scheduleSync() > syncCheckMkStatuses() > setTimeout(checkmk_sync_interval_ms)
(default 60s; skipped entirely if checkmk_enabled !== 'true')
Auth header: Authorization: Bearer <user> <secret>
Step 1 GET /domain-types/host_config/collections/all
> build IP > hostname map (checks attributes + effective_attributes)
Step 2 for each device:
- no CheckMK host for its IP > status 'unknown'
- GET /objects/host/{name}?columns=state…
state 0 > online
state 1 | 2 > offline
else > unknown
- update devices.status, lastCheckedAt, cmkHostname
- on change: write a 'status' log
Summary log per cycle: "<online> online, <offline> offline, <unknown> unknown"
HTTP hints: 401/403/404 mapped to actionable messages (checkmkHttpHint)
```
### 6.2 Ansible Semaphore — Playbook Automation
```
Loop: scheduleSemaphoreCheck() > checkAndTriggerAnsibleTasks() > setTimeout(30s)
(skipped if semaphore_enabled !== 'true')
Setup bookings WHERE startDateTime <= now AND ansibleSetupTriggered=0
AND status != 'cancelled' > trigger lab.semaphoreSetupTemplateId
Teardown bookings WHERE endDateTime <= now AND ansibleTeardownTriggered=0
AND status != 'cancelled' > trigger lab.semaphoreTeardownTemplateId
(also triggered immediately when a started booking is cancelled)
triggerSemaphoreTask(templateId, extraVars):
POST {apiUrl}/api/project/{projectId}/tasks
body { template_id, environment: JSON.stringify(extraVars) }
extraVars = { booking_id, lab_name, user_id, start_time, end_time }
> store returned job id on booking; log success/failure
(a booking with no template id is marked triggered > not retried)
Manual: POST /api/semaphore/trigger/{bookingId} body { type: 'setup'|'teardown' }
GET /api/semaphore/templates (proxy for UI dropdowns)
```
### 6.3 Caddy — Reverse Proxy
```
buildCaddyfile():
{ local_certs } # global block
per custom route { [encode] [tls internal] reverse_proxy <upstream> { … } }
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'
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
```
---
## 7. Frontend Architecture
### 7.1 Application Structure
```
src/
+-- main.tsx # React entry: imports fonts + index.css, renders <App/>
+-- App.tsx # Stateful root: auth gate, data loading, tab routing, all handlers
+-- index.css # Tailwind + theme tokens
+-- types.ts # Shared interfaces (Device, LabTemplate, Booking, …) — see §8
+-- vite-env.d.ts
+-- lib/
| +-- auth.ts # localStorage token/user, authFetch() wrapper, session helpers
+-- components/
+-- Header.tsx # Top bar; exports GhostGridLogo; notifications; theme/logout
+-- Dashboard.tsx # Active/upcoming bookings + quick-links widget
+-- BookingCalendar.tsx # Day-offset grid; create/cancel; conflict + online checks
+-- BookingDetailsModal.tsx # Booking detail; manual Semaphore trigger; cancel/delete
+-- DeviceInventory.tsx # List/detail; CRUD; markdown emergency sheet; CheckMK link
+-- LabTemplates.tsx # Lab CRUD + topology editor; embeds TopologyPanel
+-- TopologyPanel.tsx # Pure SVG (800x400) node/link renderer
+-- Logbook.tsx # Sorted/filtered log list + manual entry
+-- LinkDashboard.tsx # Quick-link CRUD; 6 accent colors; category grouping
+-- UserDirectory.tsx # Team list; avatar colors; edit/delete modal
+-- LoginPage.tsx # Local login + Azure SSO button (if enabled)
+-- RegisterPage.tsx # Self-registration form
+-- Settings.tsx # Integration config cards (Azure, CheckMK, Semaphore, Caddy)
```
### 7.2 State & Data Flow
`App.tsx` is the single stateful root (no router, no global store):
```
+-- Auth state: currentUser (from localStorage), authView (login|register), authChecked
+-- App data: users, devices, labs, bookings, logs, links (loaded in one Promise.all)
+-- UI state: activeTab, navCollapsed*, theme* (dark|light), notifications,
| selectedBookingForDetails, inventoryHighlightDevice, checkmk{Enabled,BaseUrl}
+-- Effects:
| +-- Startup token verify + OAuth ?token=/?auth_error= handling
| +-- Load data on login
| +-- Poll GET /api/devices every 30s (surface CheckMK-driven status changes)
| +-- Booking reminder check every 60s (fires once per upcoming booking ≤30min away)
+-- Handlers: handleAdd/Update/Delete* for bookings, devices, labs, links, users +
handleAddLogManually — call API via authFetch, update local state,
most then refetch /api/logs
(* persisted to localStorage)
```
**Navigation** is a plain `activeTab` switch. Groups: Dashboard / Lab Management (Booking, Inventory, Topology) / Resources (Quick Links, Team) / Audit (Logbook) / System (Settings).
### 7.3 Key UI Components
```
Dashboard
+-- Active / upcoming booking cards
+-- Quick-links widget
+-- Navigation shortcuts (to calendar, devices, labs, links)
Booking Calendar
+-- Day-offset grid
+-- Create booking with conflict detection + device-online validation
+-- (devices in 'unknown' status are not bookable when CheckMK enabled)
Device Inventory
+-- Searchable list + detail panel
+-- CRUD; class presets (Switch/Firewall/Access-Point/Controller) + free-form
+-- Markdown emergency sheet; optional CheckMK deep-link
Lab Templates + Topology
+-- Lab CRUD; Semaphore setup/teardown template selection
+-- Topology link editor (fromDevice > toDevice, link type)
+-- TopologyPanel: SVG layout by node count (1 / 2 / 3 / circular)
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;
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)
```
---
## 8. Shared Types (`src/types.ts`)
The single contract between frontend and backend — imported by **both** `server.ts` and the React components.
| Type | Notes |
|------|-------|
| `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 |
| `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) |
| `QuickLink` | `{ id, title, url, description, category, color, createdBy?, createdAt }` |
---
## 9. Deployment Architecture
### 9.1 Process & Build Model
```
Dev : npm run dev > tsx server.ts
Express + Vite middleware (HMR) on :3000
Build : npm run build
vite build > dist/ (frontend)
esbuild server.ts --bundle --platform=node --format=cjs
--packages=external > dist/server.cjs
Prod : NODE_ENV=production node dist/server.cjs
Express serves static dist/ + SPA fallback (GET * > dist/index.html)
```
### 9.2 Proxmox LXC + systemd (two instances)
```
+----------------------- Proxmox LXC (Debian 12) -----------------------+
| |
| Production Staging |
| +-------------------------+ +-----------------------------+ |
| | branch : main | | branch : dev | |
| | dir : /opt/ghostgrid| | dir : /opt/ghostgrid-dev | |
| | port : 3000 | | port : 3001 | |
| | service : ghostgrid | | service : ghostgrid-dev | |
| | db : ghostgrid.db | | db : ghostgrid.db (own) | |
| | .env : own JWT_SECRET| | .env : own JWT_SECRET | |
| +-------------------------+ +-----------------------------+ |
| |
| Both exposed directly on the LAN (no reverse proxy / TLS by default; |
| the in-app Caddy feature can add this). |
+-----------------------------------------------------------------------+
Install : deploy/proxmox-ghostgrid.sh (creates LXC, Node 20, clones both
branches, builds, configures services)
Update : deploy/deploy.sh <branch> (git pull + npm run build + systemctl restart;
defaults to main)
Backup : ghostgrid.db + ghostgrid.db-wal + ghostgrid.db-shm
```
### 9.3 Environment Variables
| Var | Default | Purpose |
|-----|---------|---------|
| `JWT_SECRET` | insecure fallback | Sign/verify JWTs — **must be set in production** |
| `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 |
---
## 10. Security Architecture
```
+-------------------------------------------------------------+
| SECURITY OVERVIEW |
+-------------------------------------------------------------+
| Authentication |
| +-- Local users: bcrypt password hashing (cost 10) |
| +-- JWT (HS256), 24h expiry, no refresh tokens |
| +-- Token in Authorization header (not cookies) |
| +-- Optional Azure Entra ID SSO (MSAL), group restriction |
+-------------------------------------------------------------+
| Authorization |
| +-- requireAuth on all data routes |
| +-- role column ('User'/'admin') exists |
| +-- ⚠ requireAdmin defined but NOT applied — any |
| authenticated user can read/write settings + users |
+-------------------------------------------------------------+
| Secret Handling |
| +-- Integration secrets stored in settings table |
| +-- Masked as __SET__ on read (SECRET_KEYS) |
| +-- Only overwritten when a non-sentinel value is sent |
+-------------------------------------------------------------+
| Notable gaps / accepted risks |
| +-- JWT_SECRET has an insecure fallback if unset |
| +-- POST /api/bookings trusts client-supplied userId |
| (does not force req.user.userId) |
| +-- No rate limiting on auth endpoints |
| +-- Secrets at rest are plaintext in SQLite (file perms |
| are the protection boundary) |
+-------------------------------------------------------------+
```
---
## 11. Project Structure
```
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); 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)
+-- .env.example # JWT_SECRET, APP_URL
+-- metadata.json # app name/description metadata
+-- README.md # user-facing overview
+-- DEPLOY.md # Proxmox LXC / systemd deployment guide
+-- ARCHITECTURE.md # ← this file
+-- deploy/
| +-- deploy.sh # git pull + build + systemctl restart (arg: branch)
| +-- ghostgrid.service # systemd unit — production (main, :3000)
| +-- ghostgrid-dev.service # systemd unit — staging (dev, :3001)
| +-- proxmox-ghostgrid.sh # one-shot LXC installer (Proxmox VE helper-script style)
+-- src/
+-- main.tsx # React entry
+-- App.tsx # root component (state, routing, handlers)
+-- index.css # Tailwind + theme
+-- types.ts # shared TS interfaces
+-- vite-env.d.ts
+-- lib/auth.ts # token storage + authFetch
+-- components/ # 14 components (see §7.1)
# Runtime artifacts (gitignored):
# ghostgrid.db, ghostgrid.db-wal, ghostgrid.db-shm, dist/, node_modules/
```
---
## 12. Key Technical Decisions Summary
| Decision | Choice | Rationale |
|----------|--------|-----------|
| **Process model** | Single Express process serves API + SPA | No orchestration; trivial LXC deployment |
| **Database** | SQLite (`better-sqlite3`, synchronous, WAL) | Zero-ops, single file, ideal for LAN/air-gap |
| **Auth** | JWT in `localStorage` + optional Azure MSAL | Self-contained; SSO optional, header-based (no CSRF) |
| **Frontend state** | React hooks only (no Redux/Zustand/router) | App is small; one stateful root is enough |
| **Runtime config** | Integration settings in DB, edited in Settings UI | No redeploy to change CheckMK/Semaphore/Caddy/Azure |
| **Background jobs** | Self-rescheduling `setTimeout` loops | Picks up settings changes each cycle; no cron/queue dep |
| **Styling** | Tailwind v4 + class-based dark/light | Utility-first, theme toggled on `<html>` |
| **Offline** | Fonts bundled via `@fontsource` | No CDN / external runtime fetches |
| **Build** | Vite (frontend) + esbuild (server bundle) | Fast, single `dist/server.cjs` output |
---
## 13. Operational Notes & Risk Mitigation
| Risk / Concern | Mitigation / Status |
|----------------|---------------------|
| Missing `JWT_SECRET` in prod | Documented in `.env.example`/DEPLOY.md; **set per instance** (installer generates a random one) |
| No admin RBAC enforced | `requireAdmin` exists — wire it onto `/api/settings` and `/api/users` if stricter control is needed |
| Client-supplied `userId` on booking create | Force `req.user.userId` server-side if spoofing is a concern |
| 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) |
---
## 14. Dependencies and Libraries
### 14.1 Backend (`dependencies`)
```
@azure/msal-node ^5.2.2 # Entra ID OAuth
@fontsource/inter ^5.2.8 # self-hosted font (used by frontend build)
@fontsource/jetbrains-mono ^5.2.8
@tailwindcss/vite ^4.1.14 # Tailwind v4 Vite plugin
@vitejs/plugin-react ^5.0.4
bcryptjs ^2.4.3 # password hashing
better-sqlite3 ^12.10.0 # SQLite driver
dotenv ^17.2.3 # .env loading
express ^4.21.2 # HTTP server
jsonwebtoken ^9.0.2 # JWT
lucide-react ^0.546.0 # icons
react / react-dom ^19.0.1
vite ^6.2.3
```
### 14.2 Dev / Build (`devDependencies`)
```
@types/bcryptjs, @types/better-sqlite3, @types/express,
@types/jsonwebtoken, @types/node # type definitions
esbuild ^0.25.0 # bundle server.ts > dist/server.cjs
tailwindcss ^4.1.14
tsx ^4.21.0 # run TS directly in dev
typescript ~5.8.2
```
### 14.3 npm Scripts
| Script | Command | Purpose |
|--------|---------|---------|
| `dev` | `tsx server.ts` | Dev server (Express + Vite middleware) on :3000 |
| `build` | `vite build && esbuild server.ts … --outfile=dist/server.cjs` | Build frontend + bundle server |
| `start` | `node dist/server.cjs` | Run production build (`NODE_ENV=production`) |
| `clean` | `rm -rf dist server.js` | Remove build artifacts |
| `lint` | `tsc --noEmit` | Type check |
---
## 15. Quick Mental Model (for future prompts)
```
Browser (React SPA, localStorage JWT)
│ authFetch > Authorization: Bearer <jwt>
Express (server.ts) ──► better-sqlite3 (ghostgrid.db, WAL)
├─ /api/auth/* (JWT local + Azure MSAL)
├─ /api/{devices,labs,bookings,logs,links,users,settings}
├─ /api/checkmk/* ── background: ~60s status sync
├─ /api/semaphore/* ── background: 30s setup/teardown trigger
└─ /api/caddy/* ── pushes Caddyfile to Caddy admin API
└─ serves frontend: Vite middleware (dev) / static dist/ (prod)
```
**Invariants to remember when editing:**
- Frontend and backend share `src/types.ts` — change both sides together.
- `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.
- 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**.
---
*Generated from the GhostGrid codebase. Keep this document in sync when the data model, API surface, or integrations change.*

View File

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

View File

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

View File

@ -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 the shared Caddy (one Caddy per container).
[[ "$d" == "${APP_DIR}" ]] && run "printf 'CADDY_MANAGER=true\n' >> ${d}/.env"
done
msg_ok ".env files created (main + dev)"

View File

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

131
package-lock.json generated
View File

@ -19,7 +19,6 @@
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3"
@ -30,7 +29,6 @@
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
@ -1629,43 +1627,6 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/autoprefixer": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
"integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"browserslist": "^4.28.2",
"caniuse-lite": "^1.0.30001787",
"fraction.js": "^5.3.4",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
"bin": {
"autoprefixer": "bin/autoprefixer"
},
"engines": {
"node": "^10 || ^12 || >=14"
},
"peerDependencies": {
"postcss": "^8.1.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -2323,47 +2284,6 @@
"node": ">= 0.6"
}
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
"integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.40.0",
"motion-utils": "^12.39.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@ -3077,47 +2997,6 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/motion": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz",
"integrity": "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.40.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
"integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.39.0"
}
},
"node_modules/motion-utils": {
"version": "12.39.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -3284,13 +3163,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@ -3813,7 +3685,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD",
"optional": true
},
"node_modules/tsx": {
"version": "4.22.4",

View File

@ -22,7 +22,6 @@
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3"
@ -33,7 +32,6 @@
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",

24
public/favicon.svg Normal file
View 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

View File

@ -1,7 +1,7 @@
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');
console.log(`[Database] Connecting to SQLite database at: ${DB_FILE}`);
const db = new Database(DB_FILE);
@ -26,7 +26,9 @@ db.exec(`
type TEXT NOT NULL,
status TEXT NOT NULL,
emergencySheet TEXT NOT NULL,
lastCheckedAt TEXT
lastCheckedAt TEXT,
checkMkUrl TEXT NOT NULL DEFAULT '',
cmkHostname TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS labs (
@ -36,7 +38,9 @@ db.exec(`
contactPerson TEXT NOT NULL,
location TEXT NOT NULL,
deviceIds TEXT NOT NULL,
topology TEXT NOT NULL
topology TEXT NOT NULL,
semaphoreSetupTemplateId TEXT NOT NULL DEFAULT '',
semaphoreTeardownTemplateId TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS bookings (
@ -47,8 +51,12 @@ db.exec(`
endDateTime TEXT NOT NULL,
notes TEXT,
status TEXT NOT NULL,
notified INTEGER DEFAULT 0,
emailSent INTEGER DEFAULT 0
notified INTEGER NOT NULL DEFAULT 0,
emailSent INTEGER NOT NULL DEFAULT 0,
ansibleSetupTriggered INTEGER NOT NULL DEFAULT 0,
ansibleTeardownTriggered INTEGER NOT NULL DEFAULT 0,
ansibleSetupJobId TEXT NOT NULL DEFAULT '',
ansibleTeardownJobId TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS logs (
@ -70,17 +78,93 @@ db.exec(`
createdBy TEXT,
createdAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
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'))
);
`);
// Lightweight migrations for columns added after the initial release.
// CREATE TABLE IF NOT EXISTS never alters an existing table, so add them by hand.
function ensureColumn(table: string, column: string, ddl: string) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
if (!cols.some(c => c.name === column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
}
// Seed default settings — INSERT OR IGNORE writes a key only if it is absent.
const DEFAULT_SETTINGS: Record<string, string> = {
azure_enabled: 'false',
azure_client_id: '',
azure_tenant_id: '',
azure_client_secret: '',
azure_redirect_uri: '',
azure_allowed_group: '',
checkmk_enabled: 'false',
checkmk_api_url: '',
checkmk_api_user: 'automation',
checkmk_api_secret: '',
checkmk_sync_interval_ms: '60000',
semaphore_enabled: 'false',
semaphore_api_url: '',
semaphore_api_token: '',
semaphore_project_id: '',
caddy_enabled: 'false',
caddy_admin_url: 'http://localhost:2019',
};
const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) seedSetting.run(key, value);
export function getSetting(key: string): string {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
return row?.value ?? '';
}
ensureColumn('devices', 'checkMkUrl', "checkMkUrl TEXT NOT NULL DEFAULT ''");
export function setSetting(key: string, value: string): void {
db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))").run(key, value);
}
export function getAllSettings(): Record<string, string> {
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
return Object.fromEntries(rows.map(r => [r.key, r.value]));
}
export interface CaddyRoute {
id: number;
hostname: string;
upstream: string;
tls: number;
compress: number;
created_at: string;
}
export function getCaddyRoutes(): CaddyRoute[] {
return db.prepare('SELECT * FROM caddy ORDER BY id ASC').all() as CaddyRoute[];
}
export function addCaddyRoute(hostname: string, upstream: string, tls: boolean, compress: boolean): CaddyRoute {
const { lastInsertRowid } = db.prepare(
'INSERT INTO caddy (hostname, upstream, tls, compress) VALUES (?, ?, ?, ?)'
).run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0);
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(lastInsertRowid) as CaddyRoute;
}
export function updateCaddyRoute(id: number, hostname: string, upstream: string, tls: boolean, compress: boolean): CaddyRoute {
db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ? WHERE id = ?')
.run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0, id);
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute;
}
export function deleteCaddyRoute(id: number): void {
db.prepare('DELETE FROM caddy WHERE id = ?').run(id);
}
export function getCaddyRouteById(id: number): CaddyRoute | undefined {
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute | undefined;
}
export default db;

852
server.ts
View File

@ -1,10 +1,13 @@
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 db from './server-db';
import DatabaseConstructor from 'better-sqlite3';
import { ConfidentialClientApplication } from '@azure/msal-node';
import db, { getSetting, setSetting, getAllSettings, getCaddyRoutes, addCaddyRoute, updateCaddyRoute, deleteCaddyRoute, getCaddyRouteById, DB_FILE } from './server-db';
import { Device, LabTemplate, Booking, LogEntry, User, QuickLink } from './src/types';
const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
@ -12,6 +15,11 @@ const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toStrin
const JWT_SECRET = process.env.JWT_SECRET || 'ghostgrid-dev-secret-change-in-production';
const JWT_EXPIRY = '24h';
// One Caddy serves the whole container; only the instance with CADDY_MANAGER=true
// owns it (pushes config, seeds routes, accepts route edits). The other instance
// must never push — POST /load replaces the entire config and would clobber it.
const IS_CADDY_MANAGER = process.env.CADDY_MANAGER === 'true';
interface JwtPayload {
userId: string;
email: string;
@ -41,12 +49,144 @@ function requireAuth(req: Request, res: Response, next: NextFunction) {
}
}
function requireAdmin(req: Request, res: Response, next: NextFunction) {
const row = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as { role: string } | undefined;
if (!row || row.role.toLowerCase() !== 'admin') {
res.status(403).json({ error: 'Admin access required.' });
return;
}
next();
}
function getMsalClient(): ConfidentialClientApplication | null {
const clientId = getSetting('azure_client_id');
const tenantId = getSetting('azure_tenant_id');
const secret = getSetting('azure_client_secret');
if (getSetting('azure_enabled') !== 'true' || !clientId || !tenantId || !secret) return null;
return new ConfidentialClientApplication({
auth: { clientId, authority: `https://login.microsoftonline.com/${tenantId}`, clientSecret: secret },
});
}
const SECRET_KEYS = ['azure_client_secret', 'checkmk_api_secret', 'semaphore_api_token'];
function maskSettings(raw: Record<string, string>): Record<string, string> {
const out = { ...raw };
for (const k of SECRET_KEYS) {
if (out[k]) out[k] = '__SET__';
}
return out;
}
function buildCaddyfile(): string {
const customRoutes = getCaddyRoutes();
const lines: string[] = ['{\n local_certs\n}', ''];
for (const route of customRoutes) {
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} {`);
// 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) {
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)')
.run(uid('log'), new Date().toISOString(), 'system',
`Caddy: imported ${imported.length} route(s) from Caddyfile — ${imported.join(', ')}`,
null, userId ?? null);
}
}
async function pushCaddyConfig(): Promise<void> {
if (!IS_CADDY_MANAGER) return;
if (getSetting('caddy_enabled') !== 'true') return;
const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019';
const body = buildCaddyfile();
const res = await fetch(`${adminUrl}/load`, {
method: 'POST',
headers: { 'Content-Type': 'text/caddyfile' },
body,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Caddy /load returned ${res.status}: ${text}`);
}
}
async function startServer() {
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_CADDY_MANAGER && 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
// -------------------------------------------------------------
@ -95,6 +235,10 @@ 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);
res.json({ token, user });
} catch (err: any) {
res.status(500).json({ error: err.message });
@ -113,6 +257,128 @@ async function startServer() {
}
});
// Public: frontend checks this before rendering the Azure login button
app.get('/api/auth/config', (_req, res) => {
const enabled = getSetting('azure_enabled') === 'true';
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 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',
checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''),
caddyManaged: IS_CADDY_MANAGER,
});
});
// Start Azure OAuth flow
app.get('/api/auth/azure', async (_req, res) => {
const msalClient = getMsalClient();
if (!msalClient) {
return res.redirect('/?auth_error=Azure+login+not+configured');
}
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`;
const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
try {
const authCodeUrl = await msalClient.getAuthCodeUrl({
scopes: ['openid', 'profile', 'email'],
redirectUri,
});
res.redirect(authCodeUrl);
} catch (err: any) {
console.error('[Azure Auth] getAuthCodeUrl error:', err);
res.redirect('/?auth_error=Failed+to+start+Microsoft+login');
}
});
// Azure OAuth callback
app.get('/api/auth/azure/callback', async (req, res) => {
const { code, error, error_description } = req.query;
if (error) {
const msg = encodeURIComponent(String(error_description || error));
return res.redirect(`/?auth_error=${msg}`);
}
if (!code) {
return res.redirect('/?auth_error=No+authorization+code+received');
}
const msalClient = getMsalClient();
if (!msalClient) {
return res.redirect('/?auth_error=Azure+login+not+configured');
}
const appUrl = process.env.APP_URL || `http://localhost:${PORT}`;
const redirectUri = getSetting('azure_redirect_uri') || `${appUrl}/api/auth/azure/callback`;
try {
const result = await msalClient.acquireTokenByCode({
code: String(code),
scopes: ['openid', 'profile', 'email'],
redirectUri,
});
const email = (result.account?.username ?? '').toLowerCase();
const name = result.account?.name || email;
if (!email) {
return res.redirect('/?auth_error=No+email+returned+by+Microsoft');
}
const allowedGroup = getSetting('azure_allowed_group');
if (allowedGroup) {
const claims = result.idTokenClaims as { groups?: string[] } | undefined;
if (!claims?.groups?.includes(allowedGroup)) {
return res.redirect('/?auth_error=Not+a+member+of+the+required+group');
}
}
let user = db.prepare('SELECT id, name, role, email FROM users WHERE email = ?').get(email) as User | undefined;
if (!user) {
const id = uid("u");
db.prepare('INSERT INTO users (id, name, role, email, password_hash) VALUES (?, ?, ?, ?, ?)')
.run(id, name, 'User', email, '');
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 });
res.redirect(`/?token=${encodeURIComponent(token)}`);
} catch (err: any) {
console.error('[Azure Auth] acquireTokenByCode error:', err);
res.redirect('/?auth_error=Authentication+failed');
}
});
// -------------------------------------------------------------
// RESTFUL API: Settings (admin only)
// -------------------------------------------------------------
app.get('/api/settings', requireAuth, (_req, res) => {
try {
res.json(maskSettings(getAllSettings()));
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/settings', requireAuth, (req, res) => {
try {
const allowed = ['azure_enabled', 'azure_client_id', 'azure_tenant_id', 'azure_client_secret',
'azure_redirect_uri', 'azure_allowed_group', 'checkmk_enabled', 'checkmk_api_url', 'checkmk_api_user',
'checkmk_api_secret', 'checkmk_sync_interval_ms',
'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) {
res.status(500).json({ error: err.message });
}
});
// -------------------------------------------------------------
// RESTFUL API: Users
// -------------------------------------------------------------
@ -125,6 +391,41 @@ async function startServer() {
}
});
app.put('/api/users/:id', requireAuth, (req, res) => {
try {
const id = req.params.id;
const { name, email } = req.body as { name?: string; email?: string };
if (!name && !email) return res.status(400).json({ error: 'Nothing to update.' });
const existing = db.prepare('SELECT id, name, email FROM users WHERE id = ?').get(id) as User | undefined;
if (!existing) return res.status(404).json({ error: 'User not found.' });
if (email && email !== existing.email) {
const dupe = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, id);
if (dupe) return res.status(409).json({ error: 'Email already in use.' });
}
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;
res.json(updated);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.delete('/api/users/:id', requireAuth, (req, res) => {
try {
const id = req.params.id;
if (id === req.user!.userId) return res.status(400).json({ error: 'You cannot delete your own account.' });
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'User not found.' });
const count = (db.prepare('SELECT COUNT(*) as n FROM users').get() as { n: number }).n;
if (count <= 1) return res.status(400).json({ error: 'Cannot delete the last user.' });
db.prepare('DELETE FROM users WHERE id = ?').run(id);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// -------------------------------------------------------------
// RESTFUL API: Devices / Inventory
// -------------------------------------------------------------
@ -139,16 +440,16 @@ async function startServer() {
app.post('/api/devices', requireAuth, (req, res) => {
try {
const { hostname, ip, location, notes, type, status, emergencySheet, checkMkUrl, lastCheckedAt } = req.body;
const { hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt } = req.body;
if (!hostname || !ip || !type) {
return res.status(400).json({ error: 'Missing required device specifications.' });
}
const id = uid("dev");
db.prepare(`
INSERT INTO devices (id, hostname, ip, location, notes, type, status, emergencySheet, checkMkUrl, lastCheckedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, hostname, ip, location || '', notes || '', type, status || 'unknown', emergencySheet || '', checkMkUrl || '', lastCheckedAt || null);
INSERT INTO devices (id, hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt)
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 (?, ?, ?, ?, ?, ?)`)
@ -166,12 +467,12 @@ async function startServer() {
app.put('/api/devices/:id', requireAuth, (req, res) => {
try {
const id = req.params.id;
const { hostname, ip, location, notes, type, status, emergencySheet, checkMkUrl, lastCheckedAt, operatorName } = req.body;
const { hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt, operatorName } = req.body;
db.prepare(`
UPDATE devices SET hostname = ?, ip = ?, location = ?, notes = ?, type = ?, status = ?, emergencySheet = ?, checkMkUrl = ?, lastCheckedAt = ?
UPDATE devices SET hostname = ?, ip = ?, location = ?, notes = ?, type = ?, status = ?, emergencySheet = ?, lastCheckedAt = ?
WHERE id = ?
`).run(hostname, ip, location, notes, type, status, emergencySheet, checkMkUrl ?? '', lastCheckedAt ?? null, id);
`).run(hostname, ip, location, notes, type, status, emergencySheet, lastCheckedAt ?? null, id);
const logId = uid("log");
const operatorText = operatorName ? `${operatorName} finished ` : 'Updated ';
@ -227,7 +528,9 @@ async function startServer() {
const labs: LabTemplate[] = rows.map(r => ({
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)
deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology),
semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '',
semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '',
}));
res.json(labs);
} catch (err: any) {
@ -237,14 +540,14 @@ async function startServer() {
app.post('/api/labs', requireAuth, (req, res) => {
try {
const { name, description, contactPerson, location, deviceIds, topology } = req.body;
const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId } = req.body;
if (!name || !deviceIds || !Array.isArray(deviceIds)) {
return res.status(400).json({ error: 'Missing name or associated device configurations.' });
}
const id = uid("lab");
db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology) VALUES (?, ?, ?, ?, ?, ?, ?)`)
.run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []));
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 || '');
const logId = uid("log");
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
@ -253,7 +556,7 @@ async function startServer() {
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) });
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 || '' });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
@ -262,10 +565,10 @@ async function startServer() {
app.put('/api/labs/:id', requireAuth, (req, res) => {
try {
const id = req.params.id;
const { name, description, contactPerson, location, deviceIds, topology } = req.body;
const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId } = req.body;
db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ? WHERE id = ?`)
.run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), id);
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 logId = uid("log");
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
@ -273,7 +576,7 @@ async function startServer() {
`Modified the active topology mapping schema for the "${name}" lab template.`, 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) });
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 || '' });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
@ -309,7 +612,11 @@ async function startServer() {
id: r.id, labId: r.labId, userId: r.userId,
startDateTime: r.startDateTime, endDateTime: r.endDateTime,
notes: r.notes || '', status: r.status as any,
notified: r.notified === 1, emailSent: r.emailSent === 1
notified: r.notified === 1, emailSent: r.emailSent === 1,
ansibleSetupTriggered: r.ansibleSetupTriggered === 1,
ansibleTeardownTriggered: r.ansibleTeardownTriggered === 1,
ansibleSetupJobId: r.ansibleSetupJobId || '',
ansibleTeardownJobId: r.ansibleTeardownJobId || '',
}));
res.json(bookings);
} catch (err: any) {
@ -348,7 +655,7 @@ async function startServer() {
}
});
app.put('/api/bookings/:id', requireAuth, (req, res) => {
app.put('/api/bookings/:id', requireAuth, async (req, res) => {
try {
const id = req.params.id;
const { status, operatorName } = req.body;
@ -359,16 +666,38 @@ async function startServer() {
db.prepare('UPDATE bookings SET status = ? WHERE id = ?').run(status, id);
if (status === 'cancelled') {
const lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(booking.labId) as { name: string } | undefined;
const lab = db.prepare('SELECT name, semaphoreTeardownTemplateId FROM labs WHERE id = ?').get(booking.labId) as { name: string; semaphoreTeardownTemplateId: string } | undefined;
const logId = uid("log");
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);
// Trigger teardown if booking had already started and teardown not yet triggered
const now = new Date();
if (new Date(booking.startDateTime) <= now && !booking.ansibleTeardownTriggered) {
const templateId = lab?.semaphoreTeardownTemplateId;
if (templateId) {
const jobId = await triggerSemaphoreTask(Number(templateId), {
booking_id: booking.id, lab_name: lab?.name || '', user_id: booking.userId,
start_time: booking.startDateTime, end_time: booking.endDateTime,
});
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?')
.run(jobId !== null ? String(jobId) : '', booking.id);
}
}
}
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
res.json({ id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime, endDateTime: r.endDateTime, notes: r.notes || '', status: r.status, notified: r.notified === 1, emailSent: r.emailSent === 1 });
res.json({
id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime,
endDateTime: r.endDateTime, notes: r.notes || '', status: r.status,
notified: r.notified === 1, emailSent: r.emailSent === 1,
ansibleSetupTriggered: r.ansibleSetupTriggered === 1,
ansibleTeardownTriggered: r.ansibleTeardownTriggered === 1,
ansibleSetupJobId: r.ansibleSetupJobId || '',
ansibleTeardownJobId: r.ansibleTeardownJobId || '',
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
@ -486,7 +815,452 @@ async function startServer() {
});
// -------------------------------------------------------------
// VITE / STATIC SERVING
// DATABASE API
// -------------------------------------------------------------
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
// Looks up each device by IP address in CheckMK's host_config collection,
// then fetches the monitoring state. Devices not found in CheckMK are reset
// to 'unknown'. Runs on a self-scheduling setTimeout so that interval changes
// in Settings take effect on the next cycle without a server restart.
// -------------------------------------------------------------
function checkmkHttpHint(status: number): string {
if (status === 401) return 'HTTP 401 Unauthorized - wrong automation user or secret (check Settings > CheckMK)';
if (status === 403) return 'HTTP 403 Forbidden - automation user lacks permission in CheckMK';
if (status === 404) return 'HTTP 404 Not Found - API URL incorrect or site name wrong';
return `HTTP ${status}`;
}
async function syncCheckMkStatuses() {
const now = new Date().toISOString();
if (getSetting('checkmk_enabled') !== 'true') return;
const CHECKMK_API_URL = getSetting('checkmk_api_url') || process.env.CHECKMK_API_URL;
const CHECKMK_API_USER = getSetting('checkmk_api_user') || process.env.CHECKMK_API_USER || 'automation';
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.');
return;
}
const authHeader = `Bearer ${CHECKMK_API_USER} ${CHECKMK_API_SECRET}`;
const headers = { Authorization: authHeader, Accept: 'application/json' };
// Step 1: build IP > hostname map from host configuration
// Checks both attributes (explicitly set) and effective_attributes (inherited).
let ipToHostname: Map<string, string>;
try {
const cfgRes = await fetch(
`${CHECKMK_API_URL}/domain-types/host_config/collections/all`,
{ headers }
);
if (!cfgRes.ok) throw new Error(checkmkHttpHint(cfgRes.status));
const cfgData = await cfgRes.json();
ipToHostname = new Map<string, string>();
for (const host of cfgData?.value ?? []) {
const ext = host?.extensions;
const ip: string | undefined =
ext?.attributes?.ipaddress || ext?.effective_attributes?.ipaddress;
const name: string | undefined = host?.id;
if (ip && name) ipToHostname.set(ip, name);
}
} 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);
return;
}
// Step 2: for each device, look up its CheckMK hostname by IP, then query the
// individual monitoring object at /objects/host/{name} for the live state.
// The collection endpoint (/domain-types/host/collections/all) only returns
// minimal fields without state, so per-host calls are required.
const rows = db.prepare('SELECT id, hostname, ip, status FROM devices').all() as { id: string; hostname: string; ip: string; status: string }[];
const counts = { online: 0, offline: 0, unknown: 0 };
for (const dev of rows) {
const cmkHost = ipToHostname.get(dev.ip);
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);
}
counts.unknown++;
continue;
}
try {
const hostRes = await fetch(
`${CHECKMK_API_URL}/objects/host/${encodeURIComponent(cmkHost)}?columns=state&columns=hard_state&columns=has_been_checked`,
{ headers }
);
if (!hostRes.ok) throw new Error(checkmkHttpHint(hostRes.status));
const hostData = await hostRes.json();
const state: number = hostData?.extensions?.state ?? -1;
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);
}
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);
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).`);
}
async function scheduleSync() {
await syncCheckMkStatuses();
const ms = Number(getSetting('checkmk_sync_interval_ms')) || 60_000;
setTimeout(scheduleSync, ms);
}
scheduleSync();
app.post('/api/checkmk/sync', requireAuth, async (_req, res) => {
try {
await syncCheckMkStatuses();
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// -------------------------------------------------------------
// ANSIBLE SEMAPHORE INTEGRATION
// Triggers Semaphore task templates at booking start (setup) and
// booking end (teardown). Uses the same self-rescheduling pattern
// 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.');
return null;
}
try {
const res = await fetch(`${apiUrl}/api/project/${projectId}/tasks`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ template_id: templateId, environment: JSON.stringify(extraVars) }),
});
if (!res.ok) {
const hint = res.status === 401 ? 'HTTP 401 Unauthorized — check API token'
: res.status === 403 ? 'HTTP 403 Forbidden — token lacks permission'
: res.status === 404 ? 'HTTP 404 — wrong project ID or Semaphore URL'
: `HTTP ${res.status}`;
throw new Error(hint);
}
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}).`);
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);
return null;
}
}
async function checkAndTriggerAnsibleTasks() {
if (getSetting('semaphore_enabled') !== 'true') return;
const now = new Date().toISOString();
// Bookings that have started but setup hasn't been triggered yet
const setupPending = db.prepare(
`SELECT b.*, l.semaphoreSetupTemplateId, l.name AS labName
FROM bookings b LEFT JOIN labs l ON b.labId = l.id
WHERE b.startDateTime <= ? AND b.ansibleSetupTriggered = 0 AND b.status != 'cancelled'`
).all(now) as any[];
for (const row of setupPending) {
const templateId = row.semaphoreSetupTemplateId;
if (!templateId) {
db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1 WHERE id = ?').run(row.id);
continue;
}
const jobId = await triggerSemaphoreTask(Number(templateId), {
booking_id: row.id, lab_name: row.labName || '', user_id: row.userId,
start_time: row.startDateTime, end_time: row.endDateTime,
});
db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1, ansibleSetupJobId = ? WHERE id = ?')
.run(jobId !== null ? String(jobId) : '', row.id);
}
// Bookings that have ended but teardown hasn't been triggered yet
const teardownPending = db.prepare(
`SELECT b.*, l.semaphoreTeardownTemplateId, l.name AS labName
FROM bookings b LEFT JOIN labs l ON b.labId = l.id
WHERE b.endDateTime <= ? AND b.ansibleTeardownTriggered = 0 AND b.status != 'cancelled'`
).all(now) as any[];
for (const row of teardownPending) {
const templateId = row.semaphoreTeardownTemplateId;
if (!templateId) {
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1 WHERE id = ?').run(row.id);
continue;
}
const jobId = await triggerSemaphoreTask(Number(templateId), {
booking_id: row.id, lab_name: row.labName || '', user_id: row.userId,
start_time: row.startDateTime, end_time: row.endDateTime,
});
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?')
.run(jobId !== null ? String(jobId) : '', row.id);
}
}
async function scheduleSemaphoreCheck() {
await checkAndTriggerAnsibleTasks();
setTimeout(scheduleSemaphoreCheck, 30_000);
}
scheduleSemaphoreCheck();
// Proxy Semaphore template list so the UI can populate dropdowns
app.get('/api/semaphore/templates', requireAuth, async (_req, res) => {
const apiUrl = getSetting('semaphore_api_url');
const token = getSetting('semaphore_api_token');
const projectId = getSetting('semaphore_project_id');
if (!apiUrl || !token || !projectId) {
return res.status(400).json({ error: 'Semaphore not fully configured.' });
}
try {
const r = await fetch(`${apiUrl}/api/project/${projectId}/templates`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!r.ok) return res.status(r.status).json({ error: `Semaphore returned HTTP ${r.status}` });
res.json(await r.json());
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Manual re-trigger for a specific booking (admin use / testing)
app.post('/api/semaphore/trigger/:bookingId', requireAuth, async (req, res) => {
try {
const { bookingId } = req.params;
const { type } = req.body as { type: 'setup' | 'teardown' };
const row = db.prepare(
`SELECT b.*, l.semaphoreSetupTemplateId, l.semaphoreTeardownTemplateId, l.name AS labName
FROM bookings b LEFT JOIN labs l ON b.labId = l.id WHERE b.id = ?`
).get(bookingId) as any;
if (!row) return res.status(404).json({ error: 'Booking not found.' });
const templateId = type === 'setup' ? row.semaphoreSetupTemplateId : row.semaphoreTeardownTemplateId;
if (!templateId) return res.status(400).json({ error: `No Semaphore ${type} template configured for this lab.` });
const jobId = await triggerSemaphoreTask(Number(templateId), {
booking_id: row.id, lab_name: row.labName || '', user_id: row.userId,
start_time: row.startDateTime, end_time: row.endDateTime,
});
if (type === 'setup') {
db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1, ansibleSetupJobId = ? WHERE id = ?')
.run(jobId !== null ? String(jobId) : '', bookingId);
} else {
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?')
.run(jobId !== null ? String(jobId) : '', bookingId);
}
res.json({ ok: true, jobId });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// -------------------------------------------------------------
// CADDY API
// -------------------------------------------------------------
app.get('/api/caddy/status', requireAuth, async (_req, res) => {
try {
const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019';
const r = await fetch(`${adminUrl}/config/`, { signal: AbortSignal.timeout(2000) });
res.json({ available: r.ok });
} catch {
res.json({ available: false });
}
});
app.get('/api/caddy/routes', requireAuth, (_req, res) => {
try {
res.json(getCaddyRoutes());
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/caddy/routes', requireAuth, async (req, res) => {
try {
if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
const { hostname, upstream, tls, compress } = req.body as {
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean;
};
if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' });
if (getCaddyRoutes().some(r => r.hostname === hostname.trim()))
return res.status(409).json({ error: `Route for ${hostname.trim()} already exists.` });
const route = addCaddyRoute(hostname.trim(), upstream.trim(), tls !== false, compress !== false);
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)')
.run(uid('log'), new Date().toISOString(), 'system',
`Caddy route added: ${hostname.trim()}${upstream.trim()}`, null, req.user!.userId);
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route add:', err.message));
res.json(route);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/caddy/routes/:id', requireAuth, async (req, res) => {
try {
if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
const id = Number(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid route id.' });
const { hostname, upstream, tls, compress } = req.body as {
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean;
};
if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' });
const route = updateCaddyRoute(id, hostname.trim(), upstream.trim(), tls !== false, compress !== false);
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)')
.run(uid('log'), new Date().toISOString(), 'system',
`Caddy route updated: ${hostname.trim()}${upstream.trim()}`, null, req.user!.userId);
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_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
const id = Number(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid route id.' });
const existing = getCaddyRouteById(id);
deleteCaddyRoute(id);
if (existing) {
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)')
.run(uid('log'), new Date().toISOString(), 'system',
`Caddy route deleted: ${existing.hostname}${existing.upstream}`, null, req.user!.userId);
}
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route delete:', err.message));
res.status(204).send();
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
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({
@ -502,40 +1276,6 @@ async function startServer() {
});
}
// -------------------------------------------------------------
// CYCLIC CHECKMK STATUS SYNC
// The device status shown in the UI is owned by CheckMK, not the app.
// This job runs on an interval and reconciles each *linked* device's status
// from the CheckMK REST API. The frontend additionally polls /api/devices,
// so anything written here surfaces in the inventory & booking screens.
// Until CHECKMK_API_URL/SECRET are configured, devices stay 'unknown' (and
// therefore not bookable) - which is the intended safe default.
// -------------------------------------------------------------
const CHECKMK_SYNC_INTERVAL_MS = Number(process.env.CHECKMK_SYNC_INTERVAL_MS) || 60_000;
const CHECKMK_API_URL = process.env.CHECKMK_API_URL; // e.g. https://checkmk.internal/<site>/check_mk/api/1.0
const CHECKMK_API_SECRET = process.env.CHECKMK_API_SECRET; // automation user secret
async function syncCheckMkStatuses() {
if (!CHECKMK_API_URL || !CHECKMK_API_SECRET) return; // not configured - leave statuses as 'unknown'
const rows = db.prepare("SELECT id, hostname, checkMkUrl FROM devices WHERE checkMkUrl != ''")
.all() as { id: string; hostname: string; checkMkUrl: string }[];
for (const dev of rows) {
try {
// TODO(checkmk): query the host's hard state from the CheckMK API using the
// automation secret, map 0 (UP) -> 'online' and anything else -> 'offline':
// const res = await fetch(`${CHECKMK_API_URL}/objects/host/${host}/actions/...`,
// { headers: { Authorization: `Bearer automation ${CHECKMK_API_SECRET}` } });
// const state = (await res.json()).extensions.state === 0 ? 'online' : 'offline';
// db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ? WHERE id = ?')
// .run(state, new Date().toISOString(), dev.id);
} catch (err) {
console.error(`[CheckMK] Status sync failed for ${dev.hostname}:`, err);
}
}
}
setInterval(syncCheckMkStatuses, CHECKMK_SYNC_INTERVAL_MS);
syncCheckMkStatuses();
app.listen(PORT, '0.0.0.0', () => {
console.log(`[Server] Core Server running at http://0.0.0.0:${PORT}`);
});

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { User, Device, LabTemplate, Booking, LogEntry, QuickLink } from './types';
import { authFetch, getToken, getStoredUser, clearSession } from './lib/auth';
import { authFetch, getToken, getStoredUser, clearSession, saveSession } from './lib/auth';
import Header, { GhostGridLogo } from './components/Header';
import Dashboard from './components/Dashboard';
import BookingCalendar from './components/BookingCalendar';
@ -12,10 +12,11 @@ import UserDirectory from './components/UserDirectory';
import BookingDetailsModal from './components/BookingDetailsModal';
import LoginPage from './components/LoginPage';
import RegisterPage from './components/RegisterPage';
import Settings from './components/Settings';
import {
LayoutDashboard, Calendar, Server, Layers, History, Link as LinkIcon, Users,
PanelLeftClose, PanelLeftOpen,
PanelLeftClose, PanelLeftOpen, Settings2,
} from 'lucide-react';
type AuthView = 'login' | 'register';
@ -47,10 +48,13 @@ export default function App() {
localStorage.setItem('ghostgrid_nav_collapsed', navCollapsed ? '1' : '0');
}, [navCollapsed]);
const [oauthError, setOauthError] = useState('');
const [notifications, setNotifications] = useState<string[]>([]);
const [remindedBookings, setRemindedBookings] = useState<Set<string>>(new Set());
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null);
const [checkmkEnabled, setCheckmkEnabled] = useState(false);
const [checkmkBaseUrl, setCheckmkBaseUrl] = useState('');
useEffect(() => {
const root = document.documentElement;
@ -59,14 +63,45 @@ export default function App() {
localStorage.setItem('ghostgrid_theme', theme);
}, [theme]);
// Verify stored token on startup
// Verify stored token on startup + handle OAuth callback (?token= / ?auth_error=)
useEffect(() => {
async function verifyToken() {
const token = getToken();
// Handle OAuth redirect params first
const params = new URLSearchParams(window.location.search);
const urlToken = params.get('token');
const urlError = params.get('auth_error');
if (urlToken || urlError) {
window.history.replaceState({}, '', '/');
}
if (urlError) {
setOauthError(decodeURIComponent(urlError));
setAuthChecked(true);
return;
}
const token = urlToken || getToken();
if (!token) {
setAuthChecked(true);
return;
}
if (urlToken) {
// Token came from OAuth callback verify and persist
try {
const res = await fetch('/api/auth/me', { headers: { Authorization: `Bearer ${urlToken}` } });
if (res.ok) {
const user = await res.json();
saveSession(urlToken, user);
setCurrentUser(user);
} else {
setOauthError('Login failed. Please try again.');
}
} catch {
setOauthError('Server unreachable. Please try again.');
} finally {
setAuthChecked(true);
}
return;
}
try {
const res = await authFetch('/api/auth/me');
if (res.ok) {
@ -92,13 +127,14 @@ export default function App() {
async function loadData() {
setLoading(true);
try {
const [usersRes, devicesRes, labsRes, bookingsRes, logsRes, linksRes] = await Promise.all([
const [usersRes, devicesRes, labsRes, bookingsRes, logsRes, linksRes, configRes] = await Promise.all([
authFetch('/api/users'),
authFetch('/api/devices'),
authFetch('/api/labs'),
authFetch('/api/bookings'),
authFetch('/api/logs'),
authFetch('/api/links'),
fetch('/api/auth/config'),
]);
if (usersRes.ok) setUsers(await usersRes.json());
@ -107,6 +143,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 || ''); }
} catch (err) {
console.error('[App] Failed to load data:', err);
} finally {
@ -304,6 +341,25 @@ export default function App() {
} catch (err) { console.error('[App] Error adding log:', err); }
};
// User handlers
const handleDeleteUser = async (id: string) => {
try {
const res = await authFetch(`/api/users/${id}`, { method: 'DELETE' });
if (res.ok) setUsers(prev => prev.filter(u => u.id !== id));
else { const d = await res.json(); throw new Error(d.error); }
} catch (err: any) { throw err; }
};
const handleUpdateUser = async (id: string, name: string, email: string) => {
try {
const res = await authFetch(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify({ name, email }) });
if (res.ok) {
const updated = await res.json();
setUsers(prev => prev.map(u => u.id === id ? updated : u));
} 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 {
@ -365,6 +421,12 @@ export default function App() {
{ id: 'logs', label: 'Logbook', icon: <History className="w-4 h-4 shrink-0" /> },
],
},
{
label: 'System',
items: [
{ id: 'settings', label: 'Settings', icon: <Settings2 className="w-4 h-4 shrink-0" /> },
],
},
];
// Startup check not done yet
@ -386,7 +448,7 @@ export default function App() {
if (authView === 'register') {
return <RegisterPage onLogin={handleLogin} onNavigateToLogin={() => setAuthView('login')} />;
}
return <LoginPage onLogin={handleLogin} onNavigateToRegister={() => setAuthView('register')} />;
return <LoginPage onLogin={handleLogin} onNavigateToRegister={() => setAuthView('register')} authError={oauthError} />;
}
// Loading data after login
@ -483,7 +545,7 @@ export default function App() {
<div className="text-[11px] text-slate-400 leading-relaxed font-sans space-y-0.5">
<div>Active: <span className="text-emerald-400 font-semibold font-mono">{bookings.filter(b => b.status === 'active').length}</span></div>
<div>Upcoming: <span className="text-indigo-400 font-semibold font-mono">{bookings.filter(b => b.status === 'upcoming').length}</span></div>
<div>Online: <span className="text-white font-semibold font-mono">{devices.filter(d => d.checkMkUrl && d.status === 'online').length}<span className="text-slate-500">/{devices.length}</span></span> devices</div>
<div>Online: <span className="text-white font-semibold font-mono">{devices.filter(d => d.status === 'online').length}<span className="text-slate-500">/{devices.length}</span></span> devices</div>
<div>Labs: <span className="text-white font-semibold font-mono">{labs.length}</span> configured</div>
</div>
<div className="h-1 bg-gradient-to-r from-emerald-500 to-teal-600 rounded-full w-full animate-pulse mt-2" /></div>
@ -512,6 +574,7 @@ export default function App() {
labs={labs}
devices={devices}
currentUser={currentUser}
checkmkEnabled={checkmkEnabled}
onAddBooking={handleAddBooking}
onCancelBooking={handleCancelBooking}
onDeleteBooking={handleDeleteBooking}
@ -521,6 +584,8 @@ export default function App() {
{activeTab === 'devices' && (
<DeviceInventory
devices={devices}
checkmkEnabled={checkmkEnabled}
checkmkBaseUrl={checkmkBaseUrl}
onAddDevice={handleAddDevice}
onUpdateDevice={handleUpdateDevice}
onDeleteDevice={handleDeleteDevice}
@ -550,6 +615,8 @@ export default function App() {
users={users}
currentUser={currentUser}
bookings={bookings}
onDeleteUser={handleDeleteUser}
onUpdateUser={handleUpdateUser}
/>
)}
{activeTab === 'logs' && (
@ -561,6 +628,9 @@ export default function App() {
onAddLog={handleAddLogManually}
/>
)}
{activeTab === 'settings' && (
<Settings currentUser={currentUser} />
)}
</main>
</div>

View File

@ -5,11 +5,10 @@ import {
X, Layers, Server, Clock, ChevronDown
} from 'lucide-react';
/** A device can only be reserved when CheckMK reports it online. */
function effectiveStatus(d: Device): 'online' | 'offline' | 'unknown' {
return d.checkMkUrl ? (d.status === 'online' || d.status === 'offline' ? d.status : 'unknown') : 'unknown';
return d.status;
}
function isBookable(d: Device): boolean {
function isOnline(d: Device): boolean {
return effectiveStatus(d) === 'online';
}
@ -18,6 +17,7 @@ interface BookingCalendarProps {
labs: LabTemplate[];
devices: Device[];
currentUser: User;
checkmkEnabled: boolean;
onAddBooking: (booking: Omit<Booking, 'id' | 'notified' | 'emailSent'>) => void;
onCancelBooking: (id: string) => void;
onDeleteBooking: (id: string) => void;
@ -73,6 +73,7 @@ export default function BookingCalendar({
labs,
devices,
currentUser,
checkmkEnabled,
onAddBooking,
onCancelBooking,
onDeleteBooking,
@ -156,11 +157,11 @@ export default function BookingCalendar({
return { hasConflict: false };
}
// Devices in the current selection that CheckMK does not report as online - these block the booking.
function blockingDevices(deviceIds: string[]): Device[] {
// Devices in the current selection that CheckMK does not report as online - shown as a warning only.
function offlineDevices(deviceIds: string[]): Device[] {
return deviceIds
.map(id => devices.find(d => d.id === id))
.filter((d): d is Device => !!d && !isBookable(d));
.filter((d): d is Device => !!d && !isOnline(d));
}
// ── available-now helpers for Quick Booking ────────────────────────────
@ -171,17 +172,14 @@ export default function BookingCalendar({
return { startMs: start.getTime(), endMs: end.getTime(), start, end };
}, [quickDuration]);
// A lab is quick-bookable only when every device is free AND reported online by CheckMK.
// A lab is quick-bookable when every device is free (regardless of online status).
const availableLabs = useMemo(() => labs.filter(lab =>
lab.deviceIds.length > 0 &&
lab.deviceIds.every(dId => {
const dev = devices.find(d => d.id === dId);
return !!dev && isBookable(dev) && !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs);
})
lab.deviceIds.every(dId => !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs))
), [labs, devices, bookings, quickWindow]);
const availableDevices = useMemo(() => devices.filter(dev =>
isBookable(dev) && !isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs)
!isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs)
), [devices, bookings, quickWindow]);
// ── booking actions ────────────────────────────────────────────────────
@ -191,12 +189,6 @@ export default function BookingCalendar({
const deviceIds = targetDeviceIds();
if (deviceIds.length === 0) { alert('Please select a resource to reserve.'); return; }
const blocked = blockingDevices(deviceIds);
if (blocked.length > 0) {
alert(`Not bookable: ${blocked.map(d => `"${d.hostname}" (${effectiveStatus(d)})`).join(', ')} ${blocked.length === 1 ? 'is' : 'are'} not online in CheckMK.`);
return;
}
const conflict = checkConflict(deviceIds, startDate, startTime, endDate, endTime);
if (conflict.hasConflict) { alert(conflict.message); return; }
@ -225,10 +217,6 @@ export default function BookingCalendar({
};
const handleQuickBookDevice = (device: Device) => {
if (!isBookable(device)) {
alert(`"${device.hostname}" is ${effectiveStatus(device)} in CheckMK and cannot be reserved.`);
return;
}
// Find or pick a lab that contains this device; fall back to device ID as labId marker
const hostLab = labs.find(l => l.deviceIds.includes(device.id));
onAddBooking({
@ -283,7 +271,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' })} &gt;{' '}
{new Date(Date.now() + quickDuration * 3600_000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
@ -312,16 +300,22 @@ export default function BookingCalendar({
<div className="px-5 pb-5 pt-3 max-h-72 overflow-y-auto space-y-2">
{quickTab === 'labs' ? (
availableLabs.length === 0 ? (
<p className="text-xs text-slate-400 text-center py-6 italic">Nothing free and fully online for {quickDuration}h right now. all boxes either leased or not reporting in.</p>
<p className="text-xs text-slate-400 text-center py-6 italic">Nothing free for {quickDuration}h right now. All slots leased.</p>
) : (
availableLabs.map(lab => {
const labDevices = lab.deviceIds.map(id => devices.find(d => d.id === id)).filter(Boolean) as Device[];
const offlineCount = labDevices.filter(d => !isOnline(d)).length;
return (
<div key={lab.id} className="flex items-center justify-between gap-3 p-3 bg-slate-900/60 border border-slate-800 rounded-lg hover:border-emerald-800/50 transition-all">
<div className="min-w-0">
<p className="text-xs font-bold text-white truncate">{lab.name}</p>
<p className="text-[10px] text-slate-400 font-mono">{lab.location} · {labDevices.length} device{labDevices.length !== 1 ? 's' : ''}</p>
<p className="text-[9px] text-slate-500 truncate mt-0.5">{labDevices.map(d => d.hostname).join(', ')}</p>
{offlineCount > 0 && (
<p className="flex items-center gap-0.5 text-[9px] text-amber-400 font-mono mt-0.5">
<AlertTriangle className="w-2.5 h-2.5 shrink-0" />{offlineCount} device{offlineCount !== 1 ? 's' : ''} not reachable
</p>
)}
</div>
<button
onClick={() => handleQuickBookLab(lab)}
@ -338,30 +332,30 @@ export default function BookingCalendar({
const status = effectiveStatus(device);
const online = status === 'online';
const free = !isDeviceBooked(device.id, quickWindow.startMs, quickWindow.endMs);
const bookable = online && free;
return (
<div key={device.id} className={`flex items-center justify-between gap-3 p-3 border rounded-lg transition-all ${
bookable ? 'bg-slate-900/60 border-slate-800 hover:border-emerald-800/50' : 'bg-slate-950/40 border-slate-900 opacity-60'
free ? 'bg-slate-900/60 border-slate-800 hover:border-emerald-800/50' : 'bg-slate-950/40 border-slate-900 opacity-60'
}`}>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={`w-1.5 h-1.5 rounded-full ${online ? 'bg-emerald-400' : status === 'offline' ? 'bg-rose-400' : 'bg-slate-500'}`} />
<p className="text-xs font-bold text-white font-mono">{device.hostname}</p>
{!online && free && (
<span className="flex items-center gap-0.5 text-[9px] text-amber-400 font-mono" title="Not reachable in CheckMK">
<AlertTriangle className="w-2.5 h-2.5" />{status}
</span>
)}
</div>
<p className="text-[10px] text-slate-400 font-mono">{device.type} · {device.ip}</p>
<p className="text-[9px] text-slate-500">{device.location}</p>
</div>
{bookable ? (
{free ? (
<button
onClick={() => handleQuickBookDevice(device)}
className="shrink-0 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold rounded-lg transition-all"
>
Book
</button>
) : !online ? (
<span className="shrink-0 flex items-center gap-1 text-[10px] text-amber-400 font-mono font-semibold capitalize" title="Not online in CheckMK - cannot be reserved">
<AlertTriangle className="w-3 h-3" />{status}
</span>
) : (
<span className="shrink-0 text-[10px] text-rose-400 font-mono font-semibold">Busy</span>
)}
@ -597,7 +591,7 @@ export default function BookingCalendar({
>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{isBookable(d) ? '🟢' : ''} {d.hostname} · {d.type} ({d.ip})
{isOnline(d) ? '🟢' : ''} {d.hostname} · {d.type} ({d.ip})
</option>
))}
</select>
@ -671,28 +665,31 @@ export default function BookingCalendar({
{(() => {
const deviceIds = targetDeviceIds();
const blocked = blockingDevices(deviceIds);
const offline = offlineDevices(deviceIds);
const conflict = checkConflict(deviceIds, startDate, startTime, endDate, endTime);
if (blocked.length > 0) {
if (conflict.hasConflict) {
return (
<div className="bg-amber-950/40 p-2.5 rounded border border-amber-800/60 flex gap-2 text-amber-300 text-[11px] leading-normal">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
<span>
Not bookable - {blocked.map(d => `${d.hostname} (${effectiveStatus(d)})`).join(', ')} {blocked.length === 1 ? 'is' : 'are'} not online in CheckMK. Hardware must be reachable before it can be reserved.
</span>
</div>
);
}
return conflict.hasConflict ? (
<div className="bg-rose-950/40 p-2.5 rounded border border-rose-900/60 flex gap-2 text-rose-300 text-[11px] leading-normal">
<AlertCircle className="w-4 h-4 text-rose-400 shrink-0" />
<span>{conflict.message}</span>
</div>
) : (
);
}
if (checkmkEnabled && offline.length > 0) {
return (
<div className="bg-amber-950/40 p-2.5 rounded border border-amber-800/60 flex gap-2 text-amber-300 text-[11px] leading-normal">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
<span>
Warning {offline.map(d => `${d.hostname} (${effectiveStatus(d)})`).join(', ')} {offline.length === 1 ? 'is' : 'are'} not reachable in CheckMK. Booking will still be created.
</span>
</div>
);
}
return (
<div className="bg-emerald-950/20 p-2.5 rounded border border-emerald-900/40 flex gap-2 text-emerald-300 text-[11px] leading-normal">
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
<span>Online & free. Timeframe is available.</span>
<span>Timeframe is available.</span>
</div>
);
})()}
@ -700,7 +697,6 @@ export default function BookingCalendar({
{(() => {
const deviceIds = targetDeviceIds();
const disabled = deviceIds.length === 0
|| blockingDevices(deviceIds).length > 0
|| checkConflict(deviceIds, startDate, startTime, endDate, endTime).hasConflict;
return (
<button

View File

@ -5,9 +5,10 @@
import React, { useState } from 'react';
import { Booking, LabTemplate, Device, User } from '../types';
import { authFetch } from '../lib/auth';
import {
X, Calendar, Clock, UserIcon, Database, Terminal, Cpu, Play, Check,
Trash2, Ban, Copy, CheckCircle, HelpCircle, HardDrive
X, Calendar, Clock, UserIcon, Database, Terminal, Play, Check,
Trash2, Ban, Copy, CheckCircle, HelpCircle, HardDrive,
} from 'lucide-react';
interface BookingDetailsModalProps {
@ -39,12 +40,43 @@ export default function BookingDetailsModal({
// Find devices mapped to this booking
const mappedDevices = devices.filter(d => lab?.deviceIds.includes(d.id));
// Developer panel tabs ('rest', 'ansible', 'terminal')
const [activeTab, setActiveTab] = useState<'rest' | 'ansible' | 'terminal'>('ansible');
const [triggerStatus, setTriggerStatus] = useState<string | null>(null);
const [triggering, setTriggering] = useState(false);
const [localSetupTriggered, setLocalSetupTriggered] = useState(false);
const [localSetupJobId, setLocalSetupJobId] = useState('');
const [localTeardownTriggered, setLocalTeardownTriggered] = useState(false);
const [localTeardownJobId, setLocalTeardownJobId] = useState('');
const setupTriggered = booking.ansibleSetupTriggered || localSetupTriggered;
const setupJobId = booking.ansibleSetupJobId || localSetupJobId;
const teardownTriggered = booking.ansibleTeardownTriggered || localTeardownTriggered;
const teardownJobId = booking.ansibleTeardownJobId || localTeardownJobId;
async function manualTrigger(type: 'setup' | 'teardown') {
setTriggering(true);
setTriggerStatus(null);
try {
const res = await authFetch(`/api/semaphore/trigger/${booking.id}`, {
method: 'POST',
body: JSON.stringify({ type }),
});
const data = await res.json();
if (!res.ok) {
setTriggerStatus(`Error: ${data.error || `HTTP ${res.status}`}`);
} else {
const jobId = data.jobId !== null ? String(data.jobId) : '';
setTriggerStatus(`Job #${data.jobId} triggered successfully.`);
if (type === 'setup') { setLocalSetupTriggered(true); setLocalSetupJobId(jobId); }
else { setLocalTeardownTriggered(true); setLocalTeardownJobId(jobId); }
}
} catch {
setTriggerStatus('Error: Network error.');
} finally {
setTriggering(false);
}
}
const [isCopied, setIsCopied] = useState(false);
const [isSimulating, setIsSimulating] = useState(false);
const [simulationLogs, setSimulationLogs] = useState<string[]>([]);
const [simStep, setSimStep] = useState(0);
const startFormatted = new Date(booking.startDateTime).toLocaleString('en-US', {
weekday: 'short',
@ -64,35 +96,6 @@ export default function BookingDetailsModal({
timeZoneName: 'short'
});
// Dynamic Ansible playbook string based on active nodes
const ipList = mappedDevices.map(d => d.ip);
const ansiblePlaybook = `---
- name: Reset GhostGrid Infrastructure Post-Reservation
hosts: localhost
gather_facts: false
vars:
target_nodes:
${ipList.map(ip => ` - "${ip}"`).join('\n') || ' - "No registered targets"'}
backup_repo: "https://git.ghostgrid.io/topology-configs"
tasks:
- name: Audit out-of-band diagnostic link states
ansible.builtin.ping:
register: ping_result
- name: Fetch designated golden config profile
ansible.builtin.get_url:
url: "{{ backup_repo }}/golden/${booking.labId}.cfg"
dest: "/tmp/golden_${booking.id}.cfg"
- name: Commit golden parameters & purge current stack
ansible.netcommon.net_config:
src: "/tmp/golden_${booking.id}.cfg"
replace: block
when: ping_result is succeeded
`;
// Dynamic REST Response
const mockJsonResponse = JSON.stringify({
retrievedAt: new Date().toISOString(),
apiEndpoint: `/api/bookings/${booking.id}`,
@ -108,57 +111,12 @@ ${ipList.map(ip => ` - "${ip}"`).join('\n') || ' - "No registered targ
}
}, null, 2);
const handleCopyText = (text: string) => {
navigator.clipboard.writeText(text);
const handleCopyJson = () => {
navigator.clipboard.writeText(mockJsonResponse);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
// Ansible Terminal simulation execution
const runAnsibleSimulation = () => {
if (isSimulating) return;
setIsSimulating(true);
setSimStep(1);
setSimulationLogs([
`[ansible-playbook -i localhost] Starting playbook: "Reset GhostGrid Infrastructure"`,
`[ansible-playbook] Configured target node list: ${ipList.join(', ') || 'None'}`
]);
setTimeout(() => {
setSimStep(2);
setSimulationLogs(prev => [
...prev,
`[localhost] TASK [Audit out-of-band diagnostic link states] **********************`,
...mappedDevices.map(d => `ok: [${d.hostname} (${d.ip})] ping_state=SUCCESS latency=1.2ms`)
]);
}, 1000);
setTimeout(() => {
setSimStep(3);
setSimulationLogs(prev => [
...prev,
`[localhost] TASK [Fetch designated golden config profile] *************************`,
`changed: [localhost] fetched golden profile for lab ID "${booking.labId}"`
]);
}, 2200);
setTimeout(() => {
setSimStep(4);
setSimulationLogs(prev => [
...prev,
`[localhost] TASK [Commit golden parameters & purge current stack] ******************`,
...mappedDevices.map(d => `changed: [${d.hostname}] configuration synced - cache invalidated - interfaces reset`),
`PLAY RECAP *************************************************************************`,
`localhost : ok=4 changed=2 unreachable=0 failed=0`
]);
setIsSimulating(false);
onAddLog({
type: 'maintenance',
message: `System Worker triggered an automated Ansible Golden Reset on reservation ${booking.id} (${lab?.name || 'Unknown'}). Checked ${mappedDevices.length} hosts.`
});
}, 3800);
};
return (
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" id="booking-details-modal">
<div className="bg-[#1E293B] border border-slate-705 w-full max-w-4xl rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150 flex flex-col max-h-[90vh]">
@ -294,115 +252,88 @@ ${ipList.map(ip => ` - "${ip}"`).join('\n') || ' - "No registered targ
</div>
{/* BELOW BLOCK: Restful API & Automation Integration developer panel */}
{/* Ansible Semaphore automation status */}
{(lab?.semaphoreSetupTemplateId || lab?.semaphoreTeardownTemplateId) && (
<div className="border border-orange-900/40 rounded-xl bg-orange-950/10 p-4 font-sans">
<div className="flex items-center gap-2 mb-3">
<Terminal className="w-4 h-4 text-orange-400" />
<span className="text-[10px] uppercase tracking-wider font-bold text-orange-400">Ansible Automation</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{lab.semaphoreSetupTemplateId && (
<div className="bg-slate-950/60 border border-slate-800 rounded-lg p-3 space-y-2">
<p className="text-[10px] text-slate-400 uppercase tracking-wide font-semibold">Setup</p>
{setupTriggered ? (
<div className="flex items-center gap-1.5 text-emerald-400 text-xs font-mono">
<CheckCircle className="w-3.5 h-3.5" />
{setupJobId ? `Job #${setupJobId}` : 'Triggered'}
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 font-mono">Pending</span>
<button
onClick={() => manualTrigger('setup')}
disabled={triggering || booking.status === 'cancelled'}
className="flex items-center gap-1 px-2 py-1 bg-orange-900/40 hover:bg-orange-800/40 border border-orange-800/50 text-orange-400 text-[10px] font-semibold rounded transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
<Play className="w-2.5 h-2.5" /> Trigger now
</button>
</div>
)}
</div>
)}
{lab.semaphoreTeardownTemplateId && (
<div className="bg-slate-950/60 border border-slate-800 rounded-lg p-3 space-y-2">
<p className="text-[10px] text-slate-400 uppercase tracking-wide font-semibold">Teardown</p>
{teardownTriggered ? (
<div className="flex items-center gap-1.5 text-emerald-400 text-xs font-mono">
<CheckCircle className="w-3.5 h-3.5" />
{teardownJobId ? `Job #${teardownJobId}` : 'Triggered'}
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 font-mono">Pending</span>
<button
onClick={() => manualTrigger('teardown')}
disabled={triggering || booking.status === 'cancelled'}
className="flex items-center gap-1 px-2 py-1 bg-orange-900/40 hover:bg-orange-800/40 border border-orange-800/50 text-orange-400 text-[10px] font-semibold rounded transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
<Play className="w-2.5 h-2.5" /> Trigger now
</button>
</div>
)}
</div>
)}
</div>
{triggerStatus && (
<p className={`mt-2 text-[11px] font-mono ${triggerStatus.startsWith('Error') ? 'text-red-400' : 'text-emerald-400'}`}>
{triggerStatus}
</p>
)}
</div>
)}
{/* JSON REST Response panel */}
<div className="border border-slate-800 rounded-xl overflow-hidden bg-slate-950 font-mono">
{/* Panel Tabs Header */}
<div className="bg-slate-900 border-b border-slate-850 px-4 py-2 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
<div className="bg-slate-900 border-b border-slate-800 px-4 py-2 flex items-center justify-between">
<span className="text-[10px] uppercase tracking-wider font-bold text-indigo-400 font-sans flex items-center gap-2">
<Terminal className="w-4 h-4 text-emerald-400" />
Developer Restful API & Ansible Integration
<Database className="w-3.5 h-3.5" />
GET /api/bookings/{booking.id}
</span>
<div className="flex gap-1">
<button
onClick={() => setActiveTab('ansible')}
className={`px-2.5 py-1 text-xs rounded transition-all font-sans font-semibold flex items-center gap-1 ${
activeTab === 'ansible' ? 'bg-emerald-950/80 border border-emerald-500/50 text-emerald-400' : 'text-slate-400 hover:text-white'
}`}
>
<Cpu className="w-3 h-3" /> Ansible Playbook
</button>
<button
onClick={() => setActiveTab('terminal')}
className={`px-2.5 py-1 text-xs rounded transition-all font-sans font-semibold flex items-center gap-1 ${
activeTab === 'terminal' ? 'bg-emerald-950/80 border border-emerald-500/50 text-emerald-400' : 'text-slate-400 hover:text-white'
}`}
>
<Play className="w-3 h-3" /> Reset-Simulator {isSimulating && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse ml-1" />}
</button>
<button
onClick={() => setActiveTab('rest')}
className={`px-2.5 py-1 text-xs rounded transition-all font-sans font-semibold flex items-center gap-1 ${
activeTab === 'rest' ? 'bg-emerald-950/80 border border-emerald-500/50 text-emerald-400' : 'text-slate-400 hover:text-white'
}`}
>
<Database className="w-3 h-3" /> JSON REST Response
</button>
<span className="text-indigo-400 bg-indigo-500/20 border border-indigo-500/30 px-1.5 py-0.5 rounded font-mono text-[9px]">application/json</span>
</div>
</div>
{/* Panel Content Box */}
<div className="p-4 bg-slate-950 text-xs leading-normal font-mono relative overflow-x-auto min-h-[180px] max-h-[300px] overflow-y-auto">
{/* Copy Overlay button */}
{activeTab !== 'terminal' && (
<div className="p-4 relative overflow-x-auto max-h-[260px] overflow-y-auto">
<button
onClick={() => handleCopyText(activeTab === 'ansible' ? ansiblePlaybook : mockJsonResponse)}
className="absolute top-4 right-4 bg-slate-900 border border-slate-800 hover:border-slate-700 hover:bg-slate-850 p-1.5 text-slate-400 hover:text-white rounded flex items-center gap-1 text-[10px] font-sans transition-all"
onClick={handleCopyJson}
className="absolute top-4 right-4 bg-slate-900 border border-slate-800 hover:border-slate-700 p-1.5 text-slate-400 hover:text-white rounded flex items-center gap-1 text-[10px] font-sans transition-all"
>
{isCopied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
<span>{isCopied ? 'Copied' : 'Copy'}</span>
</button>
)}
{activeTab === 'ansible' && (
<div className="space-y-3">
<div className="text-[10px] text-slate-500 font-sans mb-1 uppercase tracking-wider">
Use this playbook in your local cron or Ansible Tower instance to automatically sync devices post-session:
</div>
<pre className="text-emerald-400/90 whitespace-pre text-[11px] leading-relaxed select-all">
{ansiblePlaybook}
</pre>
</div>
)}
{activeTab === 'rest' && (
<div className="space-y-3">
<div className="text-[10px] text-slate-500 font-sans mb-1 uppercase tracking-wider flex items-center justify-between">
<span>GET Endpoint: /api/bookings/{booking.id}</span>
<span className="text-indigo-400 bg-indigo-950 border border-indigo-900 px-1 py-0.5 rounded font-mono text-[9px]">application/json</span>
</div>
<pre className="text-slate-300 text-[11px] leading-relaxed select-all">
<pre className="text-slate-300 text-[11px] leading-relaxed select-all pr-16">
{mockJsonResponse}
</pre>
</div>
)}
{activeTab === 'terminal' && (
<div className="space-y-3 flex flex-col h-full justify-between">
<div className="text-[10px] text-slate-500 font-sans mb-2 uppercase tracking-wider flex justify-between items-center bg-slate-900/40 p-2 border border-slate-900 rounded">
<span>Manual trigger simulation to verify post-booking hardware reset tasks</span>
<button
onClick={runAnsibleSimulation}
disabled={isSimulating}
className="px-2.5 py-1 bg-emerald-600 hover:bg-emerald-500 hover:cursor-pointer disabled:bg-slate-800 text-slate-950 font-sans font-bold text-[10px] rounded flex items-center gap-1 transition"
>
<Play className="w-3 h-3 fill-slate-950" />
<span>{isSimulating ? 'SIMULATING...' : 'RUN SIMULATOR'}</span>
</button>
</div>
<div className="bg-slate-1000 border border-slate-900 p-3 rounded-lg text-[11px] leading-6 space-y-1 font-mono text-slate-305 max-h-[180px] overflow-y-auto">
{simulationLogs.length === 0 ? (
<p className="text-slate-600 italic">Playbook simulator offline. Press "Run Simulator" above to run the automated Ansible pipeline check on the active SQLite nodes...</p>
) : (
simulationLogs.map((logLine, lIdx) => (
<div key={lIdx} className={`${
logLine.includes('failed=0') || logLine.includes('sync') ? 'text-emerald-400 font-semibold' :
logLine.includes('TASK') ? 'text-indigo-400 font-semibold mt-3' :
logLine.includes('Starting') ? 'text-slate-400' : 'text-slate-300'
}`}>
{logLine}
</div>
))
)}
</div>
</div>
)}
</div>
</div>
</div>

View File

@ -65,10 +65,10 @@ export default function Dashboard({
// Quick state checklist for the user to mark items as done as they test their lab!
// (kept deliberately nerdy - morale is a critical infrastructure dependency)
const [todoList, setTodoList] = useState([
{ id: 't1', text: 'Ping 8.8.8.8 to confirm the Internet still exists', checked: false },
{ id: 't2', text: 'Coffee level nominal ☕ - packets route faster on caffeine', checked: true },
{ id: 't3', text: "Rule out DNS first (narrator: it was, in fact, always DNS)", checked: false },
{ id: 't4', text: 'Layer-1 check: is it actually plugged in? (PEBKAC defense)', checked: false }
{ id: 't1', text: 'Verify network connectivity (ping gateway)', checked: false },
{ id: 't2', text: 'Coffee ready ☕', checked: true },
{ id: 't3', text: 'Check DNS resolution', checked: false },
{ id: 't4', text: 'Confirm physical connections are in place', checked: false }
]);
const toggleTodo = (id: string) => {
@ -91,21 +91,17 @@ export default function Dashboard({
return (
<div className="space-y-6" id="dashboard-cockpit-root">
{/* Banner Card Grid */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 bg-gradient-to-br from-[#1E293B] to-[#111827] border border-slate-800 rounded-2xl p-6 shadow-sm overflow-hidden relative">
<div className="absolute top-0 right-0 p-8 text-slate-800 pointer-events-none select-none font-mono text-9xl font-extrabold opacity-10">
NET
</div>
<div className="md:col-span-8 space-y-4">
<h2 className="text-2xl font-bold tracking-tight text-white leading-tight font-sans">
Welcome back, <span className="text-emerald-400">{currentUser.name}</span>!
{/* Banner */}
<div className="bg-[#1E293B] border border-slate-800 rounded-2xl p-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-xl font-bold tracking-tight text-white font-sans">
Welcome back, <span className="text-emerald-400">{currentUser.name}</span>
</h2>
<p className="text-xs text-slate-300 leading-relaxed font-sans max-w-xl">
Your lab cockpit. Grab some hardware, block a time slot, and keep the rescue runbooks one click away for when a switch decides to packet-storm itself at 16:59 on a Friday. root@ghostgrid:~# have fun, break things (in the lab).
<p className="text-xs text-slate-400 font-sans">
Your lab management hub. Reserve hardware, track active sessions, and keep runbooks close.
</p>
<div className="pt-2 flex items-center gap-3">
</div>
<div className="flex items-center gap-3 shrink-0">
<button
onClick={onNavigateToCalendar}
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-sans font-bold text-xs rounded-lg transition-colors flex items-center gap-1.5 hover:cursor-pointer"
@ -122,30 +118,6 @@ export default function Dashboard({
</div>
</div>
<div className="md:col-span-4 bg-slate-950/60 p-4 rounded-xl border border-slate-850 flex flex-col justify-between font-sans">
<div>
<span className="text-[10px] font-mono uppercase tracking-widest text-slate-500 block">System Time</span>
<div className="text-2xl font-mono text-emerald-400 font-bold mt-1 tabular-nums">
{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</div>
<p className="text-[10px] text-slate-400 font-sans mt-0.5">
{now.toLocaleDateString([], { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</p>
</div>
<div className="mt-4 pt-4 border-t border-slate-850 grid grid-cols-2 gap-2 text-center text-[10px] text-slate-350">
<div className="bg-slate-900 p-2 rounded border border-slate-850">
<span className="block font-bold text-slate-100 font-mono">{devices.length}</span>
<span>Hardware Nodes</span>
</div>
<div className="bg-slate-900 p-2 rounded border border-slate-850">
<span className="block font-bold text-slate-100 font-mono">{labs.length}</span>
<span>Available Labs</span>
</div>
</div>
</div>
</div>
{/* Main Grid Content */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
@ -156,28 +128,16 @@ export default function Dashboard({
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm">
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-4 font-sans justify-between">
<span className="flex items-center gap-2">
<Clock className="w-4.5 h-4.5 text-emerald-400" />
Active Reservations (your boxes, right now)
</span>
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono font-bold text-emerald-400 bg-emerald-950/40 border border-emerald-900/50 rounded-full px-2.5 py-0.5">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500"></span>
</span>
LIVE
<Clock className="w-4 h-4 text-emerald-400" />
Active Reservations
</span>
<span className="w-2 h-2 rounded-full bg-emerald-500 shrink-0" />
</h3>
{activeBookings.length === 0 ? (
<div className="text-center py-10 bg-slate-900/35 rounded-lg border border-slate-850 p-6 font-sans">
<PlayCircle className="w-8 h-8 text-slate-700 mx-auto mb-2 opacity-50" />
<p className="text-xs text-slate-400">No boxes checked out right now. idle hands, idle hardware.</p>
<button
onClick={onNavigateToCalendar}
className="text-xs text-emerald-400 font-semibold underline mt-2 hover:text-emerald-300"
>
grab a slot -&gt;
</button>
<div className="text-center py-8 bg-slate-900/35 rounded-lg border border-slate-800 font-sans">
<PlayCircle className="w-7 h-7 text-slate-600 mx-auto mb-2 opacity-50" />
<p className="text-xs text-slate-400">No active sessions.</p>
</div>
) : (
<div className="space-y-4 font-sans">
@ -209,28 +169,24 @@ export default function Dashboard({
</span>
</div>
<p className="text-xs text-slate-300 leading-relaxed font-sans mb-3 italic">
"{booking.notes || 'no notes - running blind'}"
</p>
<div className="pt-3 border-t border-slate-900/60 flex justify-between items-center text-[10px]">
<div className="pt-3 border-t border-slate-800/60 flex justify-between items-center text-[10px]">
<span className="font-mono text-slate-400">
Active window: {startF} - {endF}
{startF} {endF}
</span>
<div className="flex gap-2">
<button
onClick={() => onSelectBookingDetails(booking)}
className="px-2.5 py-1 bg-emerald-950/80 border border-emerald-500/30 text-emerald-400 hover:text-emerald-300 rounded flex items-center gap-1 transition-all font-semibold hover:cursor-pointer"
className="px-3 py-1.5 bg-emerald-950/80 border border-emerald-500/30 text-emerald-400 hover:text-emerald-300 rounded-lg text-xs font-semibold transition-all hover:cursor-pointer"
>
Inspect Details (Rest / Ansible)
Details
</button>
<button
onClick={() => {
if (confirm('Are you sure you want to release these nodes early? Hardware holds will terminate immediately.')) {
if (confirm('Release this reservation early?')) {
onCancelBooking(booking.id);
}
}}
className="px-2.5 py-1 bg-rose-950/40 border border-rose-700/50 text-rose-400 hover:bg-rose-900/60 hover:border-rose-500 hover:text-rose-300 rounded flex items-center gap-1 transition-all font-semibold hover:cursor-pointer"
className="px-3 py-1.5 bg-rose-950/40 border border-rose-700/50 text-rose-400 hover:bg-rose-900/60 hover:border-rose-500 hover:text-rose-300 rounded-lg text-xs font-semibold transition-all hover:cursor-pointer"
>
Release
</button>
@ -245,13 +201,13 @@ export default function Dashboard({
{/* Upcoming Sessions */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-slate-101 flex items-center gap-2 mb-3.5">
<Calendar className="w-4.5 h-4.5 text-indigo-400" />
Upcoming in the Queue ({upcomingBookings.length})
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-3.5">
<Calendar className="w-4 h-4 text-slate-400" />
Upcoming ({upcomingBookings.length})
</h3>
{upcomingBookings.length === 0 ? (
<p className="text-xs text-slate-400 py-4 italic text-center">Queue is empty. crontab clean, nothing scheduled.</p>
<p className="text-xs text-slate-400 py-4 text-center">No upcoming reservations.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{upcomingBookings.map((booking) => {
@ -260,14 +216,14 @@ export default function Dashboard({
const startF = new Date(booking.startDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
const endF = new Date(booking.endDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
return (
<div key={booking.id} className="p-3 bg-slate-905/30 border border-slate-850 hover:border-slate-800 rounded-lg flex flex-col justify-between">
<div key={booking.id} className="p-3 bg-slate-900/30 border border-slate-800 hover:border-slate-700 rounded-lg flex flex-col justify-between">
<div>
<div className="flex justify-between items-start mb-1">
<span className="font-mono font-bold text-[10px] text-indigo-405 bg-indigo-950/50 border border-indigo-900 px-2 py-0.5 rounded">
<span className="font-mono font-bold text-[10px] text-indigo-400 bg-indigo-950/50 border border-indigo-900/50 px-2 py-0.5 rounded">
{dayStr}
</span>
<span className="text-[10px] font-mono text-slate-500">
{startF} - {endF}
{startF} {endF}
</span>
</div>
<h4 className="text-xs font-bold text-slate-200 mt-1 font-sans">{lab?.name}</h4>
@ -276,32 +232,32 @@ export default function Dashboard({
</p>
</div>
<div className="pt-2 mt-2 border-t border-slate-850 flex justify-end gap-1.5 pt-2">
<div className="pt-2 mt-2 border-t border-slate-800 flex justify-end gap-1.5">
<button
onClick={() => onSelectBookingDetails(booking)}
className="px-2.5 py-1 text-[9px] text-emerald-400 hover:text-emerald-350 bg-emerald-950/40 border border-emerald-990/30 rounded font-semibold transition hover:cursor-pointer"
className="px-2.5 py-1 text-xs text-emerald-400 hover:text-emerald-300 bg-emerald-950/40 border border-emerald-900/30 rounded-lg font-semibold transition hover:cursor-pointer"
>
Specs / REST API
Details
</button>
<button
onClick={() => {
if (confirm('Cancel this upcoming reservation? Linked SMTP alerts will notify appropriate parties.')) {
if (confirm('Cancel this upcoming reservation?')) {
onCancelBooking(booking.id);
}
}}
className="px-2 py-1 text-[9px] text-slate-400 hover:text-white hover:bg-slate-800 rounded border border-transparent hover:cursor-pointer"
className="px-2.5 py-1 text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded-lg border border-slate-700/50 hover:cursor-pointer transition"
>
Cancel Slot
Cancel
</button>
<button
onClick={() => {
if (confirm('Are you sure you want to permanently delete this reservation from SQLite storage? This action cannot be reversed.')) {
if (confirm('Permanently delete this reservation?')) {
onDeleteBooking(booking.id);
}
}}
className="px-2 py-1 text-[9px] text-rose-400 hover:text-rose-300 hover:bg-rose-950/30 rounded border border-transparent hover:cursor-pointer"
className="px-2.5 py-1 text-xs text-rose-400 hover:text-rose-300 hover:bg-rose-950/30 rounded-lg border border-rose-900/30 hover:cursor-pointer transition"
>
Purge SQLite
Purge
</button>
</div>
</div>
@ -316,62 +272,58 @@ export default function Dashboard({
{/* RIGHT COLUMN: Checklist and simulated action panel */}
<div className="lg:col-span-4 space-y-6">
{/* Workflows Checklist */}
{/* Lab Checklist */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-slate-101 flex items-center gap-2 mb-3.5">
<ListTodo className="w-4.5 h-4.5 text-amber-500" />
Pre-Flight Checklist (before you blame the network)
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-3.5">
<ListTodo className="w-4 h-4 text-slate-400" />
Lab Checklist
</h3>
<div className="space-y-2.5">
<div className="space-y-2">
{todoList.map((item) => (
<div
key={item.id}
onClick={() => toggleTodo(item.id)}
className="flex items-start gap-2.5 p-2 bg-slate-1000/40 hover:bg-slate-900 rounded-lg cursor-pointer transition-all border border-slate-850/60"
className="flex items-start gap-2.5 p-2 bg-slate-900/40 hover:bg-slate-900 rounded-lg cursor-pointer transition-all border border-slate-800/60"
>
<input
type="checkbox"
checked={item.checked}
onChange={() => {}}
className="mt-0.5 rounded border-slate-800 text-emerald-500 focus:ring-emerald-450 w-3.5 h-3.5 shrink-0"
className="mt-0.5 rounded border-slate-700 text-emerald-500 w-3.5 h-3.5 shrink-0"
/>
<span className={`text-[11px] leading-tight ${item.checked ? 'text-slate-500 line-through' : 'text-slate-200'}`}>
<span className={`text-xs leading-tight ${item.checked ? 'text-slate-500 line-through' : 'text-slate-300'}`}>
{item.text}
</span>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-slate-850 text-[10px] text-slate-450 text-center">
Works on my machine (TM). check the boxes anyway.
</div>
</div>
{/* Quick Links - shortcut into the shared tooling dashboard */}
{/* Quick Links */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-slate-101 flex items-center gap-2 mb-3.5 justify-between">
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-3.5 justify-between">
<span className="flex items-center gap-2">
<LinkIcon className="w-4.5 h-4.5 text-cyan-400" />
<LinkIcon className="w-4 h-4 text-slate-400" />
Quick Links
</span>
<button
onClick={onNavigateToLinks}
className="text-[10px] text-cyan-400 hover:text-cyan-300 font-semibold flex items-center gap-0.5 hover:cursor-pointer"
className="text-[10px] text-slate-400 hover:text-slate-200 font-semibold flex items-center gap-0.5 hover:cursor-pointer"
>
Manage <ArrowRight className="w-3 h-3" />
</button>
</h3>
{links.length === 0 ? (
<div className="text-center py-6 bg-slate-900/35 rounded-lg border border-slate-850 p-5">
<Globe className="w-7 h-7 text-slate-700 mx-auto mb-2 opacity-50" />
<p className="text-[11px] text-slate-400">No shared links yet.</p>
<div className="text-center py-6 bg-slate-900/35 rounded-lg border border-slate-800">
<Globe className="w-7 h-7 text-slate-600 mx-auto mb-2 opacity-50" />
<p className="text-xs text-slate-400">No shared links yet.</p>
<button
onClick={onNavigateToLinks}
className="text-[11px] text-cyan-400 font-semibold underline mt-1.5 hover:text-cyan-300 hover:cursor-pointer"
className="text-xs text-slate-400 font-semibold underline mt-1.5 hover:text-slate-200 hover:cursor-pointer"
>
Add CheckMK, Semaphore & co.
Add links
</button>
</div>
) : (
@ -386,23 +338,23 @@ export default function Dashboard({
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-2.5 p-2 bg-slate-1000/40 hover:bg-slate-900 rounded-lg border border-slate-850/60 hover:border-slate-800 transition-all"
className="group flex items-center gap-2.5 p-2 bg-slate-900/40 hover:bg-slate-900 rounded-lg border border-slate-800/60 hover:border-slate-700 transition-all"
>
<span className={`w-7 h-7 rounded-md bg-slate-950 border border-slate-800 flex items-center justify-center shrink-0 ${accent}`}>
<Globe className="w-3.5 h-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[11px] font-semibold text-slate-200 group-hover:text-white truncate">{link.title}</span>
<span className={`block text-[9px] font-mono truncate ${accent}`}>{host}</span>
<span className="block text-xs font-semibold text-slate-200 group-hover:text-white truncate">{link.title}</span>
<span className={`block text-[10px] font-mono truncate ${accent}`}>{host}</span>
</span>
<ExternalLink className="w-3.5 h-3.5 text-slate-600 group-hover:text-slate-300 shrink-0" />
<ExternalLink className="w-3.5 h-3.5 text-slate-600 group-hover:text-slate-400 shrink-0" />
</a>
);
})}
{links.length > 6 && (
<button
onClick={onNavigateToLinks}
className="w-full text-center text-[10px] text-slate-500 hover:text-cyan-400 pt-1.5 font-semibold hover:cursor-pointer"
className="w-full text-center text-[10px] text-slate-500 hover:text-slate-300 pt-1.5 font-semibold hover:cursor-pointer"
>
+{links.length - 6} more links
</button>

View File

@ -6,8 +6,8 @@
import React, { useState, useMemo } from 'react';
import { Device, DeviceType } from '../types';
import {
Server, Search, Plus, Trash, Edit2, MapPin, Info,
BookOpen, Save, X, ExternalLink, Gauge
Server, Search, Plus, Trash, Edit2, MapPin, Gauge,
BookOpen, Save, X, Info, ExternalLink
} from 'lucide-react';
// Built-in device class presets shown in the dropdown.
@ -15,6 +15,8 @@ const DEVICE_CLASS_PRESETS = ['Switch', 'Firewall', 'Access-Point', 'Controller'
interface DeviceInventoryProps {
devices: Device[];
checkmkEnabled: boolean;
checkmkBaseUrl: string;
onAddDevice: (device: Omit<Device, 'id'>) => void;
onUpdateDevice: (device: Device) => void;
onDeleteDevice: (id: string) => void;
@ -22,6 +24,8 @@ interface DeviceInventoryProps {
export default function DeviceInventory({
devices,
checkmkEnabled,
checkmkBaseUrl,
onAddDevice,
onUpdateDevice,
onDeleteDevice,
@ -50,7 +54,6 @@ export default function DeviceInventory({
notes: string;
type: DeviceType;
emergencySheet: string;
checkMkUrl: string;
}>({
hostname: '',
ip: '',
@ -58,12 +61,13 @@ export default function DeviceInventory({
notes: '',
type: 'Switch',
emergencySheet: '',
checkMkUrl: ''
});
// Effective status: nothing is known until CheckMK is linked and reports a state.
const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' =>
d.checkMkUrl ? (d.status === 'online' || d.status === 'offline' ? d.status : 'unknown') : 'unknown';
const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' => d.status;
const cmkHostUrl = (d: Device) =>
checkmkEnabled && checkmkBaseUrl && d.cmkHostname
? `${checkmkBaseUrl}/index.py?host=${encodeURIComponent(d.cmkHostname)}`
: null;
const statusMeta = (s: 'online' | 'offline' | 'unknown') => {
if (s === 'online') return { label: 'online', badge: 'bg-emerald-950/60 border-emerald-900/80 text-emerald-400', dot: 'bg-emerald-400' };
@ -90,7 +94,6 @@ export default function DeviceInventory({
location: '',
notes: '',
type: 'Switch',
checkMkUrl: '',
emergencySheet: `### EMERGENCY MANUAL [HOSTNAME]
**Device Type:** [Enter Model]
@ -119,7 +122,6 @@ export default function DeviceInventory({
location: dev.location,
notes: dev.notes,
type: dev.type,
checkMkUrl: dev.checkMkUrl ?? '',
emergencySheet: dev.emergencySheet
});
setIsEditing(true);
@ -138,7 +140,6 @@ export default function DeviceInventory({
type: formData.type,
status: 'unknown',
emergencySheet: formData.emergencySheet,
checkMkUrl: formData.checkMkUrl
});
} else if (formMode === 'edit' && formData.id) {
const match = devices.find(d => d.id === formData.id);
@ -151,7 +152,6 @@ export default function DeviceInventory({
notes: formData.notes,
type: formData.type,
emergencySheet: formData.emergencySheet,
checkMkUrl: formData.checkMkUrl
});
}
}
@ -324,26 +324,25 @@ export default function DeviceInventory({
{/* Right: Actions and Status */}
<div className="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
{/* CheckMK Monitoring Badge */}
{(() => { const m = statusMeta(effectiveStatus(device)); return (
{/* CheckMK Status Badge only when CheckMK is enabled */}
{checkmkEnabled && (() => { const m = statusMeta(effectiveStatus(device)); return (
<div className="flex flex-col items-end gap-1 font-sans">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium border capitalize ${m.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${m.dot}`} />
{m.label}
</span>
<span className="text-[9px] font-mono text-slate-500">{device.checkMkUrl ? 'via CheckMK' : 'not linked'}</span>
</div>
); })()}
{/* Action Panel */}
<div className="flex items-center gap-1.5 border-l border-slate-800/80 pl-3">
{device.checkMkUrl && (
{cmkHostUrl(device) && (
<a
href={device.checkMkUrl}
href={cmkHostUrl(device)!}
target="_blank"
rel="noopener noreferrer"
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-cyan-400 transition-colors"
title="Open in CheckMK"
title="Open host in CheckMK"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
@ -403,7 +402,8 @@ export default function DeviceInventory({
</div>
</div>
{/* CheckMK Monitoring Panel */}
{/* CheckMK Monitoring Panel only when CheckMK is enabled */}
{checkmkEnabled && (
<div className="mt-4 pt-4 border-t border-slate-800 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400 font-sans font-medium flex items-center gap-1.5">
@ -417,9 +417,9 @@ export default function DeviceInventory({
</span>
); })()}
</div>
{selectedDevice.checkMkUrl ? (
{cmkHostUrl(selectedDevice) && (
<a
href={selectedDevice.checkMkUrl}
href={cmkHostUrl(selectedDevice)!}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 py-1.5 bg-slate-900 border border-slate-700 text-slate-200 hover:text-cyan-400 hover:border-cyan-500 rounded text-xs transition-colors font-mono"
@ -427,12 +427,14 @@ export default function DeviceInventory({
<ExternalLink className="w-3.5 h-3.5" />
Open host in CheckMK
</a>
) : (
<p className="text-[11px] text-slate-500 italic bg-slate-900/50 border border-slate-800 rounded p-2 leading-relaxed">
No CheckMK host linked yet. Add the host URL in the modify window - live status will be pulled from the CheckMK API.
)}
{selectedDevice.lastCheckedAt && (
<p className="text-[10px] text-slate-500 font-mono">
Last checked: {new Date(selectedDevice.lastCheckedAt).toLocaleString()}
</p>
)}
</div>
)}
</div>
{/* Emergency rescue guidelines sheet */}
@ -569,29 +571,6 @@ Pick a box from the list to see its specs and break-glass playbook.
/>
</div>
{/* CheckMK Monitoring integration */}
<div className="bg-slate-900/60 border border-cyan-900/40 rounded-lg p-3.5 space-y-3">
<div className="flex items-center gap-2 text-cyan-400 font-semibold">
<Gauge className="w-4 h-4" />
CheckMK Monitoring
</div>
<p className="text-[11px] text-slate-400 leading-relaxed flex gap-1.5">
<Info className="w-3.5 h-3.5 text-cyan-400 shrink-0 mt-0.5" />
Link this host to CheckMK. Connectivity is monitored there and pulled in via the CheckMK API - no in-app ping checks.
</p>
<div>
<label className="block text-slate-300 font-semibold mb-1">CheckMK Host URL</label>
<input
type="text"
value={formData.checkMkUrl}
onChange={(e) => setFormData({ ...formData, checkMkUrl: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-cyan-500 font-mono"
placeholder="https://checkmk.internal/site/check_mk/index.py?host=SW-CORE-03"
/>
<p className="text-[10px] text-slate-500 mt-1.5 italic">Without a linked CheckMK host the status stays unknown.</p>
</div>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Disaster Recovery Guide (Markdown formatting supported)</label>
<textarea

View File

@ -63,8 +63,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 bg-emerald-500 animate-pulse"></span>
<span>System: Online (Simulated)</span>
<span className={`w-2 h-2 rounded-full animate-pulse ${(import.meta.env.VITE_DEPLOY_ENV === 'dev' || import.meta.env.DEV) ? 'bg-amber-400' : 'bg-emerald-500'}`} />
<span>System: {(import.meta.env.VITE_DEPLOY_ENV === 'dev' || import.meta.env.DEV) ? 'Development' : 'Production'}</span>
</div>
{/* Mail Inbox */}

View File

@ -8,7 +8,7 @@ import { LabTemplate, Device, TopologyLink } from '../types';
import TopologyPanel from './TopologyPanel';
import {
Server, Plus, Edit3, Trash, User, MapPin,
Layers, ChevronRight, Save, X, Check
Layers, ChevronRight, Save, X, Check, Pencil, Terminal,
} from 'lucide-react';
interface LabTemplatesProps {
@ -37,6 +37,8 @@ export default function LabTemplates({
const [linkFrom, setLinkFrom] = useState('');
const [linkTo, setLinkTo] = useState('');
const [linkType, setLinkType] = useState('Trunk Uplink');
const [editingLinkIdx, setEditingLinkIdx] = useState<number | null>(null);
const [editingLinkLabel, setEditingLinkLabel] = useState('');
const [formData, setFormData] = useState<{
id?: string;
@ -45,12 +47,16 @@ export default function LabTemplates({
contactPerson: string;
location: string;
deviceIds: string[];
semaphoreSetupTemplateId: string;
semaphoreTeardownTemplateId: string;
}>({
name: '',
description: '',
contactPerson: '',
location: '',
deviceIds: []
deviceIds: [],
semaphoreSetupTemplateId: '',
semaphoreTeardownTemplateId: '',
});
// Calculate filtered devices associated with selected lab
@ -66,7 +72,9 @@ export default function LabTemplates({
description: '',
contactPerson: '',
location: '',
deviceIds: []
deviceIds: [],
semaphoreSetupTemplateId: '',
semaphoreTeardownTemplateId: '',
});
setIsEditing(true);
};
@ -80,7 +88,9 @@ export default function LabTemplates({
description: lab.description,
contactPerson: lab.contactPerson,
location: lab.location,
deviceIds: [...lab.deviceIds]
deviceIds: [...lab.deviceIds],
semaphoreSetupTemplateId: lab.semaphoreSetupTemplateId || '',
semaphoreTeardownTemplateId: lab.semaphoreTeardownTemplateId || '',
});
setIsEditing(true);
};
@ -124,7 +134,9 @@ export default function LabTemplates({
contactPerson: formData.contactPerson,
location: formData.location,
deviceIds: formData.deviceIds,
topology: tempLinks
topology: tempLinks,
semaphoreSetupTemplateId: formData.semaphoreSetupTemplateId,
semaphoreTeardownTemplateId: formData.semaphoreTeardownTemplateId,
};
if (formMode === 'add') {
@ -467,19 +479,54 @@ export default function LabTemplates({
{tempLinks.map((link, idx) => {
const fromDev = devices.find(d => d.id === link.fromDevice)?.hostname || link.fromDevice;
const toDev = devices.find(d => d.id === link.toDevice)?.hostname || link.toDevice;
const isEditingThis = editingLinkIdx === idx;
return (
<div key={idx} className="flex justify-between items-center bg-slate-950 px-3 py-1.5 rounded border border-slate-855 font-mono text-[10px] hover:border-slate-700">
<span className="text-slate-300">
<strong>{fromDev}</strong> {link.type} <strong>{toDev}</strong>
</span>
<div key={idx} className="flex items-center gap-2 bg-slate-950 px-3 py-1.5 rounded border border-slate-855 font-mono text-[10px] hover:border-slate-700">
<span className="text-slate-300 shrink-0"><strong>{fromDev}</strong> </span>
{isEditingThis ? (
<input
autoFocus
value={editingLinkLabel}
onChange={(e) => setEditingLinkLabel(e.target.value)}
onBlur={() => {
if (editingLinkLabel.trim()) {
setTempLinks(tempLinks.map((l, i) => i === idx ? { ...l, type: editingLinkLabel.trim() } : l));
}
setEditingLinkIdx(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (editingLinkLabel.trim()) {
setTempLinks(tempLinks.map((l, i) => i === idx ? { ...l, type: editingLinkLabel.trim() } : l));
}
setEditingLinkIdx(null);
}
if (e.key === 'Escape') setEditingLinkIdx(null);
}}
className="flex-1 min-w-0 bg-slate-800 text-slate-100 border border-indigo-500 rounded px-1.5 py-0.5 focus:outline-none"
/>
) : (
<span className="flex-1 min-w-0 text-indigo-300 truncate">{link.type}</span>
)}
<span className="text-slate-300 shrink-0"> <strong>{toDev}</strong></span>
<div className="flex gap-1.5 shrink-0 ml-auto font-sans">
<button
type="button"
onClick={() => { setEditingLinkIdx(idx); setEditingLinkLabel(link.type); }}
className="text-slate-400 hover:text-indigo-400 transition-colors"
title="Edit label"
>
<Pencil className="w-3 h-3" />
</button>
<button
type="button"
onClick={() => handleRemoveLink(idx)}
className="text-rose-500 hover:text-rose-450 font-sans font-bold"
className="text-rose-500 hover:text-rose-400 font-bold"
>
Remove
<X className="w-3 h-3" />
</button>
</div>
</div>
);
})}
</div>
@ -488,6 +535,39 @@ export default function LabTemplates({
)}
</div>
{/* Ansible Semaphore Automation */}
<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)
</label>
<p className="text-[10px] text-slate-400 mb-2">Semaphore task template IDs to trigger at booking start and end. Leave empty to skip automation for this lab.</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] text-slate-400 mb-1">Setup Template ID</label>
<input
type="text"
inputMode="numeric"
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>
<label className="block text-[10px] text-slate-400 mb-1">Teardown Template ID</label>
<input
type="text"
inputMode="numeric"
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>
{/* Form submit handlers */}
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
<button

View File

@ -43,6 +43,8 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
const [activeCategory, setActiveCategory] = useState<string>('all');
const [editingDescId, setEditingDescId] = useState<string | null>(null);
const [descDraft, setDescDraft] = useState('');
const categories = useMemo(() => {
const set = new Set<string>();
@ -97,6 +99,16 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
setDraft(EMPTY_DRAFT);
};
const startDescEdit = (link: QuickLink) => {
setEditingDescId(link.id);
setDescDraft(link.description);
};
const commitDescEdit = (link: QuickLink) => {
onUpdateLink({ ...link, description: descDraft.trim() });
setEditingDescId(null);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const title = draft.title.trim();
@ -239,8 +251,28 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
</div>
</div>
{link.description && (
<p className="text-[11px] text-slate-400 leading-relaxed mt-3 line-clamp-2">{link.description}</p>
{editingDescId === link.id ? (
<textarea
autoFocus
rows={2}
value={descDraft}
onChange={(e) => setDescDraft(e.target.value)}
onBlur={() => commitDescEdit(link)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitDescEdit(link); }
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
onClick={() => startDescEdit(link)}
title="Click to edit description"
className={`text-[11px] leading-relaxed mt-3 line-clamp-2 cursor-text ${link.description ? 'text-slate-400 hover:text-slate-200' : 'text-slate-600 italic hover:text-slate-400'} transition-colors`}
>
{link.description || 'Add a description…'}
</p>
)}
{/* Hover actions */}

View File

@ -33,7 +33,9 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
// Filter logs
const filteredLogs = sortedLogs.filter(log => {
const matchesSearch = log.message.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = typeFilter === 'all' || log.type === typeFilter;
const matchesType =
typeFilter === 'all' ? true :
log.type === typeFilter;
return matchesSearch && matchesType;
});
@ -121,18 +123,24 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
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"
/>
</div>
<div className="flex gap-1 shrink-0 text-xs font-medium">
{['all', 'booking', 'maintenance'].map((type) => (
<div className="flex gap-1 shrink-0 text-xs font-medium flex-wrap">
{[
{ key: 'all', label: 'All' },
{ key: 'booking', label: 'Booking' },
{ key: 'maintenance',label: 'Maintenance' },
{ key: 'status', label: 'Status' },
{ key: 'system', label: 'System' },
].map(({ key, label }) => (
<button
key={type}
onClick={() => setTypeFilter(type)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium capitalize transition-all ${
typeFilter === type
key={key}
onClick={() => setTypeFilter(key)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
typeFilter === key
? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400'
: 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'
}`}
>
{type === 'all' ? 'All' : getLogTypeLabel(type)}
{label}
</button>
))}
</div>

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { GhostGridLogo } from './Header';
import { authFetch, saveSession } from '../lib/auth';
import { User } from '../types';
@ -7,14 +7,23 @@ import { LogIn, Eye, EyeOff, AlertCircle } from 'lucide-react';
interface LoginPageProps {
onLogin: (user: User) => void;
onNavigateToRegister: () => void;
authError?: string;
}
export default function LoginPage({ onLogin, onNavigateToRegister }: LoginPageProps) {
export default function LoginPage({ onLogin, onNavigateToRegister, authError }: LoginPageProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useState(authError || '');
const [loading, setLoading] = useState(false);
const [azureEnabled, setAzureEnabled] = useState(false);
useEffect(() => {
fetch('/api/auth/config')
.then(r => r.json())
.then(d => setAzureEnabled(Boolean(d.azureEnabled)))
.catch(() => {});
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -137,6 +146,30 @@ export default function LoginPage({ onLogin, onNavigateToRegister }: LoginPagePr
</button>
</form>
{azureEnabled && (
<>
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-slate-800" />
<span className="text-[10px] font-mono text-slate-500 uppercase tracking-widest">or</span>
<div className="flex-1 h-px bg-slate-800" />
</div>
<button
type="button"
onClick={() => { window.location.href = '/api/auth/azure'; }}
className="w-full flex items-center justify-center gap-3 bg-slate-800 hover:bg-slate-700 border border-slate-700 hover:border-slate-600 text-white font-semibold text-sm py-2.5 rounded-lg transition-all"
>
{/* Microsoft M logo */}
<svg width="18" height="18" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="1" y="1" width="9" height="9" fill="#F25022" />
<rect x="11" y="1" width="9" height="9" fill="#7FBA00" />
<rect x="1" y="11" width="9" height="9" fill="#00A4EF" />
<rect x="11" y="11" width="9" height="9" fill="#FFB900" />
</svg>
Sign In with Entra ID
</button>
</>
)}
<p className="text-center text-xs text-slate-400">
No account yet?{' '}
<button

1220
src/components/Settings.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -271,7 +271,6 @@ export default function TopologyPanel({ devices, links, onSelectDevice }: Topolo
>
{/* Status Indicator (sourced from CheckMK; grey = unknown) */}
<span className={`absolute -top-1 -right-1 w-3 h-3 rounded-full border-2 border-slate-950 ${
!device.checkMkUrl ? 'bg-slate-500' :
device.status === 'online' ? 'bg-emerald-500' :
device.status === 'offline' ? 'bg-rose-500' : 'bg-slate-500'
}`} />

View File

@ -1,19 +1,15 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo } from 'react';
import { User, Booking } from '../types';
import { Users, Search, Mail, Calendar, Activity } from 'lucide-react';
import { Users, Search, Mail, Calendar, Activity, Trash2, Pencil, X, Save, AlertCircle } from 'lucide-react';
interface UserDirectoryProps {
users: User[];
currentUser: User;
bookings: Booking[];
onDeleteUser: (id: string) => Promise<void>;
onUpdateUser: (id: string, name: string, email: string) => Promise<void>;
}
// Deterministic accent so a given user always renders the same colour.
const AVATAR_COLORS = [
'from-emerald-500 to-teal-600',
'from-cyan-500 to-blue-600',
@ -35,8 +31,96 @@ function initials(name: string): string {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
export default function UserDirectory({ users, currentUser, bookings }: UserDirectoryProps) {
interface EditModalProps {
user: User;
onClose: () => void;
onSave: (name: string, email: string) => Promise<void>;
}
function EditModal({ user, onClose, onSave }: EditModalProps) {
const [name, setName] = useState(user.name);
const [email, setEmail] = useState(user.email);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim() || !email.trim()) return;
setSaving(true);
setError('');
try {
await onSave(name.trim(), email.trim().toLowerCase());
onClose();
} catch (err: any) {
setError(err.message || 'Failed to save.');
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/70 backdrop-blur-sm">
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl shadow-2xl w-full max-w-md">
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-800">
<h3 className="text-sm font-semibold text-white">Edit User</h3>
<button onClick={onClose} className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-all">
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<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" />
{error}
</div>
)}
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wide">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
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"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wide">Email address</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
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 font-mono"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition-all"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-700 disabled:text-slate-500 text-white transition-all"
>
{saving ? <span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-3.5 h-3.5" />}
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</form>
</div>
</div>
);
}
export default function UserDirectory({ users, currentUser, bookings, onDeleteUser, onUpdateUser }: UserDirectoryProps) {
const [search, setSearch] = useState('');
const [editingUser, setEditingUser] = useState<User | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const bookingCount = useMemo(() => {
const map = new Map<string, number>();
@ -63,6 +147,16 @@ export default function UserDirectory({ users, currentUser, bookings }: UserDire
);
}, [users, search]);
async function handleDelete(id: string) {
setDeletingId(id);
try { await onDeleteUser(id); } finally { setDeletingId(null); }
}
async function handleSaveEdit(name: string, email: string) {
if (!editingUser) return;
await onUpdateUser(editingUser.id, name, email);
}
return (
<div className="space-y-6 font-sans" id="user-directory-root">
@ -77,9 +171,8 @@ export default function UserDirectory({ users, currentUser, bookings }: UserDire
Registered Operators
</h2>
<p className="text-xs text-slate-300 max-w-xl leading-relaxed">
Everyone with an account on this box. booking counts come straight from the shared reservation pool - no shadow IT here.
Everyone with an account on this box. Booking counts come straight from the shared reservation pool.
</p>
<div className="flex flex-wrap gap-2 pt-3">
<span className="inline-flex items-center gap-1.5 bg-slate-950/60 border border-slate-800 rounded-lg px-3 py-1.5 text-[11px] text-slate-300">
<Users className="w-3.5 h-3.5 text-emerald-400" />
@ -99,12 +192,12 @@ Everyone with an account on this box. booking counts come straight from the shar
{/* Search */}
<div className="relative">
<span className="absolute left-3 top-2.5 text-slate-550"><Search className="w-4 h-4" /></span>
<span className="absolute left-3 top-2.5 text-slate-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)}
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"
/>
</div>
@ -118,6 +211,7 @@ Everyone with an account on this box. booking counts come straight from the shar
const isMe = user.id === currentUser.id;
const total = bookingCount.get(user.id) ?? 0;
const active = activeCount.get(user.id) ?? 0;
const isDeleting = deletingId === user.id;
return (
<div
key={user.id}
@ -145,9 +239,10 @@ Everyone with an account on this box. booking counts come straight from the shar
</div>
</div>
<div className="mt-4 pt-3 border-t border-slate-850 flex items-center justify-between">
<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>
<div className="flex items-center gap-2">
<div className="flex items-center gap-3 text-[10px] font-mono text-slate-400">
<span className="flex items-center gap-1" title="Total bookings">
<Calendar className="w-3 h-3 text-indigo-400" />
@ -158,12 +253,44 @@ Everyone with an account on this box. booking counts come straight from the shar
{active}
</span>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 ml-1">
<button
onClick={() => setEditingUser(user)}
className="p-1.5 rounded-lg text-slate-500 hover:text-cyan-400 hover:bg-slate-900 transition-all"
title="Edit name / email"
>
<Pencil className="w-3.5 h-3.5" />
</button>
{!isMe && (
<button
onClick={() => handleDelete(user.id)}
disabled={isDeleting}
className="p-1.5 rounded-lg text-slate-500 hover:text-rose-400 hover:bg-slate-900 transition-all disabled:opacity-40"
title="Delete user"
>
{isDeleting
? <span className="w-3.5 h-3.5 border-2 border-slate-600 border-t-rose-400 rounded-full animate-spin inline-block" />
: <Trash2 className="w-3.5 h-3.5" />}
</button>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
{editingUser && (
<EditModal
user={editingUser}
onClose={() => setEditingUser(null)}
onSave={handleSaveEdit}
/>
)}
</div>
);
}

View File

@ -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;
@ -72,11 +72,11 @@
border-color: var(--border) !important;
}
/* Amber-tinted warning/safety card used in Dashboard */
/* BookingCalendar "Quick Booking" green-tinted card */
:root.light .bg-\[\#1D2535\],
:root.light .bg-\[\#1d2535\] {
background-color: #fffbeb !important;
border-color: #fde68a !important;
background-color: #f0fdf4 !important;
border-color: #bbf7d0 !important;
}
/* ── Header & nav ─────────────────────────────────────────────── */
@ -128,8 +128,10 @@
background-color: #e9ecf0 !important;
}
/* ── Dashboard banner gradient ────────────────────────────────── */
:root.light .bg-gradient-to-br {
/* ── Dashboard / UserDirectory banner gradients (dark hex only) ── */
/* Targets only the dark-themed banners, not coloured avatar gradients */
:root.light .bg-gradient-to-br.from-\[\#1E293B\],
:root.light .bg-gradient-to-br.from-\[\#1e293b\] {
background-image: linear-gradient(to bottom right, #ffffff, #f1f5f9) !important;
border-color: var(--border) !important;
}
@ -155,6 +157,7 @@
}
/* ── Borders ──────────────────────────────────────────────────── */
:root.light .border-slate-900,
:root.light .border-slate-800,
:root.light .border-slate-850,
:root.light .border-slate-855,
@ -164,6 +167,12 @@
border-color: var(--border) !important;
}
:root.light .border-red-900\/50,
:root.light .border-red-900\/40,
:root.light .border-red-900\/30 {
border-color: #fca5a5 !important;
}
:root.light .divide-slate-800 > *,
:root.light .divide-slate-850 > * {
border-color: var(--border) !important;
@ -171,6 +180,9 @@
/* ── Text colours ─────────────────────────────────────────────── */
:root.light .text-white,
:root.light .text-white\/90,
:root.light .text-white\/80,
:root.light .text-white\/70,
:root.light .text-slate-100,
:root.light .text-slate-200 {
color: var(--text) !important;
@ -238,6 +250,32 @@
color: #9f1239 !important;
}
:root.light .bg-rose-950\/30,
:root.light .hover\:bg-rose-950\/30:hover {
background-color: #ffe4e6 !important;
}
:root.light .bg-red-950\/60,
:root.light .bg-red-950\/50,
:root.light .bg-red-950\/40,
:root.light .bg-red-950\/20 {
background-color: #fee2e2 !important;
border-color: #fca5a5 !important;
color: #b91c1c !important;
}
:root.light .border-red-800\/60 {
border-color: #fca5a5 !important;
}
:root.light .text-red-300 {
color: #b91c1c !important;
}
:root.light .text-red-400 {
color: #dc2626 !important;
}
:root.light .bg-cyan-950\/40 {
background-color: #cffafe !important;
border-color: #67e8f9 !important;
@ -460,6 +498,24 @@
color: inherit !important;
}
/* ── Settings page blue (Entra ID) tokens ─────────────────────── */
:root.light .bg-blue-950\/60,
:root.light .bg-blue-950\/50,
:root.light .bg-blue-950\/40 {
background-color: #dbeafe !important;
border-color: #93c5fd !important;
color: #1d4ed8 !important;
}
:root.light .border-blue-900\/50,
:root.light .border-blue-900\/40 {
border-color: #93c5fd !important;
}
:root.light .text-blue-400 {
color: #1d4ed8 !important;
}
/* ── AirIT badge - always white text on navy, regardless of theme ─ */
.airit-badge {
color: #ffffff !important;
@ -480,21 +536,63 @@
border-color: var(--border) !important;
}
/* Developer panel wrapper - restore dark terminal feel */
/* JSON REST Response panel GitHub Light style in light mode */
:root.light #booking-details-modal .font-mono.bg-slate-950 {
background-color: #0d1117 !important;
color: #c9d1d9 !important;
border-color: #30363d !important;
background-color: #f6f8fa !important;
color: #24292f !important;
border-color: #d0d7de !important;
}
/* Terminal output area bg-slate-1000 */
:root.light #booking-details-modal .bg-slate-1000 {
background-color: #0d1117 !important;
color: #c9d1d9 !important;
border-color: #30363d !important;
/* The <pre> inside inherits the dark pre-rule; override explicitly */
:root.light #booking-details-modal .font-mono.bg-slate-950 pre {
background-color: transparent !important;
color: #24292f !important;
border-color: transparent !important;
}
/* Ansible/terminal button trigger row bg-slate-900/40 - keep readable */
/* Header bar inside the JSON panel */
:root.light #booking-details-modal .font-mono.bg-slate-950 .bg-slate-900 {
background-color: #eaeef2 !important;
border-color: #d0d7de !important;
}
/* Title label and icon in panel header */
:root.light #booking-details-modal .font-mono.bg-slate-950 .text-indigo-400 {
color: #6366f1 !important;
}
/* Copy button inside panel */
:root.light #booking-details-modal .font-mono.bg-slate-950 button {
background-color: #eaeef2 !important;
border-color: #d0d7de !important;
color: #57606a !important;
}
:root.light #booking-details-modal .font-mono.bg-slate-950 button:hover {
background-color: #d0d7de !important;
color: #24292f !important;
}
/* Ansible status card orange accent in light mode */
:root.light #booking-details-modal .bg-orange-950\/10 {
background-color: #fff7ed !important;
}
:root.light #booking-details-modal .border-orange-900\/40 {
border-color: #fdba74 !important;
}
:root.light .text-orange-400 {
color: #ea580c !important;
}
:root.light .bg-orange-950\/60,
:root.light .bg-orange-900\/40 {
background-color: #fed7aa !important;
border-color: #fb923c !important;
color: #9a3412 !important;
}
:root.light .border-orange-800\/50 {
border-color: #fb923c !important;
}
/* bg-slate-900/40 rows (notice box etc.) light in light mode */
:root.light #booking-details-modal .bg-slate-900\/40 {
background-color: #f1f5f9 !important;
border-color: var(--border) !important;
@ -557,3 +655,259 @@
:root.light #add-link-btn:hover {
background-color: #6366f1 !important;
}
/* ─────────────────────────────────────────────────────────────────── */
/* EXTENDED LIGHT MODE OVERRIDES */
/* ─────────────────────────────────────────────────────────────────── */
/* ── Solid (no-opacity) color backgrounds ────────────────────────── */
/* These are used in Logbook type badges and Dashboard countdown pill */
:root.light .bg-emerald-950 {
background-color: #d1fae5 !important;
border-color: #6ee7b7 !important;
color: #065f46 !important;
}
:root.light .bg-cyan-950 {
background-color: #cffafe !important;
border-color: #67e8f9 !important;
color: #155e75 !important;
}
:root.light .bg-indigo-950 {
background-color: #e0e7ff !important;
border-color: #a5b4fc !important;
color: #3730a3 !important;
}
:root.light .bg-rose-950 {
background-color: #ffe4e6 !important;
border-color: #fca5a5 !important;
color: #9f1239 !important;
}
/* ── emerald-900 opacity variants (quick booking modal tabs) ──────── */
:root.light .bg-emerald-900\/50,
:root.light .bg-emerald-900\/40,
:root.light .bg-emerald-900\/30 {
background-color: #d1fae5 !important;
}
/* ── Violet accent (LinkDashboard) ────────────────────────────────── */
:root.light .bg-violet-950\/60,
:root.light .bg-violet-950\/40,
:root.light .bg-violet-950\/20 {
background-color: #ede9fe !important;
border-color: #c4b5fd !important;
color: #5b21b6 !important;
}
:root.light .text-violet-400 {
color: #7c3aed !important;
}
/* ── 300-level text near-invisible on white backgrounds ────────── */
:root.light .text-amber-300 {
color: #b45309 !important;
}
:root.light .text-emerald-300 {
color: #059669 !important;
}
:root.light .text-cyan-300 {
color: #0891b2 !important;
}
:root.light .text-rose-300 {
color: #be123c !important;
}
:root.light .text-indigo-300 {
color: #4338ca !important;
}
/* ── Missing border opacity variants ─────────────────────────────── */
/* slate-700 with opacity */
:root.light .border-slate-700\/40,
:root.light .border-slate-700\/50,
:root.light .border-slate-700\/60 {
border-color: var(--border) !important;
}
/* slate-900 opacity (section dividers, row separators) */
:root.light .border-slate-900\/30,
:root.light .border-slate-900\/40,
:root.light .border-slate-900\/60 {
border-color: var(--border) !important;
}
/* emerald borders solid and opacity variants */
:root.light .border-emerald-900,
:root.light .border-emerald-900\/30,
:root.light .border-emerald-900\/40,
:root.light .border-emerald-900\/60 {
border-color: #6ee7b7 !important;
}
:root.light .border-emerald-800\/50,
:root.light .border-emerald-800\/60 {
border-color: #a7f3d0 !important;
}
/* cyan borders */
:root.light .border-cyan-900\/50,
:root.light .border-cyan-900\/60 {
border-color: #67e8f9 !important;
}
/* amber borders */
:root.light .border-amber-900\/40,
:root.light .border-amber-900\/60 {
border-color: #fde68a !important;
}
:root.light .border-amber-800\/50,
:root.light .border-amber-800\/60 {
border-color: #fde68a !important;
}
/* rose borders */
:root.light .border-rose-900\/30,
:root.light .border-rose-900\/50,
:root.light .border-rose-900\/60 {
border-color: #fca5a5 !important;
}
/* indigo borders */
:root.light .border-indigo-900,
:root.light .border-indigo-900\/40,
:root.light .border-indigo-900\/50 {
border-color: #a5b4fc !important;
}
/* violet borders */
:root.light .border-violet-900\/50,
:root.light .border-violet-950\/40 {
border-color: #c4b5fd !important;
}
/* ── Missing bg-slate-900 opacity variants ───────────────────────── */
:root.light .bg-slate-900\/30,
:root.light .bg-slate-900\/50,
:root.light .bg-slate-900\/70,
:root.light .bg-slate-900\/90 {
background-color: #f1f5f9 !important;
}
/* ── bg-slate-800 additional variant ────────────────────────────── */
:root.light .bg-slate-800\/40 {
background-color: #e2e8f0 !important;
}
/* ── Hover-state overrides for dark bg classes ───────────────────── */
/* Without these the hover flashes a dark background in light mode. */
:root.light .hover\:bg-slate-900:hover {
background-color: #f1f5f9 !important;
}
:root.light .hover\:bg-slate-900\/35:hover,
:root.light .hover\:bg-slate-900\/40:hover,
:root.light .hover\:bg-slate-900\/60:hover,
:root.light .hover\:bg-slate-900\/70:hover,
:root.light .hover\:bg-slate-900\/80:hover {
background-color: #f1f5f9 !important;
}
:root.light .hover\:bg-slate-800:hover {
background-color: #e2e8f0 !important;
}
:root.light .hover\:bg-slate-800\/80:hover {
background-color: #e2e8f0 !important;
}
:root.light .hover\:bg-slate-950\/30:hover,
:root.light .hover\:bg-slate-950\/40:hover {
background-color: #f8fafc !important;
}
/* Coloured hover states */
:root.light .hover\:bg-emerald-900\/40:hover,
:root.light .hover\:bg-emerald-900\/60:hover {
background-color: #a7f3d0 !important;
}
:root.light .hover\:bg-rose-900\/60:hover {
background-color: #fecdd3 !important;
}
:root.light .hover\:bg-red-950\/40:hover {
background-color: #fee2e2 !important;
}
/* ── Border-dashed empty slots (calendar grid) ───────────────────── */
:root.light .border-slate-800\/40 {
border-color: var(--border) !important;
}
:root.light .hover\:border-slate-700\/60:hover {
border-color: var(--border-muted) !important;
}
/* ── Hover text colors prevent near-white text on light backgrounds */
:root.light .hover\:text-white:hover,
:root.light .hover\:text-slate-100:hover,
:root.light .hover\:text-slate-200:hover {
color: var(--text) !important;
}
:root.light .group:hover .group-hover\:text-white {
color: var(--text) !important;
}
: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;
}

View File

@ -14,9 +14,9 @@ export interface Device {
location: string;
notes: string;
type: DeviceType;
status: 'online' | 'offline' | 'unknown'; // 'unknown' until CheckMK reports a state
status: 'online' | 'offline' | 'unknown';
emergencySheet: string; // Markdown text
checkMkUrl: string; // Link to this host in CheckMK; live status comes from the CheckMK API
cmkHostname?: string;
lastCheckedAt?: string;
}
@ -34,6 +34,8 @@ export interface LabTemplate {
location: string;
deviceIds: string[];
topology: TopologyLink[];
semaphoreSetupTemplateId?: string;
semaphoreTeardownTemplateId?: string;
}
export interface Booking {
@ -46,6 +48,10 @@ export interface Booking {
status: 'active' | 'upcoming' | 'completed' | 'cancelled';
notified: boolean;
emailSent?: boolean;
ansibleSetupTriggered?: boolean;
ansibleTeardownTriggered?: boolean;
ansibleSetupJobId?: string;
ansibleTeardownJobId?: string;
}
export interface LogEntry {

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />