Compare commits
61 Commits
eed01b9665
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cc96f5b6ce | |||
| e0fd19f471 | |||
| 5c7ad3140a | |||
| c3931e7f36 | |||
| d78ade4629 | |||
| 84bad8c0e6 | |||
| 08a4df5503 | |||
| cb36caff2e | |||
| be007791dc | |||
| 515052fbda | |||
| 49cd0ae4f6 | |||
| a2d515992c | |||
| 2a2902d5bc | |||
| ac1cf8fec7 | |||
| e0332b05ad | |||
| bc677ff805 | |||
| 1dba721a9a | |||
| f6263ad2f3 | |||
| 6f621067b9 | |||
| d429b2d252 | |||
| 1526d25144 | |||
| 2857040803 | |||
| acadf8db7c | |||
| 250c347f58 | |||
| f66b1ca456 | |||
| 00cf5dd02d | |||
| 47e7b65613 | |||
| e5e7c571a4 | |||
| f1200425af | |||
| 7afb4829bc | |||
| 33c7b2ba65 | |||
| aa5c2332e8 | |||
| de4aef3d19 | |||
| 7758bcaa02 | |||
| c428b12352 | |||
| 70399a00ec | |||
| 11eb06c5ad | |||
| ea9e6c1d46 | |||
| 20308b53d6 | |||
| 744468c13d | |||
| 985178ea84 | |||
| 15c4e3f6ac | |||
| 7731a1a9af | |||
| 9fba11ccd6 | |||
| 789fe1f8e0 | |||
| a58b321a50 | |||
| 626871213d | |||
| 59f11356ec | |||
| b223e6dfe9 | |||
| e13e11ce6a | |||
| 1289e2476c | |||
| f12f92aea8 | |||
| e9fb79041e | |||
| 5769b38f74 | |||
| b7a3d2086d | |||
| 97e1b1a665 | |||
| c879f84843 | |||
| 550acd27b2 | |||
| 34c9822e42 | |||
| f7999cbe55 | |||
| d364aea4c1 |
885
ARCHITECTURE.md
Normal file
885
ARCHITECTURE.md
Normal file
@ -0,0 +1,885 @@
|
|||||||
|
# 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` | Baseline 8 tables created on boot via `server-db.ts`; additive changes to live DBs go in `server-migrations.ts` (see §4.4) |
|
||||||
|
| Settings store | key/value `settings` table | Runtime config for all integrations, seeded with `INSERT OR IGNORE` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`. Baseline tables are created idempotently on boot (`CREATE TABLE IF NOT EXISTS` in `server-db.ts`). Additive changes to live DBs (new columns, tables, default settings) are applied by the migration runner in `server-migrations.ts`. See §4.4.
|
||||||
|
|
||||||
|
### 4.1 Schema (as created in `server-db.ts`)
|
||||||
|
|
||||||
|
```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 '',
|
||||||
|
scope TEXT NOT NULL DEFAULT 'global', -- 'global' | 'personal'
|
||||||
|
ownerId TEXT NOT NULL DEFAULT '' -- userId of creator; '' = legacy (pre-migration)
|
||||||
|
);
|
||||||
|
-- scope/ownerId were added after initial release via idempotent ALTER TABLE in server-db.ts
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bookings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
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,
|
||||||
|
redirect TEXT NOT NULL DEFAULT '', -- optional 'redir / <path>' for the bare root
|
||||||
|
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 | Baseline schema in `server-db.ts` (`CREATE TABLE IF NOT EXISTS`). New columns / tables / default settings for live DBs go in `server-migrations.ts` as an appended migration object. See §4.4. |
|
||||||
|
|
||||||
|
### 4.3 Settings (key/value config)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 4.4 Migration System
|
||||||
|
|
||||||
|
Additive schema changes to live databases are handled by `server-migrations.ts`. The runner executes once at the start of `startServer()`, before routes or background jobs, and is synchronous (`better-sqlite3`).
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
1. Creates (idempotently) a `_migrations` table: `id TEXT PRIMARY KEY, applied_at TEXT`.
|
||||||
|
2. Iterates the `migrations` array in order. For each entry whose `id` is not in `_migrations`, runs `up(db)` inside a transaction and records the id on success.
|
||||||
|
3. If `up()` throws, the transaction rolls back and the id is not recorded — the migration retries on the next start.
|
||||||
|
4. Already-applied migrations are skipped forever.
|
||||||
|
|
||||||
|
**Rules for adding a migration:**
|
||||||
|
|
||||||
|
- Append to the end of the `migrations` array in `server-migrations.ts`. **Never reorder or remove entries.**
|
||||||
|
- Use a unique, descriptive `id` — format: `NNNN_short_description` (e.g. `0001_bookings_add_color`).
|
||||||
|
- Only additive DDL: `ALTER TABLE … ADD COLUMN`, `CREATE TABLE IF NOT EXISTS`, `INSERT OR IGNORE INTO settings`.
|
||||||
|
- New default settings for **live DBs** go here. Day-0 seeds (fresh install) stay in `DEFAULT_SETTINGS` in `server-db.ts`.
|
||||||
|
- New settings still need allow-listing in `PUT /api/settings` (and `SECRET_KEYS` if secret) — the migration only seeds the value.
|
||||||
|
|
||||||
|
**Fresh-install behavior:** `server-db.ts` creates all baseline tables first (at module import). `runMigrations` then applies every migration in the array — safe because all migrations are additive over the baseline.
|
||||||
|
|
||||||
|
**`_migrations` is excluded** from `/api/database/import` and `/api/database/info` (not in their explicit table lists) — migration state belongs to the current install, not the imported data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API Design
|
||||||
|
|
||||||
|
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; sets ownerId=req.user [auth]
|
||||||
|
| +-- PUT /{id} # Update lab; 403 if not owner/admin/legacy [auth]
|
||||||
|
| +-- DELETE /{id} # Delete + cancel upcoming bookings; same 403 guard [auth]
|
||||||
|
|
|
||||||
|
+-- /bookings
|
||||||
|
| +-- GET / # List bookings (int flags > booleans) [auth]
|
||||||
|
| +-- 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 to routes
|
||||||
|
+-- Roles: role column defaults to 'User'
|
||||||
|
+-- Lab ownership: PUT/DELETE /api/labs/:id enforce inline ownership check
|
||||||
|
| (owner || admin || legacy-lab with ownerId=''); 403 otherwise
|
||||||
|
```
|
||||||
|
|
||||||
|
**Local flow:** `register` (bcrypt hash, role `User`) / `login` (bcrypt compare) > issue JWT > client stores token + user.
|
||||||
|
|
||||||
|
**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] [redir / <redirect>] reverse_proxy <upstream> { … } }
|
||||||
|
redirect set → `redir / <path>` redirects only the bare root '/'
|
||||||
|
(other paths pass through; e.g. CheckMK served at /<site>/check_mk/)
|
||||||
|
every reverse_proxy block carries standard forwarding headers:
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
header_up Host {host}
|
||||||
|
upstream prefixed with https:// → block also gets a
|
||||||
|
transport http { tls_insecure_skip_verify } block
|
||||||
|
(for self-signed TLS backends like Semaphore)
|
||||||
|
|
||||||
|
importCaddyfileRoutes(): reads /etc/caddy/Caddyfile on first Caddy enable
|
||||||
|
parses hostname/upstream blocks → seeds caddy table as custom routes
|
||||||
|
(no-op if caddy table already has entries or file not found)
|
||||||
|
|
||||||
|
pushCaddyConfig(): POST <caddy_admin_url>/load (Content-Type: text/caddyfile)
|
||||||
|
called on startup, after settings save, after route add/delete
|
||||||
|
(failures logged as warnings, non-fatal; skipped if caddy_enabled !== 'true'
|
||||||
|
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
|
||||||
|
+-- Scope toggle (Global / Personal) per lab; Personal labs visible only to owner + admins
|
||||||
|
+-- List sectioned: "My Topologies" / "Global Topologies" / "Others' Personal" (admin only)
|
||||||
|
+-- Edit/Delete buttons hidden for labs the current user cannot modify
|
||||||
|
+-- Topology link editor (fromDevice > toDevice, link type)
|
||||||
|
+-- TopologyPanel: SVG layout by node count (1 / 2 / 3 / circular)
|
||||||
|
|
||||||
|
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; `scope: 'global' \| 'personal'`, `ownerId: string` (userId of creator, `''` for legacy rows) |
|
||||||
|
| `Booking` | `status: 'active' \| 'upcoming' \| 'completed' \| 'cancelled'`; ansible trigger flags + job IDs |
|
||||||
|
| `LogEntry` | `type: 'maintenance' \| 'booking' \| 'status' \| 'system'` |
|
||||||
|
| `User` | `{ id, name, role, email }` (never password on the client) |
|
||||||
|
| `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 |
|
||||||
|
| +-- Lab ownership enforced on PUT/DELETE /api/labs/:id |
|
||||||
|
| (owner || admin || legacy ownerId=''); 403 otherwise |
|
||||||
|
+-------------------------------------------------------------+
|
||||||
|
| 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/log helpers (uid, addLog)
|
||||||
|
+-- index.html # Vite HTML entry (#root > src/main.tsx); links /favicon.svg
|
||||||
|
+-- public/
|
||||||
|
| +-- favicon.svg # app favicon (GhostGrid logo; served at site root by Vite)
|
||||||
|
+-- vite.config.ts # Vite + React + Tailwind; '@' alias > repo root
|
||||||
|
+-- tsconfig.json # noEmit, react-jsx, bundler resolution
|
||||||
|
+-- package.json # scripts + deps (package name "react-example" is vestigial)
|
||||||
|
+-- .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 | For fresh installs, edit the `CREATE TABLE` block in `server-db.ts`. For live DBs (new columns, tables, default settings), append a migration to `server-migrations.ts` (see §4.4). New settings still need allow-listing in `PUT /api/settings` (+ `SECRET_KEYS` if secret). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
- Baseline schema in `server-db.ts`. Additive schema changes (new columns, tables, default settings) go in `server-migrations.ts` as an appended migration object — never edit existing entries (see §4.4).
|
||||||
|
- The SPA catch-all (`app.get('*')`) + static serving are registered **last** in `startServer()`, after every `/api` route — otherwise GET `/api/*` falls through to `index.html`. All `/api` responses carry `Cache-Control: no-store`.
|
||||||
|
- One Caddy per container; `POST /load` replaces the whole config. Only the `CADDY_MANAGER=true` instance may push/seed/edit routes — never let the non-manager push.
|
||||||
|
- All user-facing strings are in **English**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated from the GhostGrid codebase. Keep this document in sync when the data model, API surface, or integrations change.*
|
||||||
@ -180,7 +180,7 @@ sudo -u ghostgrid cat /opt/ghostgrid/.ssh/id_ed25519.pub
|
|||||||
Add the public key in Gitea:
|
Add the public key in Gitea:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Repository → Settings → Deploy Keys → Add Deploy Key
|
Repository > Settings > Deploy Keys > Add Deploy Key
|
||||||
```
|
```
|
||||||
|
|
||||||
Keep the deploy key read-only.
|
Keep the deploy key read-only.
|
||||||
@ -317,7 +317,7 @@ After installation or deployment, verify the following:
|
|||||||
7. Verify the deployment flow:
|
7. Verify the deployment flow:
|
||||||
|
|
||||||
```text
|
```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
|
## Manual Setup of the Staging Instance
|
||||||
|
|||||||
@ -16,7 +16,7 @@ git fetch --prune origin
|
|||||||
git checkout "$BRANCH"
|
git checkout "$BRANCH"
|
||||||
git pull --ff-only origin "$BRANCH"
|
git pull --ff-only origin "$BRANCH"
|
||||||
npm ci
|
npm ci
|
||||||
npm run build
|
VITE_DEPLOY_ENV="$BRANCH" npm run build
|
||||||
sudo systemctl restart "$SVC"
|
sudo systemctl restart "$SVC"
|
||||||
echo "Deployed $BRANCH ($SVC). Status:"
|
echo "Deployed $BRANCH ($SVC). Status:"
|
||||||
systemctl --no-pager status "$SVC" | head -n 5
|
systemctl --no-pager status "$SVC" | head -n 5
|
||||||
|
|||||||
@ -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 ${REPO_BRANCH} '${CLONE_URL}' ${APP_DIR}"
|
||||||
run "sudo -u ghostgrid git clone --branch ${DEV_BRANCH} '${CLONE_URL}' ${DEV_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 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_ok "Repositories cloned (main + dev)"
|
||||||
|
|
||||||
msg_info "Creating .env file for each instance"
|
msg_info "Creating .env file for each instance"
|
||||||
for d in "${APP_DIR}" "${DEV_DIR}"; do
|
for d in "${APP_DIR}" "${DEV_DIR}"; do
|
||||||
SECRET="$(openssl rand -hex 32)"
|
SECRET="$(openssl rand -hex 32)"
|
||||||
run "printf 'JWT_SECRET=\"%s\"\n' '${SECRET}' > ${d}/.env && chown ghostgrid:ghostgrid ${d}/.env && chmod 600 ${d}/.env"
|
run "printf 'JWT_SECRET=\"%s\"\n' '${SECRET}' > ${d}/.env && chown ghostgrid:ghostgrid ${d}/.env && chmod 600 ${d}/.env"
|
||||||
|
# Only the production instance owns Caddy and shows "Production" in the UI.
|
||||||
|
[[ "$d" == "${APP_DIR}" ]] && run "printf 'DEPLOY_ENV=production\n' >> ${d}/.env"
|
||||||
done
|
done
|
||||||
msg_ok ".env files created (main + dev)"
|
msg_ok ".env files created (main + dev)"
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<title>GhostGrid</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
131
package-lock.json
generated
131
package-lock.json
generated
@ -19,7 +19,6 @@
|
|||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
|
||||||
"react": "^19.0.1",
|
"react": "^19.0.1",
|
||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
"vite": "^6.2.3"
|
"vite": "^6.2.3"
|
||||||
@ -30,7 +29,6 @@
|
|||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jsonwebtoken": "^9.0.9",
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"autoprefixer": "^10.4.21",
|
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
@ -1629,43 +1627,6 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@ -2323,47 +2284,6 @@
|
|||||||
"node": ">= 0.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": {
|
"node_modules/fresh": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
@ -3077,47 +2997,6 @@
|
|||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@ -3284,13 +3163,6 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/prebuild-install": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
@ -3813,7 +3685,8 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.22.4",
|
"version": "4.22.4",
|
||||||
|
|||||||
@ -22,7 +22,6 @@
|
|||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
|
||||||
"react": "^19.0.1",
|
"react": "^19.0.1",
|
||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
"vite": "^6.2.3"
|
"vite": "^6.2.3"
|
||||||
@ -33,7 +32,6 @@
|
|||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jsonwebtoken": "^9.0.9",
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"autoprefixer": "^10.4.21",
|
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
|
|||||||
24
public/favicon.svg
Normal file
24
public/favicon.svg
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<rect width="100" height="100" rx="22" fill="#0b0f19"/>
|
||||||
|
<!-- ghost body -->
|
||||||
|
<path d="M 24,78 C 18,65 14,35 34,22 C 48,12 62,15 68,26" stroke="#06b6d4" stroke-width="4" stroke-linecap="round" fill="none"/>
|
||||||
|
<path d="M 24,78 C 26,83 31,81 35,74 C 38,68 41,74 45,77 C 48,79 50,70 52,65" stroke="#06b6d4" stroke-width="4" stroke-linecap="round" fill="none"/>
|
||||||
|
<!-- eyes -->
|
||||||
|
<rect x="38" y="32" width="6" height="13" rx="3" fill="#00f0ff"/>
|
||||||
|
<rect x="52" y="32" width="6" height="13" rx="3" fill="#00f0ff"/>
|
||||||
|
<!-- network grid -->
|
||||||
|
<line x1="56" y1="38" x2="88" y2="38" stroke="#06b6d4" stroke-width="2.5"/>
|
||||||
|
<line x1="46" y1="62" x2="84" y2="62" stroke="#06b6d4" stroke-width="2.5"/>
|
||||||
|
<line x1="56" y1="20" x2="56" y2="80" stroke="#06b6d4" stroke-width="2.5"/>
|
||||||
|
<line x1="68" y1="15" x2="68" y2="76" stroke="#0891b2" stroke-width="2"/>
|
||||||
|
<line x1="80" y1="26" x2="80" y2="62" stroke="#06b6d4" stroke-width="2"/>
|
||||||
|
<!-- nodes -->
|
||||||
|
<circle cx="56" cy="26" r="4" fill="#00f0ff"/>
|
||||||
|
<circle cx="68" cy="26" r="4" fill="#00f0ff"/>
|
||||||
|
<circle cx="80" cy="26" r="4" fill="#00f0ff"/>
|
||||||
|
<circle cx="56" cy="38" r="4" fill="#00f0ff"/>
|
||||||
|
<circle cx="80" cy="38" r="5" fill="#38bdf8"/>
|
||||||
|
<circle cx="88" cy="38" r="4" fill="#00f0ff"/>
|
||||||
|
<circle cx="68" cy="62" r="5" fill="#38bdf8"/>
|
||||||
|
<circle cx="80" cy="62" r="4" fill="#00f0ff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
148
server-db.ts
148
server-db.ts
@ -1,7 +1,10 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const DB_FILE = path.join(process.cwd(), 'ghostgrid.db');
|
export const DB_FILE = path.join(process.cwd(), 'ghostgrid.db');
|
||||||
|
|
||||||
|
/** App-generated primary key: `${prefix}-${epochMs}-${rand}` (e.g. `log-…`, `dev-…`). */
|
||||||
|
export const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
|
||||||
console.log(`[Database] Connecting to SQLite database at: ${DB_FILE}`);
|
console.log(`[Database] Connecting to SQLite database at: ${DB_FILE}`);
|
||||||
const db = new Database(DB_FILE);
|
const db = new Database(DB_FILE);
|
||||||
@ -26,7 +29,9 @@ db.exec(`
|
|||||||
type TEXT NOT NULL,
|
type TEXT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
emergencySheet 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 (
|
CREATE TABLE IF NOT EXISTS labs (
|
||||||
@ -36,7 +41,9 @@ db.exec(`
|
|||||||
contactPerson TEXT NOT NULL,
|
contactPerson TEXT NOT NULL,
|
||||||
location TEXT NOT NULL,
|
location TEXT NOT NULL,
|
||||||
deviceIds 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 (
|
CREATE TABLE IF NOT EXISTS bookings (
|
||||||
@ -47,8 +54,12 @@ db.exec(`
|
|||||||
endDateTime TEXT NOT NULL,
|
endDateTime TEXT NOT NULL,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
notified INTEGER DEFAULT 0,
|
notified INTEGER NOT NULL DEFAULT 0,
|
||||||
emailSent INTEGER 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 (
|
CREATE TABLE IF NOT EXISTS logs (
|
||||||
@ -70,17 +81,128 @@ db.exec(`
|
|||||||
createdBy TEXT,
|
createdBy TEXT,
|
||||||
createdAt TEXT NOT NULL
|
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,
|
||||||
|
redirect TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Lightweight migrations for columns added after the initial release.
|
// Idempotent migrations — SQLite throws on duplicate column; the catch makes them safe to re-run.
|
||||||
// CREATE TABLE IF NOT EXISTS never alters an existing table, so add them by hand.
|
try { db.prepare("ALTER TABLE labs ADD COLUMN scope TEXT NOT NULL DEFAULT 'global'").run(); } catch {}
|
||||||
function ensureColumn(table: string, column: string, ddl: string) {
|
try { db.prepare("ALTER TABLE labs ADD COLUMN ownerId TEXT NOT NULL DEFAULT ''").run(); } catch {}
|
||||||
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
|
|
||||||
if (!cols.some(c => c.name === column)) {
|
// Seed default settings — INSERT OR IGNORE writes a key only if it is absent.
|
||||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
|
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://127.0.0.1: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]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertLog = db.prepare(
|
||||||
|
'INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a logbook entry. `deviceId`/`userId` default to NULL; `timestamp`
|
||||||
|
* defaults to now (pass one to share a single timestamp across a batch).
|
||||||
|
* Returns the generated log id.
|
||||||
|
*/
|
||||||
|
export function addLog(
|
||||||
|
type: string,
|
||||||
|
message: string,
|
||||||
|
opts: { deviceId?: string | null; userId?: string | null; timestamp?: string } = {},
|
||||||
|
): string {
|
||||||
|
const id = uid('log');
|
||||||
|
insertLog.run(id, opts.timestamp ?? new Date().toISOString(), type, message, opts.deviceId ?? null, opts.userId ?? null);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A reverse-proxy route as stored (booleans are SQLite 0/1 integers). */
|
||||||
|
export interface CaddyRoute {
|
||||||
|
id: number;
|
||||||
|
hostname: string;
|
||||||
|
upstream: string;
|
||||||
|
tls: number;
|
||||||
|
compress: number;
|
||||||
|
redirect: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields a caller supplies to create or update a route (JS booleans). */
|
||||||
|
export interface CaddyRouteInput {
|
||||||
|
hostname: string;
|
||||||
|
upstream: string;
|
||||||
|
tls: boolean;
|
||||||
|
compress: boolean;
|
||||||
|
redirect?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCaddyRoutes(): CaddyRoute[] {
|
||||||
|
return db.prepare('SELECT * FROM caddy ORDER BY id ASC').all() as CaddyRoute[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCaddyRouteById(id: number): CaddyRoute | undefined {
|
||||||
|
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addCaddyRoute(route: CaddyRouteInput): CaddyRoute {
|
||||||
|
const { lastInsertRowid } = db.prepare(
|
||||||
|
'INSERT INTO caddy (hostname, upstream, tls, compress, redirect) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
).run(route.hostname, route.upstream, route.tls ? 1 : 0, route.compress ? 1 : 0, route.redirect ?? '');
|
||||||
|
return getCaddyRouteById(Number(lastInsertRowid))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCaddyRoute(id: number, route: CaddyRouteInput): CaddyRoute {
|
||||||
|
db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ?, redirect = ? WHERE id = ?')
|
||||||
|
.run(route.hostname, route.upstream, route.tls ? 1 : 0, route.compress ? 1 : 0, route.redirect ?? '', id);
|
||||||
|
return getCaddyRouteById(id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCaddyRoute(id: number): void {
|
||||||
|
db.prepare('DELETE FROM caddy WHERE id = ?').run(id);
|
||||||
|
}
|
||||||
|
|
||||||
export default db;
|
export default db;
|
||||||
40
server-migrations.ts
Normal file
40
server-migrations.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
interface Migration {
|
||||||
|
id: string; // unique, immutable — format: NNNN_short_description
|
||||||
|
up: (db: InstanceType<typeof Database>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append only. Never reorder or remove entries — that would corrupt tracking.
|
||||||
|
// Each `up` function receives the open DB handle inside an already-open transaction.
|
||||||
|
const migrations: Migration[] = [
|
||||||
|
// Example:
|
||||||
|
// {
|
||||||
|
// id: '0001_bookings_add_color',
|
||||||
|
// up: (db) => {
|
||||||
|
// db.exec(`ALTER TABLE bookings ADD COLUMN color TEXT NOT NULL DEFAULT 'blue'`);
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function runMigrations(db: InstanceType<typeof Database>): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS _migrations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const isApplied = db.prepare('SELECT 1 FROM _migrations WHERE id = ?');
|
||||||
|
const markApplied = db.prepare('INSERT INTO _migrations (id) VALUES (?)');
|
||||||
|
|
||||||
|
for (const migration of migrations) {
|
||||||
|
if (isApplied.get(migration.id)) continue;
|
||||||
|
console.log(`[Migrations] Applying: ${migration.id}`);
|
||||||
|
db.transaction(() => {
|
||||||
|
migration.up(db);
|
||||||
|
markApplied.run(migration.id);
|
||||||
|
})();
|
||||||
|
console.log(`[Migrations] Applied: ${migration.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/App.tsx
103
src/App.tsx
@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { User, Device, LabTemplate, Booking, LogEntry, QuickLink } from './types';
|
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 Header, { GhostGridLogo } from './components/Header';
|
||||||
import Dashboard from './components/Dashboard';
|
import Dashboard from './components/Dashboard';
|
||||||
import BookingCalendar from './components/BookingCalendar';
|
import BookingCalendar from './components/BookingCalendar';
|
||||||
@ -12,10 +12,11 @@ import UserDirectory from './components/UserDirectory';
|
|||||||
import BookingDetailsModal from './components/BookingDetailsModal';
|
import BookingDetailsModal from './components/BookingDetailsModal';
|
||||||
import LoginPage from './components/LoginPage';
|
import LoginPage from './components/LoginPage';
|
||||||
import RegisterPage from './components/RegisterPage';
|
import RegisterPage from './components/RegisterPage';
|
||||||
|
import Settings from './components/Settings';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Calendar, Server, Layers, History, Link as LinkIcon, Users,
|
LayoutDashboard, Calendar, Server, Layers, History, Link as LinkIcon, Users,
|
||||||
PanelLeftClose, PanelLeftOpen,
|
PanelLeftClose, PanelLeftOpen, Settings2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
type AuthView = 'login' | 'register';
|
type AuthView = 'login' | 'register';
|
||||||
@ -47,10 +48,15 @@ export default function App() {
|
|||||||
localStorage.setItem('ghostgrid_nav_collapsed', navCollapsed ? '1' : '0');
|
localStorage.setItem('ghostgrid_nav_collapsed', navCollapsed ? '1' : '0');
|
||||||
}, [navCollapsed]);
|
}, [navCollapsed]);
|
||||||
|
|
||||||
|
const [oauthError, setOauthError] = useState('');
|
||||||
const [notifications, setNotifications] = useState<string[]>([]);
|
const [notifications, setNotifications] = useState<string[]>([]);
|
||||||
const [remindedBookings, setRemindedBookings] = useState<Set<string>>(new Set());
|
const [remindedBookings, setRemindedBookings] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null);
|
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null);
|
||||||
|
const [checkmkEnabled, setCheckmkEnabled] = useState(false);
|
||||||
|
const [checkmkBaseUrl, setCheckmkBaseUrl] = useState('');
|
||||||
|
const [isProduction, setIsProduction] = useState(false);
|
||||||
|
const [semaphoreEnabled, setSemaphoreEnabled] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@ -59,14 +65,45 @@ export default function App() {
|
|||||||
localStorage.setItem('ghostgrid_theme', theme);
|
localStorage.setItem('ghostgrid_theme', theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
// Verify stored token on startup
|
// Verify stored token on startup + handle OAuth callback (?token= / ?auth_error=)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function verifyToken() {
|
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) {
|
if (!token) {
|
||||||
setAuthChecked(true);
|
setAuthChecked(true);
|
||||||
return;
|
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 {
|
try {
|
||||||
const res = await authFetch('/api/auth/me');
|
const res = await authFetch('/api/auth/me');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@ -92,13 +129,14 @@ export default function App() {
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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/users'),
|
||||||
authFetch('/api/devices'),
|
authFetch('/api/devices'),
|
||||||
authFetch('/api/labs'),
|
authFetch('/api/labs'),
|
||||||
authFetch('/api/bookings'),
|
authFetch('/api/bookings'),
|
||||||
authFetch('/api/logs'),
|
authFetch('/api/logs'),
|
||||||
authFetch('/api/links'),
|
authFetch('/api/links'),
|
||||||
|
fetch('/api/auth/config'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (usersRes.ok) setUsers(await usersRes.json());
|
if (usersRes.ok) setUsers(await usersRes.json());
|
||||||
@ -107,6 +145,7 @@ export default function App() {
|
|||||||
if (bookingsRes.ok) setBookings(await bookingsRes.json());
|
if (bookingsRes.ok) setBookings(await bookingsRes.json());
|
||||||
if (logsRes.ok) setLogs(await logsRes.json());
|
if (logsRes.ok) setLogs(await logsRes.json());
|
||||||
if (linksRes.ok) setLinks(await linksRes.json());
|
if (linksRes.ok) setLinks(await linksRes.json());
|
||||||
|
if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); setCheckmkBaseUrl(cfg.checkmkBaseUrl || ''); setIsProduction(!!cfg.isProduction); setSemaphoreEnabled(!!cfg.semaphoreEnabled); }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[App] Failed to load data:', err);
|
console.error('[App] Failed to load data:', err);
|
||||||
} finally {
|
} finally {
|
||||||
@ -261,7 +300,7 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Lab handlers
|
// Lab handlers
|
||||||
const handleAddLab = async (newLab: Omit<LabTemplate, 'id'>) => {
|
const handleAddLab = async (newLab: Omit<LabTemplate, 'id' | 'ownerId'>) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch('/api/labs', { method: 'POST', body: JSON.stringify(newLab) });
|
const res = await authFetch('/api/labs', { method: 'POST', body: JSON.stringify(newLab) });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@ -304,6 +343,36 @@ export default function App() {
|
|||||||
} catch (err) { console.error('[App] Error adding log:', err); }
|
} 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; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetUserRole = async (id: string, role: string) => {
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/users/${id}/role`, { method: 'PATCH', body: JSON.stringify({ role }) });
|
||||||
|
if (res.ok) {
|
||||||
|
const updated: User = await res.json();
|
||||||
|
setUsers(prev => prev.map(u => u.id === id ? updated : u));
|
||||||
|
if (updated.id === currentUser?.id) setCurrentUser(updated);
|
||||||
|
} else { const d = await res.json(); throw new Error(d.error); }
|
||||||
|
} catch (err: any) { throw err; }
|
||||||
|
};
|
||||||
|
|
||||||
// Quick-link handlers (shared link dashboard)
|
// Quick-link handlers (shared link dashboard)
|
||||||
const handleAddLink = async (newLink: Omit<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => {
|
const handleAddLink = async (newLink: Omit<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => {
|
||||||
try {
|
try {
|
||||||
@ -365,6 +434,12 @@ export default function App() {
|
|||||||
{ id: 'logs', label: 'Logbook', icon: <History className="w-4 h-4 shrink-0" /> },
|
{ 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
|
// Startup check not done yet
|
||||||
@ -386,7 +461,7 @@ export default function App() {
|
|||||||
if (authView === 'register') {
|
if (authView === 'register') {
|
||||||
return <RegisterPage onLogin={handleLogin} onNavigateToLogin={() => setAuthView('login')} />;
|
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
|
// Loading data after login
|
||||||
@ -422,6 +497,7 @@ export default function App() {
|
|||||||
theme={theme}
|
theme={theme}
|
||||||
onThemeToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
|
onThemeToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
isProduction={isProduction}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col md:flex-row">
|
<div className="flex-1 flex flex-col md:flex-row">
|
||||||
@ -483,7 +559,7 @@ export default function App() {
|
|||||||
<div className="text-[11px] text-slate-400 leading-relaxed font-sans space-y-0.5">
|
<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>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>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>Labs: <span className="text-white font-semibold font-mono">{labs.length}</span> configured</div>
|
||||||
</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>
|
<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 +588,7 @@ export default function App() {
|
|||||||
labs={labs}
|
labs={labs}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
|
checkmkEnabled={checkmkEnabled}
|
||||||
onAddBooking={handleAddBooking}
|
onAddBooking={handleAddBooking}
|
||||||
onCancelBooking={handleCancelBooking}
|
onCancelBooking={handleCancelBooking}
|
||||||
onDeleteBooking={handleDeleteBooking}
|
onDeleteBooking={handleDeleteBooking}
|
||||||
@ -521,6 +598,8 @@ export default function App() {
|
|||||||
{activeTab === 'devices' && (
|
{activeTab === 'devices' && (
|
||||||
<DeviceInventory
|
<DeviceInventory
|
||||||
devices={devices}
|
devices={devices}
|
||||||
|
checkmkEnabled={checkmkEnabled}
|
||||||
|
checkmkBaseUrl={checkmkBaseUrl}
|
||||||
onAddDevice={handleAddDevice}
|
onAddDevice={handleAddDevice}
|
||||||
onUpdateDevice={handleUpdateDevice}
|
onUpdateDevice={handleUpdateDevice}
|
||||||
onDeleteDevice={handleDeleteDevice}
|
onDeleteDevice={handleDeleteDevice}
|
||||||
@ -530,6 +609,8 @@ export default function App() {
|
|||||||
<LabTemplates
|
<LabTemplates
|
||||||
labs={labs}
|
labs={labs}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
|
currentUser={currentUser!}
|
||||||
|
semaphoreEnabled={semaphoreEnabled}
|
||||||
onAddLab={handleAddLab}
|
onAddLab={handleAddLab}
|
||||||
onUpdateLab={handleUpdateLab}
|
onUpdateLab={handleUpdateLab}
|
||||||
onDeleteLab={handleDeleteLab}
|
onDeleteLab={handleDeleteLab}
|
||||||
@ -550,6 +631,9 @@ export default function App() {
|
|||||||
users={users}
|
users={users}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
bookings={bookings}
|
bookings={bookings}
|
||||||
|
onDeleteUser={handleDeleteUser}
|
||||||
|
onUpdateUser={handleUpdateUser}
|
||||||
|
onSetRole={handleSetUserRole}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'logs' && (
|
{activeTab === 'logs' && (
|
||||||
@ -561,6 +645,9 @@ export default function App() {
|
|||||||
onAddLog={handleAddLogManually}
|
onAddLog={handleAddLogManually}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'settings' && (
|
||||||
|
<Settings currentUser={currentUser} />
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,14 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { Booking, LabTemplate, Device, User } from '../types';
|
import { Booking, LabTemplate, Device, User } from '../types';
|
||||||
import {
|
import {
|
||||||
Calendar, Zap, CheckCircle2, AlertCircle, AlertTriangle, ChevronLeft, ChevronRight, Database,
|
Calendar, Zap, CheckCircle2, AlertCircle, AlertTriangle, ChevronLeft, ChevronRight, Database,
|
||||||
X, Layers, Server, Clock, ChevronDown
|
X, Layers, Server, Clock, ChevronDown
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
/** A device can only be reserved when CheckMK reports it online. */
|
|
||||||
function effectiveStatus(d: Device): 'online' | 'offline' | 'unknown' {
|
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';
|
return effectiveStatus(d) === 'online';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -18,6 +17,7 @@ interface BookingCalendarProps {
|
|||||||
labs: LabTemplate[];
|
labs: LabTemplate[];
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
|
checkmkEnabled: boolean;
|
||||||
onAddBooking: (booking: Omit<Booking, 'id' | 'notified' | 'emailSent'>) => void;
|
onAddBooking: (booking: Omit<Booking, 'id' | 'notified' | 'emailSent'>) => void;
|
||||||
onCancelBooking: (id: string) => void;
|
onCancelBooking: (id: string) => void;
|
||||||
onDeleteBooking: (id: string) => void;
|
onDeleteBooking: (id: string) => void;
|
||||||
@ -73,6 +73,7 @@ export default function BookingCalendar({
|
|||||||
labs,
|
labs,
|
||||||
devices,
|
devices,
|
||||||
currentUser,
|
currentUser,
|
||||||
|
checkmkEnabled,
|
||||||
onAddBooking,
|
onAddBooking,
|
||||||
onCancelBooking,
|
onCancelBooking,
|
||||||
onDeleteBooking,
|
onDeleteBooking,
|
||||||
@ -156,11 +157,11 @@ export default function BookingCalendar({
|
|||||||
return { hasConflict: false };
|
return { hasConflict: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Devices in the current selection that CheckMK does not report as online - these block the booking.
|
// Devices in the current selection that CheckMK does not report as online - shown as a warning only.
|
||||||
function blockingDevices(deviceIds: string[]): Device[] {
|
function offlineDevices(deviceIds: string[]): Device[] {
|
||||||
return deviceIds
|
return deviceIds
|
||||||
.map(id => devices.find(d => d.id === id))
|
.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 ────────────────────────────
|
// ── available-now helpers for Quick Booking ────────────────────────────
|
||||||
@ -171,17 +172,26 @@ export default function BookingCalendar({
|
|||||||
return { startMs: start.getTime(), endMs: end.getTime(), start, end };
|
return { startMs: start.getTime(), endMs: end.getTime(), start, end };
|
||||||
}, [quickDuration]);
|
}, [quickDuration]);
|
||||||
|
|
||||||
// A lab is quick-bookable only when every device is free AND reported online by CheckMK.
|
const bookableLabs = useMemo(() => labs.filter(l =>
|
||||||
const availableLabs = useMemo(() => labs.filter(lab =>
|
l.scope === 'global' ||
|
||||||
|
l.ownerId === currentUser.id ||
|
||||||
|
currentUser.role?.toLowerCase() === 'admin'
|
||||||
|
), [labs, currentUser.id, currentUser.role]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedLabId && !bookableLabs.find(l => l.id === selectedLabId)) {
|
||||||
|
setSelectedLabId(bookableLabs[0]?.id || '');
|
||||||
|
}
|
||||||
|
}, [bookableLabs]);
|
||||||
|
|
||||||
|
// A lab is quick-bookable when every device is free (regardless of online status).
|
||||||
|
const availableLabs = useMemo(() => bookableLabs.filter(lab =>
|
||||||
lab.deviceIds.length > 0 &&
|
lab.deviceIds.length > 0 &&
|
||||||
lab.deviceIds.every(dId => {
|
lab.deviceIds.every(dId => !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs))
|
||||||
const dev = devices.find(d => d.id === dId);
|
), [bookableLabs, devices, bookings, quickWindow]);
|
||||||
return !!dev && isBookable(dev) && !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs);
|
|
||||||
})
|
|
||||||
), [labs, devices, bookings, quickWindow]);
|
|
||||||
|
|
||||||
const availableDevices = useMemo(() => devices.filter(dev =>
|
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]);
|
), [devices, bookings, quickWindow]);
|
||||||
|
|
||||||
// ── booking actions ────────────────────────────────────────────────────
|
// ── booking actions ────────────────────────────────────────────────────
|
||||||
@ -191,12 +201,6 @@ export default function BookingCalendar({
|
|||||||
const deviceIds = targetDeviceIds();
|
const deviceIds = targetDeviceIds();
|
||||||
if (deviceIds.length === 0) { alert('Please select a resource to reserve.'); return; }
|
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);
|
const conflict = checkConflict(deviceIds, startDate, startTime, endDate, endTime);
|
||||||
if (conflict.hasConflict) { alert(conflict.message); return; }
|
if (conflict.hasConflict) { alert(conflict.message); return; }
|
||||||
|
|
||||||
@ -225,10 +229,6 @@ export default function BookingCalendar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickBookDevice = (device: Device) => {
|
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
|
// 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));
|
const hostLab = labs.find(l => l.deviceIds.includes(device.id));
|
||||||
onAddBooking({
|
onAddBooking({
|
||||||
@ -283,7 +283,7 @@ export default function BookingCalendar({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-500 font-mono">
|
<p className="text-[10px] text-slate-500 font-mono">
|
||||||
{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} →{' '}
|
{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} >{' '}
|
||||||
{new Date(Date.now() + quickDuration * 3600_000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
{new Date(Date.now() + quickDuration * 3600_000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -312,16 +312,22 @@ export default function BookingCalendar({
|
|||||||
<div className="px-5 pb-5 pt-3 max-h-72 overflow-y-auto space-y-2">
|
<div className="px-5 pb-5 pt-3 max-h-72 overflow-y-auto space-y-2">
|
||||||
{quickTab === 'labs' ? (
|
{quickTab === 'labs' ? (
|
||||||
availableLabs.length === 0 ? (
|
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 => {
|
availableLabs.map(lab => {
|
||||||
const labDevices = lab.deviceIds.map(id => devices.find(d => d.id === id)).filter(Boolean) as Device[];
|
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 (
|
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 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">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-bold text-white truncate">{lab.name}</p>
|
<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-[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>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleQuickBookLab(lab)}
|
onClick={() => handleQuickBookLab(lab)}
|
||||||
@ -338,30 +344,30 @@ export default function BookingCalendar({
|
|||||||
const status = effectiveStatus(device);
|
const status = effectiveStatus(device);
|
||||||
const online = status === 'online';
|
const online = status === 'online';
|
||||||
const free = !isDeviceBooked(device.id, quickWindow.startMs, quickWindow.endMs);
|
const free = !isDeviceBooked(device.id, quickWindow.startMs, quickWindow.endMs);
|
||||||
const bookable = online && free;
|
|
||||||
return (
|
return (
|
||||||
<div key={device.id} className={`flex items-center justify-between gap-3 p-3 border rounded-lg transition-all ${
|
<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="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<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'}`} />
|
<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>
|
<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>
|
</div>
|
||||||
<p className="text-[10px] text-slate-400 font-mono">{device.type} · {device.ip}</p>
|
<p className="text-[10px] text-slate-400 font-mono">{device.type} · {device.ip}</p>
|
||||||
<p className="text-[9px] text-slate-500">{device.location}</p>
|
<p className="text-[9px] text-slate-500">{device.location}</p>
|
||||||
</div>
|
</div>
|
||||||
{bookable ? (
|
{free ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleQuickBookDevice(device)}
|
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"
|
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
|
Book
|
||||||
</button>
|
</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>
|
<span className="shrink-0 text-[10px] text-rose-400 font-mono font-semibold">Busy</span>
|
||||||
)}
|
)}
|
||||||
@ -582,9 +588,20 @@ export default function BookingCalendar({
|
|||||||
onChange={(e) => setSelectedLabId(e.target.value)}
|
onChange={(e) => setSelectedLabId(e.target.value)}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500"
|
||||||
>
|
>
|
||||||
{labs.map((l) => (
|
{bookableLabs.filter(l => l.scope === 'global').length > 0 && (
|
||||||
|
<optgroup label="Global Topologies">
|
||||||
|
{bookableLabs.filter(l => l.scope === 'global').map((l) => (
|
||||||
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
|
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
|
||||||
))}
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
{bookableLabs.filter(l => l.scope === 'personal').length > 0 && (
|
||||||
|
<optgroup label="My Personal Topologies">
|
||||||
|
{bookableLabs.filter(l => l.scope === 'personal').map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -597,7 +614,7 @@ export default function BookingCalendar({
|
|||||||
>
|
>
|
||||||
{devices.map((d) => (
|
{devices.map((d) => (
|
||||||
<option key={d.id} value={d.id}>
|
<option key={d.id} value={d.id}>
|
||||||
{isBookable(d) ? '🟢' : '⚠️'} {d.hostname} · {d.type} ({d.ip})
|
{isOnline(d) ? '🟢' : '⚠️'} {d.hostname} · {d.type} ({d.ip})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -662,7 +679,6 @@ export default function BookingCalendar({
|
|||||||
<textarea
|
<textarea
|
||||||
required
|
required
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="e.g. Validating STP failover convergence times..."
|
|
||||||
value={bookingNotes}
|
value={bookingNotes}
|
||||||
onChange={(e) => setBookingNotes(e.target.value)}
|
onChange={(e) => setBookingNotes(e.target.value)}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500"
|
||||||
@ -671,28 +687,31 @@ export default function BookingCalendar({
|
|||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const deviceIds = targetDeviceIds();
|
const deviceIds = targetDeviceIds();
|
||||||
const blocked = blockingDevices(deviceIds);
|
const offline = offlineDevices(deviceIds);
|
||||||
const conflict = checkConflict(deviceIds, startDate, startTime, endDate, endTime);
|
const conflict = checkConflict(deviceIds, startDate, startTime, endDate, endTime);
|
||||||
|
|
||||||
if (blocked.length > 0) {
|
if (conflict.hasConflict) {
|
||||||
return (
|
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">
|
<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" />
|
<AlertCircle className="w-4 h-4 text-rose-400 shrink-0" />
|
||||||
<span>{conflict.message}</span>
|
<span>{conflict.message}</span>
|
||||||
</div>
|
</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">
|
<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" />
|
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||||
<span>Online & free. Timeframe is available.</span>
|
<span>Timeframe is available.</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@ -700,7 +719,6 @@ export default function BookingCalendar({
|
|||||||
{(() => {
|
{(() => {
|
||||||
const deviceIds = targetDeviceIds();
|
const deviceIds = targetDeviceIds();
|
||||||
const disabled = deviceIds.length === 0
|
const disabled = deviceIds.length === 0
|
||||||
|| blockingDevices(deviceIds).length > 0
|
|
||||||
|| checkConflict(deviceIds, startDate, startTime, endDate, endTime).hasConflict;
|
|| checkConflict(deviceIds, startDate, startTime, endDate, endTime).hasConflict;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -5,9 +5,10 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Booking, LabTemplate, Device, User } from '../types';
|
import { Booking, LabTemplate, Device, User } from '../types';
|
||||||
|
import { authFetch } from '../lib/auth';
|
||||||
import {
|
import {
|
||||||
X, Calendar, Clock, UserIcon, Database, Terminal, Cpu, Play, Check,
|
X, Calendar, Clock, UserIcon, Database, Terminal, Play, Check,
|
||||||
Trash2, Ban, Copy, CheckCircle, HelpCircle, HardDrive
|
Trash2, Ban, Copy, CheckCircle, HelpCircle, HardDrive,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface BookingDetailsModalProps {
|
interface BookingDetailsModalProps {
|
||||||
@ -39,12 +40,43 @@ export default function BookingDetailsModal({
|
|||||||
// Find devices mapped to this booking
|
// Find devices mapped to this booking
|
||||||
const mappedDevices = devices.filter(d => lab?.deviceIds.includes(d.id));
|
const mappedDevices = devices.filter(d => lab?.deviceIds.includes(d.id));
|
||||||
|
|
||||||
// Developer panel tabs ('rest', 'ansible', 'terminal')
|
const [triggerStatus, setTriggerStatus] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'rest' | 'ansible' | 'terminal'>('ansible');
|
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 [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', {
|
const startFormatted = new Date(booking.startDateTime).toLocaleString('en-US', {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
@ -64,35 +96,6 @@ export default function BookingDetailsModal({
|
|||||||
timeZoneName: 'short'
|
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({
|
const mockJsonResponse = JSON.stringify({
|
||||||
retrievedAt: new Date().toISOString(),
|
retrievedAt: new Date().toISOString(),
|
||||||
apiEndpoint: `/api/bookings/${booking.id}`,
|
apiEndpoint: `/api/bookings/${booking.id}`,
|
||||||
@ -108,57 +111,12 @@ ${ipList.map(ip => ` - "${ip}"`).join('\n') || ' - "No registered targ
|
|||||||
}
|
}
|
||||||
}, null, 2);
|
}, null, 2);
|
||||||
|
|
||||||
const handleCopyText = (text: string) => {
|
const handleCopyJson = () => {
|
||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(mockJsonResponse);
|
||||||
setIsCopied(true);
|
setIsCopied(true);
|
||||||
setTimeout(() => setIsCopied(false), 2000);
|
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 (
|
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="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]">
|
<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>
|
</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">
|
<div className="border border-slate-800 rounded-xl overflow-hidden bg-slate-950 font-mono">
|
||||||
|
<div className="bg-slate-900 border-b border-slate-800 px-4 py-2 flex items-center justify-between">
|
||||||
{/* 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">
|
|
||||||
<span className="text-[10px] uppercase tracking-wider font-bold text-indigo-400 font-sans flex items-center gap-2">
|
<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" />
|
<Database className="w-3.5 h-3.5" />
|
||||||
Developer Restful API & Ansible Integration
|
GET /api/bookings/{booking.id}
|
||||||
</span>
|
</span>
|
||||||
|
<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 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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="p-4 relative overflow-x-auto max-h-[260px] overflow-y-auto">
|
||||||
|
|
||||||
{/* 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' && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCopyText(activeTab === 'ansible' ? ansiblePlaybook : mockJsonResponse)}
|
onClick={handleCopyJson}
|
||||||
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"
|
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" />}
|
{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>
|
<span>{isCopied ? 'Copied' : 'Copy'}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
<pre className="text-slate-300 text-[11px] leading-relaxed select-all pr-16">
|
||||||
|
|
||||||
{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">
|
|
||||||
{mockJsonResponse}
|
{mockJsonResponse}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -65,10 +65,10 @@ export default function Dashboard({
|
|||||||
// Quick state checklist for the user to mark items as done as they test their lab!
|
// 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)
|
// (kept deliberately nerdy - morale is a critical infrastructure dependency)
|
||||||
const [todoList, setTodoList] = useState([
|
const [todoList, setTodoList] = useState([
|
||||||
{ id: 't1', text: 'Ping 8.8.8.8 to confirm the Internet still exists', checked: false },
|
{ id: 't1', text: 'Verify network connectivity (ping gateway)', checked: false },
|
||||||
{ id: 't2', text: 'Coffee level nominal ☕ - packets route faster on caffeine', checked: true },
|
{ id: 't2', text: 'Coffee ready ☕', checked: true },
|
||||||
{ id: 't3', text: "Rule out DNS first (narrator: it was, in fact, always DNS)", checked: false },
|
{ id: 't3', text: 'Check DNS resolution', checked: false },
|
||||||
{ id: 't4', text: 'Layer-1 check: is it actually plugged in? (PEBKAC defense)', checked: false }
|
{ id: 't4', text: 'Confirm physical connections are in place', checked: false }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const toggleTodo = (id: string) => {
|
const toggleTodo = (id: string) => {
|
||||||
@ -91,21 +91,17 @@ export default function Dashboard({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6" id="dashboard-cockpit-root">
|
<div className="space-y-6" id="dashboard-cockpit-root">
|
||||||
|
|
||||||
{/* Banner Card Grid */}
|
{/* Banner */}
|
||||||
<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="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="absolute top-0 right-0 p-8 text-slate-800 pointer-events-none select-none font-mono text-9xl font-extrabold opacity-10">
|
<div className="space-y-1.5">
|
||||||
NET
|
<h2 className="text-xl font-bold tracking-tight text-white font-sans">
|
||||||
</div>
|
Welcome back, <span className="text-emerald-400">{currentUser.name}</span>
|
||||||
|
|
||||||
<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>!
|
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-300 leading-relaxed font-sans max-w-xl">
|
<p className="text-xs text-slate-400 font-sans">
|
||||||
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).
|
Your lab management hub. Reserve hardware, track active sessions, and keep runbooks close.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
<div className="pt-2 flex items-center gap-3">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateToCalendar}
|
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"
|
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>
|
</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 */}
|
{/* Main Grid Content */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
<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">
|
<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">
|
<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">
|
<span className="flex items-center gap-2">
|
||||||
<Clock className="w-4.5 h-4.5 text-emerald-400" />
|
<Clock className="w-4 h-4 text-emerald-400" />
|
||||||
Active Reservations (your boxes, right now)
|
Active Reservations
|
||||||
</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
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500 shrink-0" />
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{activeBookings.length === 0 ? (
|
{activeBookings.length === 0 ? (
|
||||||
<div className="text-center py-10 bg-slate-900/35 rounded-lg border border-slate-850 p-6 font-sans">
|
<div className="text-center py-8 bg-slate-900/35 rounded-lg border border-slate-800 font-sans">
|
||||||
<PlayCircle className="w-8 h-8 text-slate-700 mx-auto mb-2 opacity-50" />
|
<PlayCircle className="w-7 h-7 text-slate-600 mx-auto mb-2 opacity-50" />
|
||||||
<p className="text-xs text-slate-400">No boxes checked out right now. idle hands, idle hardware.</p>
|
<p className="text-xs text-slate-400">No active sessions.</p>
|
||||||
<button
|
|
||||||
onClick={onNavigateToCalendar}
|
|
||||||
className="text-xs text-emerald-400 font-semibold underline mt-2 hover:text-emerald-300"
|
|
||||||
>
|
|
||||||
grab a slot ->
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 font-sans">
|
<div className="space-y-4 font-sans">
|
||||||
@ -209,28 +169,24 @@ export default function Dashboard({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-slate-300 leading-relaxed font-sans mb-3 italic">
|
<div className="pt-3 border-t border-slate-800/60 flex justify-between items-center text-[10px]">
|
||||||
"{booking.notes || 'no notes - running blind'}"
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="pt-3 border-t border-slate-900/60 flex justify-between items-center text-[10px]">
|
|
||||||
<span className="font-mono text-slate-400">
|
<span className="font-mono text-slate-400">
|
||||||
Active window: {startF} - {endF}
|
{startF} – {endF}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => onSelectBookingDetails(booking)}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
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);
|
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
|
Release
|
||||||
</button>
|
</button>
|
||||||
@ -245,13 +201,13 @@ export default function Dashboard({
|
|||||||
|
|
||||||
{/* Upcoming Sessions */}
|
{/* Upcoming Sessions */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
|
<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">
|
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-3.5">
|
||||||
<Calendar className="w-4.5 h-4.5 text-indigo-400" />
|
<Calendar className="w-4 h-4 text-slate-400" />
|
||||||
Upcoming in the Queue ({upcomingBookings.length})
|
Upcoming ({upcomingBookings.length})
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{upcomingBookings.length === 0 ? (
|
{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">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{upcomingBookings.map((booking) => {
|
{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 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' });
|
const endF = new Date(booking.endDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||||
return (
|
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>
|
||||||
<div className="flex justify-between items-start mb-1">
|
<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}
|
{dayStr}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] font-mono text-slate-500">
|
<span className="text-[10px] font-mono text-slate-500">
|
||||||
{startF} - {endF}
|
{startF} – {endF}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-xs font-bold text-slate-200 mt-1 font-sans">{lab?.name}</h4>
|
<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>
|
</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={() => onSelectBookingDetails(booking)}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm('Cancel this upcoming reservation? Linked SMTP alerts will notify appropriate parties.')) {
|
if (confirm('Cancel this upcoming reservation?')) {
|
||||||
onCancelBooking(booking.id);
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
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);
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -316,62 +272,58 @@ export default function Dashboard({
|
|||||||
{/* RIGHT COLUMN: Checklist and simulated action panel */}
|
{/* RIGHT COLUMN: Checklist and simulated action panel */}
|
||||||
<div className="lg:col-span-4 space-y-6">
|
<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">
|
<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">
|
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-3.5">
|
||||||
<ListTodo className="w-4.5 h-4.5 text-amber-500" />
|
<ListTodo className="w-4 h-4 text-slate-400" />
|
||||||
Pre-Flight Checklist (before you blame the network)
|
Lab Checklist
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2">
|
||||||
{todoList.map((item) => (
|
{todoList.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => toggleTodo(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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={item.checked}
|
checked={item.checked}
|
||||||
onChange={() => {}}
|
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}
|
{item.text}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</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">
|
<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">
|
<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
|
Quick Links
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateToLinks}
|
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" />
|
Manage <ArrowRight className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{links.length === 0 ? (
|
{links.length === 0 ? (
|
||||||
<div className="text-center py-6 bg-slate-900/35 rounded-lg border border-slate-850 p-5">
|
<div className="text-center py-6 bg-slate-900/35 rounded-lg border border-slate-800">
|
||||||
<Globe className="w-7 h-7 text-slate-700 mx-auto mb-2 opacity-50" />
|
<Globe className="w-7 h-7 text-slate-600 mx-auto mb-2 opacity-50" />
|
||||||
<p className="text-[11px] text-slate-400">No shared links yet.</p>
|
<p className="text-xs text-slate-400">No shared links yet.</p>
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateToLinks}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -386,23 +338,23 @@ export default function Dashboard({
|
|||||||
href={link.url}
|
href={link.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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}`}>
|
<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" />
|
<Globe className="w-3.5 h-3.5" />
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<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-xs 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-[10px] font-mono truncate ${accent}`}>{host}</span>
|
||||||
</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>
|
</a>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{links.length > 6 && (
|
{links.length > 6 && (
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateToLinks}
|
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
|
+{links.length - 6} more links
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Device, DeviceType } from '../types';
|
import { Device, DeviceType } from '../types';
|
||||||
import {
|
import {
|
||||||
Server, Search, Plus, Trash, Edit2, MapPin, Info,
|
Server, Search, Plus, Trash, Edit2, MapPin, Gauge,
|
||||||
BookOpen, Save, X, ExternalLink, Gauge
|
BookOpen, Save, X, Info, ExternalLink
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// Built-in device class presets shown in the dropdown.
|
// Built-in device class presets shown in the dropdown.
|
||||||
@ -15,6 +15,8 @@ const DEVICE_CLASS_PRESETS = ['Switch', 'Firewall', 'Access-Point', 'Controller'
|
|||||||
|
|
||||||
interface DeviceInventoryProps {
|
interface DeviceInventoryProps {
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
|
checkmkEnabled: boolean;
|
||||||
|
checkmkBaseUrl: string;
|
||||||
onAddDevice: (device: Omit<Device, 'id'>) => void;
|
onAddDevice: (device: Omit<Device, 'id'>) => void;
|
||||||
onUpdateDevice: (device: Device) => void;
|
onUpdateDevice: (device: Device) => void;
|
||||||
onDeleteDevice: (id: string) => void;
|
onDeleteDevice: (id: string) => void;
|
||||||
@ -22,6 +24,8 @@ interface DeviceInventoryProps {
|
|||||||
|
|
||||||
export default function DeviceInventory({
|
export default function DeviceInventory({
|
||||||
devices,
|
devices,
|
||||||
|
checkmkEnabled,
|
||||||
|
checkmkBaseUrl,
|
||||||
onAddDevice,
|
onAddDevice,
|
||||||
onUpdateDevice,
|
onUpdateDevice,
|
||||||
onDeleteDevice,
|
onDeleteDevice,
|
||||||
@ -50,7 +54,6 @@ export default function DeviceInventory({
|
|||||||
notes: string;
|
notes: string;
|
||||||
type: DeviceType;
|
type: DeviceType;
|
||||||
emergencySheet: string;
|
emergencySheet: string;
|
||||||
checkMkUrl: string;
|
|
||||||
}>({
|
}>({
|
||||||
hostname: '',
|
hostname: '',
|
||||||
ip: '',
|
ip: '',
|
||||||
@ -58,12 +61,13 @@ export default function DeviceInventory({
|
|||||||
notes: '',
|
notes: '',
|
||||||
type: 'Switch',
|
type: 'Switch',
|
||||||
emergencySheet: '',
|
emergencySheet: '',
|
||||||
checkMkUrl: ''
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Effective status: nothing is known until CheckMK is linked and reports a state.
|
const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' => d.status;
|
||||||
const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' =>
|
const cmkHostUrl = (d: Device) =>
|
||||||
d.checkMkUrl ? (d.status === 'online' || d.status === 'offline' ? d.status : 'unknown') : 'unknown';
|
checkmkEnabled && checkmkBaseUrl && d.cmkHostname
|
||||||
|
? `${checkmkBaseUrl}/index.py?host=${encodeURIComponent(d.cmkHostname)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
const statusMeta = (s: 'online' | 'offline' | 'unknown') => {
|
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' };
|
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: '',
|
location: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
type: 'Switch',
|
type: 'Switch',
|
||||||
checkMkUrl: '',
|
|
||||||
emergencySheet: `### EMERGENCY MANUAL [HOSTNAME]
|
emergencySheet: `### EMERGENCY MANUAL [HOSTNAME]
|
||||||
|
|
||||||
**Device Type:** [Enter Model]
|
**Device Type:** [Enter Model]
|
||||||
@ -119,7 +122,6 @@ export default function DeviceInventory({
|
|||||||
location: dev.location,
|
location: dev.location,
|
||||||
notes: dev.notes,
|
notes: dev.notes,
|
||||||
type: dev.type,
|
type: dev.type,
|
||||||
checkMkUrl: dev.checkMkUrl ?? '',
|
|
||||||
emergencySheet: dev.emergencySheet
|
emergencySheet: dev.emergencySheet
|
||||||
});
|
});
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
@ -138,7 +140,6 @@ export default function DeviceInventory({
|
|||||||
type: formData.type,
|
type: formData.type,
|
||||||
status: 'unknown',
|
status: 'unknown',
|
||||||
emergencySheet: formData.emergencySheet,
|
emergencySheet: formData.emergencySheet,
|
||||||
checkMkUrl: formData.checkMkUrl
|
|
||||||
});
|
});
|
||||||
} else if (formMode === 'edit' && formData.id) {
|
} else if (formMode === 'edit' && formData.id) {
|
||||||
const match = devices.find(d => d.id === formData.id);
|
const match = devices.find(d => d.id === formData.id);
|
||||||
@ -151,7 +152,6 @@ export default function DeviceInventory({
|
|||||||
notes: formData.notes,
|
notes: formData.notes,
|
||||||
type: formData.type,
|
type: formData.type,
|
||||||
emergencySheet: formData.emergencySheet,
|
emergencySheet: formData.emergencySheet,
|
||||||
checkMkUrl: formData.checkMkUrl
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -255,7 +255,6 @@ export default function DeviceInventory({
|
|||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by hostname, IP address, rack location..."
|
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none focus:border-emerald-500 transition-colors placeholder:text-slate-500"
|
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none focus:border-emerald-500 transition-colors placeholder:text-slate-500"
|
||||||
@ -324,26 +323,25 @@ export default function DeviceInventory({
|
|||||||
|
|
||||||
{/* Right: Actions and Status */}
|
{/* Right: Actions and Status */}
|
||||||
<div className="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
|
||||||
{/* CheckMK Monitoring Badge */}
|
{/* CheckMK Status Badge – only when CheckMK is enabled */}
|
||||||
{(() => { const m = statusMeta(effectiveStatus(device)); return (
|
{checkmkEnabled && (() => { const m = statusMeta(effectiveStatus(device)); return (
|
||||||
<div className="flex flex-col items-end gap-1 font-sans">
|
<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={`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}`} />
|
<span className={`w-1.5 h-1.5 rounded-full ${m.dot}`} />
|
||||||
{m.label}
|
{m.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] font-mono text-slate-500">{device.checkMkUrl ? 'via CheckMK' : 'not linked'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
); })()}
|
); })()}
|
||||||
|
|
||||||
{/* Action Panel */}
|
{/* Action Panel */}
|
||||||
<div className="flex items-center gap-1.5 border-l border-slate-800/80 pl-3">
|
<div className="flex items-center gap-1.5 border-l border-slate-800/80 pl-3">
|
||||||
{device.checkMkUrl && (
|
{cmkHostUrl(device) && (
|
||||||
<a
|
<a
|
||||||
href={device.checkMkUrl}
|
href={cmkHostUrl(device)!}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-cyan-400 transition-colors"
|
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" />
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
</a>
|
</a>
|
||||||
@ -403,7 +401,8 @@ export default function DeviceInventory({
|
|||||||
</div>
|
</div>
|
||||||
</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="mt-4 pt-4 border-t border-slate-800 space-y-2.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-slate-400 font-sans font-medium flex items-center gap-1.5">
|
<span className="text-xs text-slate-400 font-sans font-medium flex items-center gap-1.5">
|
||||||
@ -417,9 +416,9 @@ export default function DeviceInventory({
|
|||||||
</span>
|
</span>
|
||||||
); })()}
|
); })()}
|
||||||
</div>
|
</div>
|
||||||
{selectedDevice.checkMkUrl ? (
|
{cmkHostUrl(selectedDevice) && (
|
||||||
<a
|
<a
|
||||||
href={selectedDevice.checkMkUrl}
|
href={cmkHostUrl(selectedDevice)!}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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 +426,14 @@ export default function DeviceInventory({
|
|||||||
<ExternalLink className="w-3.5 h-3.5" />
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
Open host in CheckMK
|
Open host in CheckMK
|
||||||
</a>
|
</a>
|
||||||
) : (
|
)}
|
||||||
<p className="text-[11px] text-slate-500 italic bg-slate-900/50 border border-slate-800 rounded p-2 leading-relaxed">
|
{selectedDevice.lastCheckedAt && (
|
||||||
No CheckMK host linked yet. Add the host URL in the modify window - live status will be pulled from the CheckMK API.
|
<p className="text-[10px] text-slate-500 font-mono">
|
||||||
|
Last checked: {new Date(selectedDevice.lastCheckedAt).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Emergency rescue guidelines sheet */}
|
{/* Emergency rescue guidelines sheet */}
|
||||||
@ -495,7 +496,6 @@ Pick a box from the list to see its specs and break-glass playbook.
|
|||||||
value={formData.hostname}
|
value={formData.hostname}
|
||||||
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono"
|
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono"
|
||||||
placeholder="SW-CORE-03"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -506,7 +506,6 @@ Pick a box from the list to see its specs and break-glass playbook.
|
|||||||
value={formData.ip}
|
value={formData.ip}
|
||||||
onChange={(e) => setFormData({ ...formData, ip: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, ip: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono"
|
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono"
|
||||||
placeholder="172.16.x.x"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -520,7 +519,6 @@ Pick a box from the list to see its specs and break-glass playbook.
|
|||||||
value={formData.location}
|
value={formData.location}
|
||||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
||||||
placeholder="Server Room R02, Rack C4..."
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -552,7 +550,6 @@ Pick a box from the list to see its specs and break-glass playbook.
|
|||||||
value={formData.type}
|
value={formData.type}
|
||||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as DeviceType })}
|
onChange={(e) => setFormData({ ...formData, type: e.target.value as DeviceType })}
|
||||||
className="w-full mt-2 bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
className="w-full mt-2 bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
||||||
placeholder="Enter new device class (e.g. Router, Load-Balancer)"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -565,33 +562,9 @@ Pick a box from the list to see its specs and break-glass playbook.
|
|||||||
value={formData.notes}
|
value={formData.notes}
|
||||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
||||||
placeholder="Serial numbers, module slots, connected uplinks, license status..."
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Disaster Recovery Guide (Markdown formatting supported)</label>
|
<label className="block text-slate-300 font-semibold mb-1">Disaster Recovery Guide (Markdown formatting supported)</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@ -11,6 +11,7 @@ interface HeaderProps {
|
|||||||
theme: 'dark' | 'light';
|
theme: 'dark' | 'light';
|
||||||
onThemeToggle: () => void;
|
onThemeToggle: () => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
|
isProduction: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Header({
|
export default function Header({
|
||||||
@ -22,6 +23,7 @@ export default function Header({
|
|||||||
theme,
|
theme,
|
||||||
onThemeToggle,
|
onThemeToggle,
|
||||||
onLogout,
|
onLogout,
|
||||||
|
isProduction,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
const [showMailInbox, setShowMailInbox] = useState(false);
|
const [showMailInbox, setShowMailInbox] = useState(false);
|
||||||
const [showBellDropdown, setShowBellDropdown] = useState(false);
|
const [showBellDropdown, setShowBellDropdown] = useState(false);
|
||||||
@ -63,8 +65,8 @@ export default function Header({
|
|||||||
|
|
||||||
{/* System Indicator */}
|
{/* System Indicator */}
|
||||||
<div className="hidden md:flex items-center gap-2 px-3 py-1 bg-slate-800/60 rounded-full border border-slate-700/50 text-xs font-mono text-slate-300">
|
<div className="hidden md:flex items-center gap-2 px-3 py-1 bg-slate-800/60 rounded-full border border-slate-700/50 text-xs font-mono text-slate-300">
|
||||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
<span className={`w-2 h-2 rounded-full animate-pulse ${isProduction ? 'bg-emerald-500' : 'bg-amber-400'}`} />
|
||||||
<span>System: Online (Simulated)</span>
|
<span>System: {isProduction ? 'Production' : 'Development'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mail Inbox */}
|
{/* Mail Inbox */}
|
||||||
|
|||||||
@ -4,17 +4,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { LabTemplate, Device, TopologyLink } from '../types';
|
import { LabTemplate, Device, TopologyLink, User } from '../types';
|
||||||
import TopologyPanel from './TopologyPanel';
|
import TopologyPanel from './TopologyPanel';
|
||||||
import {
|
import {
|
||||||
Server, Plus, Edit3, Trash, User, MapPin,
|
Server, Plus, Edit3, Trash, User as UserIcon, MapPin,
|
||||||
Layers, ChevronRight, Save, X, Check
|
Layers, ChevronRight, X, Check, Pencil, Terminal, Lock, Globe,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface LabTemplatesProps {
|
interface LabTemplatesProps {
|
||||||
labs: LabTemplate[];
|
labs: LabTemplate[];
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
onAddLab: (lab: Omit<LabTemplate, 'id'>) => void;
|
currentUser: User;
|
||||||
|
semaphoreEnabled: boolean;
|
||||||
|
onAddLab: (lab: Omit<LabTemplate, 'id' | 'ownerId'>) => void;
|
||||||
onUpdateLab: (lab: LabTemplate) => void;
|
onUpdateLab: (lab: LabTemplate) => void;
|
||||||
onDeleteLab: (id: string) => void;
|
onDeleteLab: (id: string) => void;
|
||||||
onOpenDeviceDetails: (device: Device) => void;
|
onOpenDeviceDetails: (device: Device) => void;
|
||||||
@ -23,6 +25,8 @@ interface LabTemplatesProps {
|
|||||||
export default function LabTemplates({
|
export default function LabTemplates({
|
||||||
labs,
|
labs,
|
||||||
devices,
|
devices,
|
||||||
|
currentUser,
|
||||||
|
semaphoreEnabled,
|
||||||
onAddLab,
|
onAddLab,
|
||||||
onUpdateLab,
|
onUpdateLab,
|
||||||
onDeleteLab,
|
onDeleteLab,
|
||||||
@ -37,6 +41,8 @@ export default function LabTemplates({
|
|||||||
const [linkFrom, setLinkFrom] = useState('');
|
const [linkFrom, setLinkFrom] = useState('');
|
||||||
const [linkTo, setLinkTo] = useState('');
|
const [linkTo, setLinkTo] = useState('');
|
||||||
const [linkType, setLinkType] = useState('Trunk Uplink');
|
const [linkType, setLinkType] = useState('Trunk Uplink');
|
||||||
|
const [editingLinkIdx, setEditingLinkIdx] = useState<number | null>(null);
|
||||||
|
const [editingLinkLabel, setEditingLinkLabel] = useState('');
|
||||||
|
|
||||||
const [formData, setFormData] = useState<{
|
const [formData, setFormData] = useState<{
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -45,12 +51,18 @@ export default function LabTemplates({
|
|||||||
contactPerson: string;
|
contactPerson: string;
|
||||||
location: string;
|
location: string;
|
||||||
deviceIds: string[];
|
deviceIds: string[];
|
||||||
|
semaphoreSetupTemplateId: string;
|
||||||
|
semaphoreTeardownTemplateId: string;
|
||||||
|
scope: 'global' | 'personal';
|
||||||
}>({
|
}>({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
contactPerson: '',
|
contactPerson: '',
|
||||||
location: '',
|
location: '',
|
||||||
deviceIds: []
|
deviceIds: [],
|
||||||
|
semaphoreSetupTemplateId: '',
|
||||||
|
semaphoreTeardownTemplateId: '',
|
||||||
|
scope: 'global',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate filtered devices associated with selected lab
|
// Calculate filtered devices associated with selected lab
|
||||||
@ -66,7 +78,10 @@ export default function LabTemplates({
|
|||||||
description: '',
|
description: '',
|
||||||
contactPerson: '',
|
contactPerson: '',
|
||||||
location: '',
|
location: '',
|
||||||
deviceIds: []
|
deviceIds: [],
|
||||||
|
semaphoreSetupTemplateId: '',
|
||||||
|
semaphoreTeardownTemplateId: '',
|
||||||
|
scope: 'global',
|
||||||
});
|
});
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
};
|
};
|
||||||
@ -80,7 +95,10 @@ export default function LabTemplates({
|
|||||||
description: lab.description,
|
description: lab.description,
|
||||||
contactPerson: lab.contactPerson,
|
contactPerson: lab.contactPerson,
|
||||||
location: lab.location,
|
location: lab.location,
|
||||||
deviceIds: [...lab.deviceIds]
|
deviceIds: [...lab.deviceIds],
|
||||||
|
semaphoreSetupTemplateId: lab.semaphoreSetupTemplateId || '',
|
||||||
|
semaphoreTeardownTemplateId: lab.semaphoreTeardownTemplateId || '',
|
||||||
|
scope: lab.scope ?? 'global',
|
||||||
});
|
});
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
};
|
};
|
||||||
@ -124,21 +142,110 @@ export default function LabTemplates({
|
|||||||
contactPerson: formData.contactPerson,
|
contactPerson: formData.contactPerson,
|
||||||
location: formData.location,
|
location: formData.location,
|
||||||
deviceIds: formData.deviceIds,
|
deviceIds: formData.deviceIds,
|
||||||
topology: tempLinks
|
topology: tempLinks,
|
||||||
|
semaphoreSetupTemplateId: formData.semaphoreSetupTemplateId,
|
||||||
|
semaphoreTeardownTemplateId: formData.semaphoreTeardownTemplateId,
|
||||||
|
scope: formData.scope,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (formMode === 'add') {
|
if (formMode === 'add') {
|
||||||
onAddLab(savedLabData);
|
onAddLab(savedLabData);
|
||||||
} else if (formMode === 'edit' && formData.id) {
|
} else if (formMode === 'edit' && formData.id) {
|
||||||
|
const existing = labs.find(l => l.id === formData.id);
|
||||||
onUpdateLab({
|
onUpdateLab({
|
||||||
...savedLabData,
|
...savedLabData,
|
||||||
id: formData.id
|
id: formData.id,
|
||||||
|
ownerId: existing?.ownerId ?? '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isAdmin = currentUser.role?.toLowerCase() === 'admin';
|
||||||
|
const canEdit = (lab: LabTemplate) => isAdmin || lab.ownerId === currentUser.id || lab.ownerId === '';
|
||||||
|
|
||||||
|
const myPersonalLabs = labs.filter(l => l.scope === 'personal' && l.ownerId === currentUser.id);
|
||||||
|
const globalLabs = labs.filter(l => l.scope === 'global');
|
||||||
|
const othersPersonal = isAdmin ? labs.filter(l => l.scope === 'personal' && l.ownerId !== currentUser.id) : [];
|
||||||
|
|
||||||
|
const renderLabCard = (lab: LabTemplate) => {
|
||||||
|
const isSelected = selectedLab?.id === lab.id;
|
||||||
|
const editable = canEdit(lab);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={lab.id}
|
||||||
|
onClick={() => setSelectedLab(lab)}
|
||||||
|
className={`p-4 rounded-xl border transition-all cursor-pointer relative ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-slate-900 border-emerald-500'
|
||||||
|
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<h3 className="font-bold text-sm text-white">{lab.name}</h3>
|
||||||
|
<div className="flex gap-1.5" onClick={e => e.stopPropagation()}>
|
||||||
|
{editable && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEdit(lab)}
|
||||||
|
className="text-slate-400 hover:text-indigo-400 p-0.5"
|
||||||
|
title="Edit template configuration"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Are you sure you want to delete lab template "${lab.name}"? Active/completed scheduling logs are retained in the system.`)) {
|
||||||
|
onDeleteLab(lab.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-slate-400 hover:text-rose-400 p-0.5"
|
||||||
|
title="Delete template"
|
||||||
|
>
|
||||||
|
<Trash className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">
|
||||||
|
{lab.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-800/80 grid grid-cols-2 gap-1 text-[10px] text-slate-350">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<UserIcon className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||||
|
<span className="truncate">{lab.contactPerson}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||||
|
<span className="truncate">{lab.location}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] font-mono text-emerald-400 bg-emerald-950/50 px-2 py-0.5 rounded border border-emerald-900/50">
|
||||||
|
{lab.deviceIds.length} connected devices
|
||||||
|
</span>
|
||||||
|
{lab.scope === 'personal' ? (
|
||||||
|
<span className="text-[10px] font-mono text-indigo-400 bg-indigo-950/50 px-2 py-0.5 rounded border border-indigo-900/50 flex items-center gap-1">
|
||||||
|
<Lock className="w-2.5 h-2.5" /> Personal
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] font-mono text-slate-400 bg-slate-950/50 px-2 py-0.5 rounded border border-slate-800/50 flex items-center gap-1">
|
||||||
|
<Globe className="w-2.5 h-2.5" /> Global
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronRight className={`w-4 h-4 text-slate-500 transition-transform ${isSelected ? 'translate-x-1 text-emerald-400' : ''}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="lab-templates-container">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="lab-templates-container">
|
||||||
|
|
||||||
@ -162,68 +269,29 @@ export default function LabTemplates({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Labs templates list */}
|
{/* Labs templates list — sectioned */}
|
||||||
<div className="space-y-3 flex-1 overflow-y-auto max-h-[550px] pr-1">
|
<div className="space-y-3 flex-1 overflow-y-auto max-h-[550px] pr-1">
|
||||||
{labs.map((lab) => {
|
{myPersonalLabs.length > 0 && (
|
||||||
const isSelected = selectedLab?.id === lab.id;
|
<>
|
||||||
return (
|
<p className="text-[10px] font-mono uppercase tracking-widest text-indigo-400 px-1">My Topologies</p>
|
||||||
<div
|
{myPersonalLabs.map(renderLabCard)}
|
||||||
key={lab.id}
|
</>
|
||||||
onClick={() => setSelectedLab(lab)}
|
)}
|
||||||
className={`p-4 rounded-xl border transition-all cursor-pointer relative ${
|
{globalLabs.length > 0 && (
|
||||||
isSelected
|
<>
|
||||||
? 'bg-slate-900 border-emerald-500'
|
<p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Global Topologies</p>
|
||||||
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
|
{globalLabs.map(renderLabCard)}
|
||||||
}`}
|
</>
|
||||||
>
|
)}
|
||||||
<div className="flex justify-between items-start">
|
{othersPersonal.length > 0 && (
|
||||||
<h3 className="font-bold text-sm text-white">{lab.name}</h3>
|
<>
|
||||||
<div className="flex gap-1.5" onClick={e => e.stopPropagation()}>
|
<p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Others' Personal</p>
|
||||||
<button
|
{othersPersonal.map(renderLabCard)}
|
||||||
onClick={() => handleOpenEdit(lab)}
|
</>
|
||||||
className="text-slate-400 hover:text-indigo-400 p-0.5"
|
)}
|
||||||
title="Edit template configuration"
|
{labs.length === 0 && (
|
||||||
>
|
<p className="text-xs text-slate-500 text-center py-8">No topology templates yet.</p>
|
||||||
<Edit3 className="w-3.5 h-3.5" />
|
)}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (confirm(`Are you sure you want to delete lab template "${lab.name}"? Active/completed scheduling logs are retained in the system.`)) {
|
|
||||||
onDeleteLab(lab.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-slate-400 hover:text-rose-400 p-0.5"
|
|
||||||
title="Delete template"
|
|
||||||
>
|
|
||||||
<Trash className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">
|
|
||||||
{lab.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-3 pt-3 border-t border-slate-800/80 grid grid-cols-2 gap-1 text-[10px] text-slate-350">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<User className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
|
||||||
<span className="truncate">{lab.contactPerson}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<MapPin className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
|
||||||
<span className="truncate">{lab.location}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center justify-between">
|
|
||||||
<span className="text-[10px] font-mono text-emerald-400 bg-emerald-950/50 px-2 py-0.5 rounded border border-emerald-900/50">
|
|
||||||
{lab.deviceIds.length} connected devices
|
|
||||||
</span>
|
|
||||||
<ChevronRight className={`w-4 h-4 text-slate-500 transition-transform ${isSelected ? 'translate-x-1 text-emerald-400' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -242,7 +310,7 @@ export default function LabTemplates({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="bg-slate-900 p-2 rounded-lg border border-slate-805 text-[10px] flex items-center gap-1">
|
<div className="bg-slate-900 p-2 rounded-lg border border-slate-805 text-[10px] flex items-center gap-1">
|
||||||
<User className="w-3.5 h-3.5 text-slate-400" />
|
<UserIcon className="w-3.5 h-3.5 text-slate-400" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-400 leading-none">Primary Contact</p>
|
<p className="text-slate-400 leading-none">Primary Contact</p>
|
||||||
<p className="text-white font-semibold mt-0.5">{selectedLab.contactPerson}</p>
|
<p className="text-white font-semibold mt-0.5">{selectedLab.contactPerson}</p>
|
||||||
@ -335,7 +403,6 @@ export default function LabTemplates({
|
|||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
||||||
placeholder="e.g. Campus Core OSPF Backup Route"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -346,7 +413,6 @@ export default function LabTemplates({
|
|||||||
value={formData.location}
|
value={formData.location}
|
||||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
||||||
placeholder="e.g. Server Room R01, Cabinet B"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -361,7 +427,6 @@ export default function LabTemplates({
|
|||||||
value={formData.contactPerson}
|
value={formData.contactPerson}
|
||||||
onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
||||||
placeholder="e.g. Jane Doe"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -372,11 +437,39 @@ export default function LabTemplates({
|
|||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
||||||
placeholder="Purpose, VLAN mappings, target device models..."
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scope toggle */}
|
||||||
|
<div className="border-t border-slate-800 pt-3">
|
||||||
|
<label className="block text-slate-300 font-semibold mb-1.5">Visibility</label>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, scope: 'global' })}
|
||||||
|
className={`py-2 rounded-lg text-xs font-semibold border transition-all flex items-center justify-center gap-1.5 ${
|
||||||
|
formData.scope === 'global'
|
||||||
|
? 'bg-slate-800 border-slate-500 text-slate-200'
|
||||||
|
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Globe className="w-3.5 h-3.5" /> Global — visible to all
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, scope: 'personal' })}
|
||||||
|
className={`py-2 rounded-lg text-xs font-semibold border transition-all flex items-center justify-center gap-1.5 ${
|
||||||
|
formData.scope === 'personal'
|
||||||
|
? 'bg-indigo-900/30 border-indigo-500 text-indigo-300'
|
||||||
|
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Lock className="w-3.5 h-3.5" /> Personal — only you
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Hardware checklist */}
|
{/* Hardware checklist */}
|
||||||
<div className="border-t border-slate-800 pt-3">
|
<div className="border-t border-slate-800 pt-3">
|
||||||
<label className="block text-slate-300 font-bold mb-1.5">1. Assign Dedicated Hardware Nodes</label>
|
<label className="block text-slate-300 font-bold mb-1.5">1. Assign Dedicated Hardware Nodes</label>
|
||||||
@ -446,7 +539,6 @@ export default function LabTemplates({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="w-full bg-slate-950 text-slate-250 border border-slate-805 p-1 rounded font-mono text-[11px]"
|
className="w-full bg-slate-950 text-slate-250 border border-slate-805 p-1 rounded font-mono text-[11px]"
|
||||||
placeholder="e.g. LACP Port-Channel 1"
|
|
||||||
value={linkType}
|
value={linkType}
|
||||||
onChange={(e) => setLinkType(e.target.value)}
|
onChange={(e) => setLinkType(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -467,19 +559,54 @@ export default function LabTemplates({
|
|||||||
{tempLinks.map((link, idx) => {
|
{tempLinks.map((link, idx) => {
|
||||||
const fromDev = devices.find(d => d.id === link.fromDevice)?.hostname || link.fromDevice;
|
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 toDev = devices.find(d => d.id === link.toDevice)?.hostname || link.toDevice;
|
||||||
|
const isEditingThis = editingLinkIdx === idx;
|
||||||
return (
|
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">
|
<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">
|
<span className="text-slate-300 shrink-0"><strong>{fromDev}</strong> ────</span>
|
||||||
<strong>{fromDev}</strong> ──── {link.type} ──── <strong>{toDev}</strong>
|
{isEditingThis ? (
|
||||||
</span>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRemoveLink(idx)}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@ -488,6 +615,37 @@ export default function LabTemplates({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ansible Semaphore Automation */}
|
||||||
|
{semaphoreEnabled && <div className="border-t border-slate-800 pt-3">
|
||||||
|
<label className="block text-slate-300 font-bold mb-1.5 flex items-center gap-1.5">
|
||||||
|
<Terminal className="w-3.5 h-3.5 text-orange-400" />
|
||||||
|
3. Ansible Automation (optional)
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
|
||||||
{/* Form submit handlers */}
|
{/* Form submit handlers */}
|
||||||
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
|
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
|
||||||
<button
|
<button
|
||||||
@ -501,7 +659,7 @@ export default function LabTemplates({
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs animate-none"
|
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs animate-none"
|
||||||
>
|
>
|
||||||
Save Lab Template
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -43,6 +43,8 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
|
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
|
||||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||||
|
const [editingDescId, setEditingDescId] = useState<string | null>(null);
|
||||||
|
const [descDraft, setDescDraft] = useState('');
|
||||||
|
|
||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
@ -97,6 +99,16 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
setDraft(EMPTY_DRAFT);
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const title = draft.title.trim();
|
const title = draft.title.trim();
|
||||||
@ -156,7 +168,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
<span className="absolute left-3 top-2.5 text-slate-550"><Search className="w-4 h-4" /></span>
|
<span className="absolute left-3 top-2.5 text-slate-550"><Search className="w-4 h-4" /></span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search links by name, host, category…"
|
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none focus:border-emerald-600"
|
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none focus:border-emerald-600"
|
||||||
@ -239,8 +250,27 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{link.description && (
|
{editingDescId === link.id ? (
|
||||||
<p className="text-[11px] text-slate-400 leading-relaxed mt-3 line-clamp-2">{link.description}</p>
|
<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"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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 */}
|
{/* Hover actions */}
|
||||||
@ -293,7 +323,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
required autoFocus
|
required autoFocus
|
||||||
value={draft.title}
|
value={draft.title}
|
||||||
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
|
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
|
||||||
placeholder="e.g. CheckMK Monitoring"
|
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
|
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -304,7 +333,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
required
|
required
|
||||||
value={draft.url}
|
value={draft.url}
|
||||||
onChange={(e) => setDraft({ ...draft, url: e.target.value })}
|
onChange={(e) => setDraft({ ...draft, url: e.target.value })}
|
||||||
placeholder="https://checkmk.internal"
|
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600 font-mono"
|
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600 font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -315,7 +343,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
list="link-categories"
|
list="link-categories"
|
||||||
value={draft.category}
|
value={draft.category}
|
||||||
onChange={(e) => setDraft({ ...draft, category: e.target.value })}
|
onChange={(e) => setDraft({ ...draft, category: e.target.value })}
|
||||||
placeholder="e.g. Monitoring, Automation, Docs"
|
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
|
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
|
||||||
/>
|
/>
|
||||||
<datalist id="link-categories">
|
<datalist id="link-categories">
|
||||||
@ -329,7 +356,6 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
rows={2}
|
rows={2}
|
||||||
value={draft.description}
|
value={draft.description}
|
||||||
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||||
placeholder="What is this tool for?"
|
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600 resize-none"
|
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -33,7 +33,9 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
// Filter logs
|
// Filter logs
|
||||||
const filteredLogs = sortedLogs.filter(log => {
|
const filteredLogs = sortedLogs.filter(log => {
|
||||||
const matchesSearch = log.message.toLowerCase().includes(searchTerm.toLowerCase());
|
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;
|
return matchesSearch && matchesType;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -115,24 +117,29 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Filter audit log entries..."
|
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full bg-slate-950 text-slate-101 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none"
|
className="w-full bg-slate-950 text-slate-101 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 shrink-0 text-xs font-medium">
|
<div className="flex gap-1 shrink-0 text-xs font-medium flex-wrap">
|
||||||
{['all', 'booking', 'maintenance'].map((type) => (
|
{[
|
||||||
|
{ key: 'all', label: 'All' },
|
||||||
|
{ key: 'booking', label: 'Booking' },
|
||||||
|
{ key: 'maintenance',label: 'Maintenance' },
|
||||||
|
{ key: 'status', label: 'Status' },
|
||||||
|
{ key: 'system', label: 'System' },
|
||||||
|
].map(({ key, label }) => (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={key}
|
||||||
onClick={() => setTypeFilter(type)}
|
onClick={() => setTypeFilter(key)}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium capitalize transition-all ${
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
typeFilter === type
|
typeFilter === key
|
||||||
? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400'
|
? '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'
|
: 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{type === 'all' ? 'All' : getLogTypeLabel(type)}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -231,7 +238,6 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
value={logMessage}
|
value={logMessage}
|
||||||
onChange={(e) => setLogMessage(e.target.value)}
|
onChange={(e) => setLogMessage(e.target.value)}
|
||||||
className="w-full bg-slate-950 text-slate-200 border border-slate-800 rounded p-2 focus:outline-none"
|
className="w-full bg-slate-950 text-slate-200 border border-slate-800 rounded p-2 focus:outline-none"
|
||||||
placeholder="Describe SFP fiber replacements, port trunking alterations, routing table diagnostic evaluations..."
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { GhostGridLogo } from './Header';
|
import { GhostGridLogo } from './Header';
|
||||||
import { authFetch, saveSession } from '../lib/auth';
|
import { authFetch, saveSession } from '../lib/auth';
|
||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
@ -7,14 +7,23 @@ import { LogIn, Eye, EyeOff, AlertCircle } from 'lucide-react';
|
|||||||
interface LoginPageProps {
|
interface LoginPageProps {
|
||||||
onLogin: (user: User) => void;
|
onLogin: (user: User) => void;
|
||||||
onNavigateToRegister: () => 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 [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState(authError || '');
|
||||||
const [loading, setLoading] = useState(false);
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -93,7 +102,6 @@ export default function LoginPage({ onLogin, onNavigateToRegister }: LoginPagePr
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={e => setEmail(e.target.value)}
|
onChange={e => setEmail(e.target.value)}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
||||||
placeholder="user@airit.rocks"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -110,7 +118,6 @@ export default function LoginPage({ onLogin, onNavigateToRegister }: LoginPagePr
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 pr-10 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 pr-10 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -137,6 +144,30 @@ export default function LoginPage({ onLogin, onNavigateToRegister }: LoginPagePr
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</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">
|
<p className="text-center text-xs text-slate-400">
|
||||||
No account yet?{' '}
|
No account yet?{' '}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -106,7 +106,6 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={e => setName(e.target.value)}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
||||||
placeholder="Max Mustermann"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -122,7 +121,6 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={e => setEmail(e.target.value)}
|
onChange={e => setEmail(e.target.value)}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -139,7 +137,6 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 pr-10 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 pr-10 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
||||||
placeholder="Min. 8 characters"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -174,7 +171,6 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
? 'border-red-700 focus:ring-red-500/50'
|
? 'border-red-700 focus:ring-red-500/50'
|
||||||
: 'border-slate-700 focus:ring-cyan-500/50 focus:border-cyan-500/50'
|
: 'border-slate-700 focus:ring-cyan-500/50 focus:border-cyan-500/50'
|
||||||
}`}
|
}`}
|
||||||
placeholder="Repeat password"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
1220
src/components/Settings.tsx
Normal file
1220
src/components/Settings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -271,7 +271,6 @@ export default function TopologyPanel({ devices, links, onSelectDevice }: Topolo
|
|||||||
>
|
>
|
||||||
{/* Status Indicator (sourced from CheckMK; grey = unknown) */}
|
{/* 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 ${
|
<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 === 'online' ? 'bg-emerald-500' :
|
||||||
device.status === 'offline' ? 'bg-rose-500' : 'bg-slate-500'
|
device.status === 'offline' ? 'bg-rose-500' : 'bg-slate-500'
|
||||||
}`} />
|
}`} />
|
||||||
|
|||||||
@ -1,19 +1,16 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { User, Booking } from '../types';
|
import { User, Booking } from '../types';
|
||||||
import { Users, Search, Mail, Calendar, Activity } from 'lucide-react';
|
import { Users, Search, Mail, Calendar, Activity, Trash2, Pencil, X, Save, AlertCircle, ShieldCheck, Shield } from 'lucide-react';
|
||||||
|
|
||||||
interface UserDirectoryProps {
|
interface UserDirectoryProps {
|
||||||
users: User[];
|
users: User[];
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
bookings: Booking[];
|
bookings: Booking[];
|
||||||
|
onDeleteUser: (id: string) => Promise<void>;
|
||||||
|
onUpdateUser: (id: string, name: string, email: string) => Promise<void>;
|
||||||
|
onSetRole: (id: string, role: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deterministic accent so a given user always renders the same colour.
|
|
||||||
const AVATAR_COLORS = [
|
const AVATAR_COLORS = [
|
||||||
'from-emerald-500 to-teal-600',
|
'from-emerald-500 to-teal-600',
|
||||||
'from-cyan-500 to-blue-600',
|
'from-cyan-500 to-blue-600',
|
||||||
@ -35,8 +32,99 @@ function initials(name: string): string {
|
|||||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
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, onSetRole }: UserDirectoryProps) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [togglingRoleId, setTogglingRoleId] = useState<string | null>(null);
|
||||||
|
const [roleError, setRoleError] = useState<string | null>(null);
|
||||||
|
const isCurrentUserAdmin = currentUser.role.toLowerCase() === 'admin';
|
||||||
|
|
||||||
const bookingCount = useMemo(() => {
|
const bookingCount = useMemo(() => {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
@ -63,6 +151,28 @@ export default function UserDirectory({ users, currentUser, bookings }: UserDire
|
|||||||
);
|
);
|
||||||
}, [users, search]);
|
}, [users, search]);
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
setDeletingId(id);
|
||||||
|
try { await onDeleteUser(id); } finally { setDeletingId(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleRole(user: User) {
|
||||||
|
setTogglingRoleId(user.id);
|
||||||
|
setRoleError(null);
|
||||||
|
try {
|
||||||
|
await onSetRole(user.id, user.role.toLowerCase() === 'admin' ? 'User' : 'admin');
|
||||||
|
} catch (err: any) {
|
||||||
|
setRoleError(err.message || 'Failed to change role.');
|
||||||
|
} finally {
|
||||||
|
setTogglingRoleId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit(name: string, email: string) {
|
||||||
|
if (!editingUser) return;
|
||||||
|
await onUpdateUser(editingUser.id, name, email);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 font-sans" id="user-directory-root">
|
<div className="space-y-6 font-sans" id="user-directory-root">
|
||||||
|
|
||||||
@ -77,9 +187,8 @@ export default function UserDirectory({ users, currentUser, bookings }: UserDire
|
|||||||
Registered Operators
|
Registered Operators
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-300 max-w-xl leading-relaxed">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 pt-3">
|
<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">
|
<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" />
|
<Users className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
@ -97,14 +206,21 @@ Everyone with an account on this box. booking counts come straight from the shar
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{roleError && (
|
||||||
|
<div className="flex items-center gap-2 bg-red-950/50 border border-red-900/50 rounded-lg px-3 py-2 text-xs text-red-300">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
{roleError}
|
||||||
|
<button onClick={() => setRoleError(null)} className="ml-auto text-red-400 hover:text-white"><X className="w-3 h-3" /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute left-3 top-2.5 text-slate-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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search operators by name, email or role…"
|
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
className="w-full bg-[#1E293B] text-slate-100 border border-slate-800 rounded-xl pl-9 pr-4 py-2 text-xs focus:outline-none focus:border-emerald-600"
|
className="w-full bg-[#1E293B] text-slate-100 border border-slate-800 rounded-xl pl-9 pr-4 py-2 text-xs focus:outline-none focus:border-emerald-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -118,6 +234,7 @@ Everyone with an account on this box. booking counts come straight from the shar
|
|||||||
const isMe = user.id === currentUser.id;
|
const isMe = user.id === currentUser.id;
|
||||||
const total = bookingCount.get(user.id) ?? 0;
|
const total = bookingCount.get(user.id) ?? 0;
|
||||||
const active = activeCount.get(user.id) ?? 0;
|
const active = activeCount.get(user.id) ?? 0;
|
||||||
|
const isDeleting = deletingId === user.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={user.id}
|
key={user.id}
|
||||||
@ -145,9 +262,13 @@ Everyone with an account on this box. booking counts come straight from the shar
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
{user.role.toLowerCase() === 'admin'
|
||||||
|
? <span className="flex items-center gap-1 text-[10px] font-mono text-amber-400 uppercase tracking-wider"><ShieldCheck className="w-3 h-3" />Admin</span>
|
||||||
|
: <span className="text-[10px] font-mono text-slate-500 uppercase tracking-wider">User</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-3 text-[10px] font-mono text-slate-400">
|
<div className="flex items-center gap-3 text-[10px] font-mono text-slate-400">
|
||||||
<span className="flex items-center gap-1" title="Total bookings">
|
<span className="flex items-center gap-1" title="Total bookings">
|
||||||
<Calendar className="w-3 h-3 text-indigo-400" />
|
<Calendar className="w-3 h-3 text-indigo-400" />
|
||||||
@ -158,12 +279,58 @@ Everyone with an account on this box. booking counts come straight from the shar
|
|||||||
{active}
|
{active}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-1 ml-1">
|
||||||
|
{isCurrentUserAdmin && !isMe && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleRole(user)}
|
||||||
|
disabled={togglingRoleId === user.id}
|
||||||
|
className={`p-1.5 rounded-lg transition-all disabled:opacity-40 ${user.role.toLowerCase() === 'admin' ? 'text-amber-400 hover:text-slate-400 hover:bg-slate-900' : 'text-slate-500 hover:text-amber-400 hover:bg-slate-900'}`}
|
||||||
|
title={user.role.toLowerCase() === 'admin' ? 'Remove admin' : 'Make admin'}
|
||||||
|
>
|
||||||
|
{togglingRoleId === user.id
|
||||||
|
? <span className="w-3.5 h-3.5 border-2 border-slate-600 border-t-amber-400 rounded-full animate-spin inline-block" />
|
||||||
|
: user.role.toLowerCase() === 'admin'
|
||||||
|
? <ShieldCheck className="w-3.5 h-3.5" />
|
||||||
|
: <Shield className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingUser(user)}
|
||||||
|
className="p-1.5 rounded-lg text-slate-500 hover:text-cyan-400 hover:bg-slate-900 transition-all"
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingUser && (
|
||||||
|
<EditModal
|
||||||
|
user={editingUser}
|
||||||
|
onClose={() => setEditingUser(null)}
|
||||||
|
onSave={handleSaveEdit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
393
src/index.css
393
src/index.css
@ -54,7 +54,7 @@
|
|||||||
color: var(--text) !important;
|
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\],
|
||||||
:root.light .bg-\[\#0b0f19\] {
|
:root.light .bg-\[\#0b0f19\] {
|
||||||
background-color: var(--bg) !important;
|
background-color: var(--bg) !important;
|
||||||
@ -72,11 +72,11 @@
|
|||||||
border-color: var(--border) !important;
|
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\],
|
||||||
:root.light .bg-\[\#1d2535\] {
|
:root.light .bg-\[\#1d2535\] {
|
||||||
background-color: #fffbeb !important;
|
background-color: #f0fdf4 !important;
|
||||||
border-color: #fde68a !important;
|
border-color: #bbf7d0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header & nav ─────────────────────────────────────────────── */
|
/* ── Header & nav ─────────────────────────────────────────────── */
|
||||||
@ -109,6 +109,7 @@
|
|||||||
:root.light .bg-slate-950\/20,
|
:root.light .bg-slate-950\/20,
|
||||||
:root.light .bg-slate-950\/30,
|
:root.light .bg-slate-950\/30,
|
||||||
:root.light .bg-slate-950\/40,
|
:root.light .bg-slate-950\/40,
|
||||||
|
:root.light .bg-slate-950\/50,
|
||||||
:root.light .bg-slate-950\/60,
|
:root.light .bg-slate-950\/60,
|
||||||
:root.light .bg-slate-950\/80 {
|
:root.light .bg-slate-950\/80 {
|
||||||
background-color: rgba(241, 245, 249, 0.85) !important;
|
background-color: rgba(241, 245, 249, 0.85) !important;
|
||||||
@ -128,8 +129,10 @@
|
|||||||
background-color: #e9ecf0 !important;
|
background-color: #e9ecf0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Dashboard banner gradient ────────────────────────────────── */
|
/* ── Dashboard / UserDirectory banner gradients (dark hex only) ── */
|
||||||
:root.light .bg-gradient-to-br {
|
/* 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;
|
background-image: linear-gradient(to bottom right, #ffffff, #f1f5f9) !important;
|
||||||
border-color: var(--border) !important;
|
border-color: var(--border) !important;
|
||||||
}
|
}
|
||||||
@ -155,6 +158,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Borders ──────────────────────────────────────────────────── */
|
/* ── Borders ──────────────────────────────────────────────────── */
|
||||||
|
:root.light .border-slate-900,
|
||||||
:root.light .border-slate-800,
|
:root.light .border-slate-800,
|
||||||
:root.light .border-slate-850,
|
:root.light .border-slate-850,
|
||||||
:root.light .border-slate-855,
|
:root.light .border-slate-855,
|
||||||
@ -164,6 +168,12 @@
|
|||||||
border-color: var(--border) !important;
|
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-800 > *,
|
||||||
:root.light .divide-slate-850 > * {
|
:root.light .divide-slate-850 > * {
|
||||||
border-color: var(--border) !important;
|
border-color: var(--border) !important;
|
||||||
@ -171,6 +181,9 @@
|
|||||||
|
|
||||||
/* ── Text colours ─────────────────────────────────────────────── */
|
/* ── Text colours ─────────────────────────────────────────────── */
|
||||||
:root.light .text-white,
|
: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-100,
|
||||||
:root.light .text-slate-200 {
|
:root.light .text-slate-200 {
|
||||||
color: var(--text) !important;
|
color: var(--text) !important;
|
||||||
@ -238,6 +251,32 @@
|
|||||||
color: #9f1239 !important;
|
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 {
|
:root.light .bg-cyan-950\/40 {
|
||||||
background-color: #cffafe !important;
|
background-color: #cffafe !important;
|
||||||
border-color: #67e8f9 !important;
|
border-color: #67e8f9 !important;
|
||||||
@ -460,6 +499,24 @@
|
|||||||
color: inherit !important;
|
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 - always white text on navy, regardless of theme ─ */
|
||||||
.airit-badge {
|
.airit-badge {
|
||||||
color: #ffffff !important;
|
color: #ffffff !important;
|
||||||
@ -480,21 +537,63 @@
|
|||||||
border-color: var(--border) !important;
|
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 {
|
:root.light #booking-details-modal .font-mono.bg-slate-950 {
|
||||||
background-color: #0d1117 !important;
|
background-color: #f6f8fa !important;
|
||||||
color: #c9d1d9 !important;
|
color: #24292f !important;
|
||||||
border-color: #30363d !important;
|
border-color: #d0d7de !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Terminal output area bg-slate-1000 */
|
/* The <pre> inside inherits the dark pre-rule; override explicitly */
|
||||||
:root.light #booking-details-modal .bg-slate-1000 {
|
:root.light #booking-details-modal .font-mono.bg-slate-950 pre {
|
||||||
background-color: #0d1117 !important;
|
background-color: transparent !important;
|
||||||
color: #c9d1d9 !important;
|
color: #24292f !important;
|
||||||
border-color: #30363d !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 {
|
:root.light #booking-details-modal .bg-slate-900\/40 {
|
||||||
background-color: #f1f5f9 !important;
|
background-color: #f1f5f9 !important;
|
||||||
border-color: var(--border) !important;
|
border-color: var(--border) !important;
|
||||||
@ -557,3 +656,265 @@
|
|||||||
:root.light #add-link-btn:hover {
|
:root.light #add-link-btn:hover {
|
||||||
background-color: #6366f1 !important;
|
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-800 with opacity */
|
||||||
|
:root.light .border-slate-800\/50,
|
||||||
|
:root.light .border-slate-800\/40 {
|
||||||
|
border-color: var(--border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* slate-700 with opacity */
|
||||||
|
:root.light .border-slate-700\/40,
|
||||||
|
:root.light .border-slate-700\/50,
|
||||||
|
: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;
|
||||||
|
}
|
||||||
|
|||||||
12
src/types.ts
12
src/types.ts
@ -14,9 +14,9 @@ export interface Device {
|
|||||||
location: string;
|
location: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
type: DeviceType;
|
type: DeviceType;
|
||||||
status: 'online' | 'offline' | 'unknown'; // 'unknown' until CheckMK reports a state
|
status: 'online' | 'offline' | 'unknown';
|
||||||
emergencySheet: string; // Markdown text
|
emergencySheet: string; // Markdown text
|
||||||
checkMkUrl: string; // Link to this host in CheckMK; live status comes from the CheckMK API
|
cmkHostname?: string;
|
||||||
lastCheckedAt?: string;
|
lastCheckedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,6 +34,10 @@ export interface LabTemplate {
|
|||||||
location: string;
|
location: string;
|
||||||
deviceIds: string[];
|
deviceIds: string[];
|
||||||
topology: TopologyLink[];
|
topology: TopologyLink[];
|
||||||
|
semaphoreSetupTemplateId?: string;
|
||||||
|
semaphoreTeardownTemplateId?: string;
|
||||||
|
scope: 'global' | 'personal';
|
||||||
|
ownerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Booking {
|
export interface Booking {
|
||||||
@ -46,6 +50,10 @@ export interface Booking {
|
|||||||
status: 'active' | 'upcoming' | 'completed' | 'cancelled';
|
status: 'active' | 'upcoming' | 'completed' | 'cancelled';
|
||||||
notified: boolean;
|
notified: boolean;
|
||||||
emailSent?: boolean;
|
emailSent?: boolean;
|
||||||
|
ansibleSetupTriggered?: boolean;
|
||||||
|
ansibleTeardownTriggered?: boolean;
|
||||||
|
ansibleSetupJobId?: string;
|
||||||
|
ansibleTeardownJobId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogEntry {
|
export interface LogEntry {
|
||||||
|
|||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
Reference in New Issue
Block a user