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.
38 KiB
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)
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 # { system, custom } routes [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> }
upstream prefixed with https:// → reverse_proxy 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')
6.4 First-start Initialization
Runs in startServer() before any routes are registered, every startup — both operations
are idempotent and only fire once on a blank database.
Default admin user:
if users table is empty:
INSERT user (name='Admin', role='Admin', email='admin@ghostgrid.local', password=bcrypt('admin'))
→ log "[Init] Default admin user created"
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)
+-- 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 |
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)
+-- 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.topologyare 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 inPUT /api/settings, and (if secret) added toSECRET_KEYS. - Schema changes go straight into the
CREATE TABLEblock inserver-db.ts— fresh-install model, no migration helper. - 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.